525 lines
16 KiB
JavaScript

#!/usr/bin/env node
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
// symbol polyfill must go first
import 'symbol-observable';
import { JsonValue, logging, schema } from '@angular-devkit/core';
import { ProcessOutput, createConsoleLogger } from '@angular-devkit/core/node';
import { UnsuccessfulWorkflowExecution } from '@angular-devkit/schematics';
import { NodeWorkflow } from '@angular-devkit/schematics/tools';
import ansiColors from 'ansi-colors';
import { existsSync } from 'node:fs';
import * as path from 'node:path';
import yargsParser, { camelCase, decamelize } from 'yargs-parser';
/**
* Parse the name of schematic passed in argument, and return a {collection, schematic} named
* tuple. The user can pass in `collection-name:schematic-name`, and this function will either
* return `{collection: 'collection-name', schematic: 'schematic-name'}`, or it will error out
* and show usage.
*
* In the case where a collection name isn't part of the argument, the default is to use the
* schematics package (@angular-devkit/schematics-cli) as the collection.
*
* This logic is entirely up to the tooling.
*
* @param str The argument to parse.
* @return {{collection: string, schematic: (string)}}
*/
function parseSchematicName(str: string | null): { collection: string; schematic: string | null } {
let collection = '@angular-devkit/schematics-cli';
let schematic = str;
if (schematic?.includes(':')) {
const lastIndexOfColon = schematic.lastIndexOf(':');
[collection, schematic] = [
schematic.slice(0, lastIndexOfColon),
schematic.substring(lastIndexOfColon + 1),
];
}
return { collection, schematic };
}
function removeLeadingSlash(value: string): string {
return value[0] === '/' ? value.slice(1) : value;
}
export interface MainOptions {
args: string[];
stdout?: ProcessOutput;
stderr?: ProcessOutput;
}
function _listSchematics(workflow: NodeWorkflow, collectionName: string, logger: logging.Logger) {
try {
const collection = workflow.engine.createCollection(collectionName);
logger.info(collection.listSchematicNames().join('\n'));
} catch (error) {
logger.fatal(error instanceof Error ? error.message : `${error}`);
return 1;
}
return 0;
}
function _createPromptProvider(): schema.PromptProvider {
return async (definitions) => {
let prompts: typeof import('@inquirer/prompts') | undefined;
const answers: Record<string, JsonValue> = {};
for (const definition of definitions) {
// Only load prompt package if needed
prompts ??= await import('@inquirer/prompts');
switch (definition.type) {
case 'confirmation':
answers[definition.id] = await prompts.confirm({
message: definition.message,
default: definition.default as boolean | undefined,
});
break;
case 'list':
if (!definition.items?.length) {
continue;
}
answers[definition.id] = await (
definition.multiselect ? prompts.checkbox : prompts.select
)({
message: definition.message,
default: definition.default,
validate: (values) => {
if (!definition.validator) {
return true;
}
return definition.validator(Object.values(values).map(({ value }) => value));
},
choices: definition.items.map((item) =>
typeof item == 'string'
? {
name: item,
value: item,
}
: {
name: item.label,
value: item.value,
},
),
});
break;
case 'input': {
let finalValue: JsonValue | undefined;
answers[definition.id] = await prompts.input({
message: definition.message,
default: definition.default as string | undefined,
async validate(value) {
if (definition.validator === undefined) {
return true;
}
let lastValidation: ReturnType<typeof definition.validator> = false;
for (const type of definition.propertyTypes) {
let potential;
switch (type) {
case 'string':
potential = String(value);
break;
case 'integer':
case 'number':
potential = Number(value);
break;
default:
potential = value;
break;
}
lastValidation = await definition.validator(potential);
// Can be a string if validation fails
if (lastValidation === true) {
finalValue = potential;
return true;
}
}
return lastValidation;
},
});
// Use validated value if present.
// This ensures the correct type is inserted into the final schema options.
if (finalValue !== undefined) {
answers[definition.id] = finalValue;
}
break;
}
}
}
return answers;
};
}
function findUp(names: string | string[], from: string) {
if (!Array.isArray(names)) {
names = [names];
}
const root = path.parse(from).root;
let currentDir = from;
while (currentDir && currentDir !== root) {
for (const name of names) {
const p = path.join(currentDir, name);
if (existsSync(p)) {
return p;
}
}
currentDir = path.dirname(currentDir);
}
return null;
}
/**
* return package manager' name by lock file
*/
function getPackageManagerName() {
// order by check priority
const LOCKS: Record<string, string> = {
'package-lock.json': 'npm',
'yarn.lock': 'yarn',
'pnpm-lock.yaml': 'pnpm',
};
const lockPath = findUp(Object.keys(LOCKS), process.cwd());
if (lockPath) {
return LOCKS[path.basename(lockPath)];
}
return 'npm';
}
// eslint-disable-next-line max-lines-per-function
export async function main({
args,
stdout = process.stdout,
stderr = process.stderr,
}: MainOptions): Promise<0 | 1> {
const { cliOptions, schematicOptions, _ } = parseArgs(args);
// Create a separate instance to prevent unintended global changes to the color configuration
const colors = ansiColors.create();
/** Create the DevKit Logger used through the CLI. */
const logger = createConsoleLogger(!!cliOptions.verbose, stdout, stderr, {
info: (s) => s,
debug: (s) => s,
warn: (s) => colors.bold.yellow(s),
error: (s) => colors.bold.red(s),
fatal: (s) => colors.bold.red(s),
});
if (cliOptions.help) {
logger.info(getUsage());
return 0;
}
/** Get the collection an schematic name from the first argument. */
const { collection: collectionName, schematic: schematicName } = parseSchematicName(
_.shift() || null,
);
const isLocalCollection = collectionName.startsWith('.') || collectionName.startsWith('/');
/** Gather the arguments for later use. */
const debugPresent = cliOptions.debug !== null;
const debug = debugPresent ? !!cliOptions.debug : isLocalCollection;
const dryRunPresent = cliOptions['dry-run'] !== null;
const dryRun = dryRunPresent ? !!cliOptions['dry-run'] : debug;
const force = !!cliOptions.force;
const allowPrivate = !!cliOptions['allow-private'];
/** Create the workflow scoped to the working directory that will be executed with this run. */
const workflow = new NodeWorkflow(process.cwd(), {
force,
dryRun,
resolvePaths: [process.cwd(), __dirname],
schemaValidation: true,
packageManager: getPackageManagerName(),
});
/** If the user wants to list schematics, we simply show all the schematic names. */
if (cliOptions['list-schematics']) {
return _listSchematics(workflow, collectionName, logger);
}
if (!schematicName) {
logger.info(getUsage());
return 1;
}
if (debug) {
logger.info(
`Debug mode enabled${isLocalCollection ? ' by default for local collections' : ''}.`,
);
}
// Indicate to the user when nothing has been done. This is automatically set to off when there's
// a new DryRunEvent.
let nothingDone = true;
// Logging queue that receives all the messages to show the users. This only get shown when no
// errors happened.
let loggingQueue: string[] = [];
let error = false;
/**
* Logs out dry run events.
*
* All events will always be executed here, in order of discovery. That means that an error would
* be shown along other events when it happens. Since errors in workflows will stop the Observable
* from completing successfully, we record any events other than errors, then on completion we
* show them.
*
* This is a simple way to only show errors when an error occur.
*/
workflow.reporter.subscribe((event) => {
nothingDone = false;
// Strip leading slash to prevent confusion.
const eventPath = removeLeadingSlash(event.path);
switch (event.kind) {
case 'error':
error = true;
logger.error(
`ERROR! ${eventPath} ${event.description == 'alreadyExist' ? 'already exists' : 'does not exist'}.`,
);
break;
case 'update':
loggingQueue.push(`${colors.cyan('UPDATE')} ${eventPath} (${event.content.length} bytes)`);
break;
case 'create':
loggingQueue.push(`${colors.green('CREATE')} ${eventPath} (${event.content.length} bytes)`);
break;
case 'delete':
loggingQueue.push(`${colors.yellow('DELETE')} ${eventPath}`);
break;
case 'rename':
loggingQueue.push(
`${colors.blue('RENAME')} ${eventPath} => ${removeLeadingSlash(event.to)}`,
);
break;
}
});
/**
* Listen to lifecycle events of the workflow to flush the logs between each phases.
*/
workflow.lifeCycle.subscribe((event) => {
if (event.kind == 'workflow-end' || event.kind == 'post-tasks-start') {
if (!error) {
// Flush the log queue and clean the error state.
loggingQueue.forEach((log) => logger.info(log));
}
loggingQueue = [];
error = false;
}
});
workflow.registry.addPostTransform(schema.transforms.addUndefinedDefaults);
// Show usage of deprecated options
workflow.registry.useXDeprecatedProvider((msg) => logger.warn(msg));
// Pass the rest of the arguments as the smart default "argv". Then delete it.
workflow.registry.addSmartDefaultProvider('argv', (schema) =>
'index' in schema ? _[Number(schema['index'])] : _,
);
// Add prompts.
if (cliOptions.interactive && isTTY()) {
workflow.registry.usePromptProvider(_createPromptProvider());
}
/**
* Execute the workflow, which will report the dry run events, run the tasks, and complete
* after all is done.
*
* The Observable returned will properly cancel the workflow if unsubscribed, error out if ANY
* step of the workflow failed (sink or task), with details included, and will only complete
* when everything is done.
*/
try {
await workflow
.execute({
collection: collectionName,
schematic: schematicName,
options: schematicOptions,
allowPrivate: allowPrivate,
debug: debug,
logger: logger,
})
.toPromise();
if (nothingDone) {
logger.info('Nothing to be done.');
} else if (dryRun) {
logger.info(
`Dry run enabled${
dryRunPresent ? '' : ' by default in debug mode'
}. No files written to disk.`,
);
}
return 0;
} catch (err) {
if (err instanceof UnsuccessfulWorkflowExecution) {
// "See above" because we already printed the error.
logger.fatal('The Schematic workflow failed. See above.');
} else if (debug && err instanceof Error) {
logger.fatal(`An error occured:\n${err.stack}`);
} else {
logger.fatal(`Error: ${err instanceof Error ? err.message : err}`);
}
return 1;
}
}
/**
* Get usage of the CLI tool.
*/
function getUsage(): string {
return `
schematics [collection-name:]schematic-name [options, ...]
By default, if the collection name is not specified, use the internal collection provided
by the Schematics CLI.
Options:
--debug Debug mode. This is true by default if the collection is a relative
path (in that case, turn off with --debug=false).
--allow-private Allow private schematics to be run from the command line. Default to
false.
--dry-run Do not output anything, but instead just show what actions would be
performed. Default to true if debug is also true.
--force Force overwriting files that would otherwise be an error.
--list-schematics List all schematics from the collection, by name. A collection name
should be suffixed by a colon. Example: '@angular-devkit/schematics-cli:'.
--no-interactive Disables interactive input prompts.
--verbose Show more information.
--help Show this message.
Any additional option is passed to the Schematics depending on its schema.
`;
}
/** Parse the command line. */
const booleanArgs = [
'allow-private',
'debug',
'dry-run',
'force',
'help',
'list-schematics',
'verbose',
'interactive',
] as const;
type ElementType<T extends ReadonlyArray<unknown>> =
T extends ReadonlyArray<infer ElementType> ? ElementType : never;
interface Options {
_: string[];
schematicOptions: Record<string, unknown>;
cliOptions: Partial<Record<ElementType<typeof booleanArgs>, boolean | null>>;
}
/** Parse the command line. */
function parseArgs(args: string[]): Options {
const { _, ...options } = yargsParser(args, {
boolean: booleanArgs as unknown as string[],
default: {
'interactive': true,
'debug': null,
'dry-run': null,
},
configuration: {
'dot-notation': false,
'boolean-negation': true,
'strip-aliased': true,
'camel-case-expansion': false,
},
});
// Camelize options as yargs will return the object in kebab-case when camel casing is disabled.
const schematicOptions: Options['schematicOptions'] = {};
const cliOptions: Options['cliOptions'] = {};
const isCliOptions = (
key: ElementType<typeof booleanArgs> | string,
): key is ElementType<typeof booleanArgs> =>
booleanArgs.includes(key as ElementType<typeof booleanArgs>);
for (const [key, value] of Object.entries(options)) {
if (/[A-Z]/.test(key)) {
throw new Error(`Unknown argument ${key}. Did you mean ${decamelize(key)}?`);
}
if (isCliOptions(key)) {
cliOptions[key] = value;
} else {
schematicOptions[camelCase(key)] = value;
}
}
return {
_: _.map((v) => v.toString()),
schematicOptions,
cliOptions,
};
}
function isTTY(): boolean {
const isTruthy = (value: undefined | string) => {
// Returns true if value is a string that is anything but 0 or false.
return value !== undefined && value !== '0' && value.toUpperCase() !== 'FALSE';
};
// If we force TTY, we always return true.
const force = process.env['NG_FORCE_TTY'];
if (force !== undefined) {
return isTruthy(force);
}
return !!process.stdout.isTTY && !isTruthy(process.env['CI']);
}
if (require.main === module) {
const args = process.argv.slice(2);
main({ args })
.then((exitCode) => (process.exitCode = exitCode))
.catch((e) => {
throw e;
});
}