angular-cli/packages/angular/cli/models/schematic-command.ts

449 lines
13 KiB
TypeScript

/**
* @license
* Copyright Google Inc. 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.io/license
*/
// tslint:disable:no-global-tslint-disable no-any
import {
JsonObject,
experimental,
logging,
normalize,
schema,
strings,
tags,
terminal,
virtualFs,
} from '@angular-devkit/core';
import { NodeJsSyncHost } from '@angular-devkit/core/node';
import {
Collection,
DryRunEvent,
Engine,
Schematic,
SchematicEngine,
UnsuccessfulWorkflowExecution,
formats,
workflow,
} from '@angular-devkit/schematics';
import {
FileSystemCollectionDesc,
FileSystemEngineHostBase,
FileSystemSchematicDesc,
NodeModulesEngineHost,
NodeWorkflow,
validateOptionsWithSchema,
} from '@angular-devkit/schematics/tools';
import { take } from 'rxjs/operators';
import { WorkspaceLoader } from '../models/workspace-loader';
import {
getDefaultSchematicCollection,
getPackageManager,
getSchematicDefaults,
} from '../utilities/config';
import { ArgumentStrategy, Command, CommandContext, Option } from './command';
export interface CoreSchematicOptions {
dryRun: boolean;
force: boolean;
}
export interface RunSchematicOptions {
collectionName: string;
schematicName: string;
schematicOptions: any;
debug?: boolean;
dryRun: boolean;
force: boolean;
showNothingDone?: boolean;
}
export interface GetOptionsOptions {
collectionName: string;
schematicName: string;
}
export interface GetOptionsResult {
options: Option[];
arguments: Option[];
}
export class UnknownCollectionError extends Error {
constructor(collectionName: string) {
super(`Invalid collection (${collectionName}).`);
}
}
export abstract class SchematicCommand extends Command {
readonly options: Option[] = [];
readonly allowPrivateSchematics: boolean = false;
private _host = new NodeJsSyncHost();
private _workspace: experimental.workspace.Workspace;
private _deAliasedName: string;
private _originalOptions: Option[];
private _engineHost: FileSystemEngineHostBase;
private _engine: Engine<FileSystemCollectionDesc, FileSystemSchematicDesc>;
private _workflow: workflow.BaseWorkflow;
argStrategy = ArgumentStrategy.Nothing;
constructor(
context: CommandContext, logger: logging.Logger,
engineHost: FileSystemEngineHostBase = new NodeModulesEngineHost()) {
super(context, logger);
this._engineHost = engineHost;
this._engine = new SchematicEngine(this._engineHost);
const registry = new schema.CoreSchemaRegistry(formats.standardFormats);
this._engineHost.registerOptionsTransform(
validateOptionsWithSchema(registry));
}
protected readonly coreOptions: Option[] = [
{
name: 'dryRun',
type: 'boolean',
default: false,
aliases: ['d'],
description: 'Run through without making any changes.',
},
{
name: 'force',
type: 'boolean',
default: false,
aliases: ['f'],
description: 'Forces overwriting of files.',
}];
public async initialize(_options: any) {
this._loadWorkspace();
}
protected getEngineHost() {
return this._engineHost;
}
protected getEngine():
Engine<FileSystemCollectionDesc, FileSystemSchematicDesc> {
return this._engine;
}
protected getCollection(collectionName: string): Collection<any, any> {
const engine = this.getEngine();
const collection = engine.createCollection(collectionName);
if (collection === null) {
throw new UnknownCollectionError(collectionName);
}
return collection;
}
protected getSchematic(
collection: Collection<any, any>, schematicName: string,
allowPrivate?: boolean): Schematic<any, any> {
return collection.createSchematic(schematicName, allowPrivate);
}
protected setPathOptions(options: any, workingDir: string): any {
if (workingDir === '') {
return {};
}
return this.options
.filter(o => o.format === 'path')
.map(o => o.name)
.filter(name => options[name] === undefined)
.reduce((acc: any, curr) => {
acc[curr] = workingDir;
return acc;
}, {});
}
/*
* Runtime hook to allow specifying customized workflow
*/
protected getWorkflow(options: RunSchematicOptions): workflow.BaseWorkflow {
const {force, dryRun} = options;
const fsHost = new virtualFs.ScopedHost(
new NodeJsSyncHost(), normalize(this.project.root));
return new NodeWorkflow(
fsHost as any,
{
force,
dryRun,
packageManager: getPackageManager(),
root: this.project.root,
},
);
}
private _getWorkflow(options: RunSchematicOptions): workflow.BaseWorkflow {
if (!this._workflow) {
this._workflow = this.getWorkflow(options);
}
return this._workflow;
}
protected runSchematic(options: RunSchematicOptions) {
const {collectionName, schematicName, debug, dryRun} = options;
let schematicOptions = this.removeCoreOptions(options.schematicOptions);
let nothingDone = true;
let loggingQueue: string[] = [];
let error = false;
const workflow = this._getWorkflow(options);
const workingDir = process.cwd().replace(this.project.root, '').replace(/\\/g, '/');
const pathOptions = this.setPathOptions(schematicOptions, workingDir);
schematicOptions = { ...schematicOptions, ...pathOptions };
const defaultOptions = this.readDefaults(collectionName, schematicName, schematicOptions);
schematicOptions = { ...schematicOptions, ...defaultOptions };
// Remove all of the original arguments which have already been parsed
const argumentCount = this._originalOptions
.filter(opt => {
let isArgument = false;
if (opt.$default !== undefined && opt.$default.$source === 'argv') {
isArgument = true;
}
return isArgument;
})
.length;
// Pass the rest of the arguments as the smart default "argv". Then delete it.
const rawArgs = schematicOptions._.slice(argumentCount);
workflow.registry.addSmartDefaultProvider('argv', (schema: JsonObject) => {
if ('index' in schema) {
return rawArgs[Number(schema['index'])];
} else {
return rawArgs;
}
});
delete schematicOptions._;
workflow.registry.addSmartDefaultProvider('projectName', (_schema: JsonObject) => {
if (this._workspace) {
try {
return this._workspace.getProjectByPath(normalize(process.cwd()))
|| this._workspace.getDefaultProjectName();
} catch (e) {
if (e instanceof experimental.workspace.AmbiguousProjectPathException) {
this.logger.warn(tags.oneLine`
Two or more projects are using identical roots.
Unable to determine project using current working directory.
Using default workspace project instead.
`);
return this._workspace.getDefaultProjectName();
}
throw e;
}
}
return undefined;
});
workflow.reporter.subscribe((event: DryRunEvent) => {
nothingDone = false;
// Strip leading slash to prevent confusion.
const eventPath = event.path.startsWith('/') ? event.path.substr(1) : event.path;
switch (event.kind) {
case 'error':
error = true;
const desc = event.description == 'alreadyExist' ? 'already exists' : 'does not exist.';
this.logger.warn(`ERROR! ${eventPath} ${desc}.`);
break;
case 'update':
loggingQueue.push(tags.oneLine`
${terminal.white('UPDATE')} ${eventPath} (${event.content.length} bytes)
`);
break;
case 'create':
loggingQueue.push(tags.oneLine`
${terminal.green('CREATE')} ${eventPath} (${event.content.length} bytes)
`);
break;
case 'delete':
loggingQueue.push(`${terminal.yellow('DELETE')} ${eventPath}`);
break;
case 'rename':
loggingQueue.push(`${terminal.blue('RENAME')} ${eventPath} => ${event.to}`);
break;
}
});
workflow.lifeCycle.subscribe(event => {
if (event.kind == 'end' || event.kind == 'post-tasks-start') {
if (!error) {
// Output the logging queue, no error happened.
loggingQueue.forEach(log => this.logger.info(log));
}
loggingQueue = [];
error = false;
}
});
return new Promise<number | void>((resolve) => {
workflow.execute({
collection: collectionName,
schematic: schematicName,
options: schematicOptions,
debug: debug,
logger: this.logger as any,
allowPrivate: this.allowPrivateSchematics,
})
.subscribe({
error: (err: Error) => {
// In case the workflow was not successful, show an appropriate error message.
if (err instanceof UnsuccessfulWorkflowExecution) {
// "See above" because we already printed the error.
this.logger.fatal('The Schematic workflow failed. See above.');
} else if (debug) {
this.logger.fatal(`An error occured:\n${err.message}\n${err.stack}`);
} else {
this.logger.fatal(err.message);
}
resolve(1);
},
complete: () => {
const showNothingDone = !(options.showNothingDone === false);
if (nothingDone && showNothingDone) {
this.logger.info('Nothing to be done.');
}
if (dryRun) {
this.logger.warn(`\nNOTE: Run with "dry run" no changes were made.`);
}
resolve();
},
});
});
}
protected removeCoreOptions(options: any): any {
const opts = Object.assign({}, options);
if (this._originalOptions.find(option => option.name == 'dryRun')) {
delete opts.dryRun;
}
if (this._originalOptions.find(option => option.name == 'force')) {
delete opts.force;
}
if (this._originalOptions.find(option => option.name == 'debug')) {
delete opts.debug;
}
return opts;
}
protected getOptions(options: GetOptionsOptions): Promise<Option[]> {
// Make a copy.
this._originalOptions = [...this.options];
const collectionName = options.collectionName || getDefaultSchematicCollection();
const collection = this.getCollection(collectionName);
const schematic = this.getSchematic(collection, options.schematicName,
this.allowPrivateSchematics);
this._deAliasedName = schematic.description.name;
if (!schematic.description.schemaJson) {
return Promise.resolve([]);
}
const properties = schematic.description.schemaJson.properties;
const keys = Object.keys(properties);
const availableOptions = keys
.map(key => ({ ...properties[key], ...{ name: strings.dasherize(key) } }))
.map(opt => {
const types = ['string', 'boolean', 'integer', 'number'];
// Ignore arrays / objects.
if (types.indexOf(opt.type) === -1) {
return null;
}
let aliases: string[] = [];
if (opt.alias) {
aliases = [...aliases, opt.alias];
}
if (opt.aliases) {
aliases = [...aliases, ...opt.aliases];
}
const schematicDefault = opt.default;
return {
...opt,
aliases,
default: undefined, // do not carry over schematics defaults
schematicDefault,
hidden: opt.visible === false,
};
})
.filter(x => x);
return Promise.resolve(availableOptions);
}
private _loadWorkspace() {
if (this._workspace) {
return;
}
const workspaceLoader = new WorkspaceLoader(this._host);
try {
workspaceLoader.loadWorkspace(this.project.root).pipe(take(1))
.subscribe(
(workspace: experimental.workspace.Workspace) => this._workspace = workspace,
(err: Error) => {
if (!this.allowMissingWorkspace) {
// Ignore missing workspace
throw err;
}
},
);
} catch (err) {
if (!this.allowMissingWorkspace) {
// Ignore missing workspace
throw err;
}
}
}
private _cleanDefaults<T, K extends keyof T>(defaults: T, undefinedOptions: string[]): T {
(Object.keys(defaults) as K[])
.filter(key => !undefinedOptions.map(strings.camelize).includes(key as string))
.forEach(key => {
delete defaults[key];
});
return defaults;
}
private readDefaults(collectionName: string, schematicName: string, options: any): {} {
if (this._deAliasedName) {
schematicName = this._deAliasedName;
}
const projectName = options.project;
const defaults = getSchematicDefaults(collectionName, schematicName, projectName);
// Get list of all undefined options.
const undefinedOptions = this.options
.filter(o => options[o.name] === undefined)
.map(o => o.name);
// Delete any default that is not undefined.
this._cleanDefaults(defaults, undefinedOptions);
return defaults;
}
}