@tsed/cli-core
Version:
Build your CLI with TypeScript and Decorators
247 lines (246 loc) • 9.41 kB
JavaScript
import { PromptRunner } from "@tsed/cli-prompts";
import { concat, tasks as tasksRunner } from "@tsed/cli-tasks";
import { classOf, isArrowFn } from "@tsed/core";
import { configuration, constant, destroyInjector, DIContext, getContext, inject, injectable, injector, logger, Provider, runInContext } from "@tsed/di";
import { $asyncAlter, $asyncEmit } from "@tsed/hooks";
import { pascalCase } from "change-case";
import { Argument, Command } from "commander";
import { v4 } from "uuid";
import { PackageManagersModule } from "../packageManagers/index.js";
import { getCommandMetadata } from "../utils/getCommandMetadata.js";
import { mapCommanderOptions, validate } from "../utils/index.js";
import { mapCommanderArgs } from "../utils/mapCommanderArgs.js";
import { parseOption } from "../utils/parseOption.js";
import { CliHooks } from "./CliHooks.js";
import { ProjectPackageJson } from "./ProjectPackageJson.js";
export class CliService {
constructor() {
this.reinstallAfterRun = constant("project.reinstallAfterRun", false);
this.program = new Command();
this.pkg = constant("pkg", { version: "1.0.0" });
this.hooks = inject(CliHooks);
this.projectPkg = inject(ProjectPackageJson);
this.packageManagers = inject(PackageManagersModule);
this.promptRunner = inject(PromptRunner);
this.commands = new Map();
}
/**
* Parse process.argv and runLifecycle action
* @param argv
*/
async parseArgs(argv) {
const { program } = this;
program.version(this.pkg.version);
this.load();
await program.parseAsync(argv);
}
/**
* Run lifecycle
* @param cmdName
* @param data
* @param $ctx
*/
runLifecycle(cmdName, data = {}, $ctx) {
return runInContext($ctx, async () => {
$ctx.set("dispatchCmd", cmdName);
await $asyncEmit("$loadPackageJson");
try {
data = await this.prompt(cmdName, data, $ctx);
await this.exec(cmdName, data, $ctx);
await $asyncEmit("$onFinish", [$ctx.get("data")]);
}
catch (er) {
await $asyncEmit("$onFinish", [$ctx.get("data"), er]);
throw er;
}
finally {
await destroyInjector();
}
});
}
async exec(cmdName, data, $ctx) {
const tasks = await this.getTasks(cmdName, data);
$ctx.set("data", data);
if (tasks.length) {
if (this.reinstallAfterRun && (this.projectPkg.rewrite || this.projectPkg.reinstall)) {
tasks.push(this.packageManagers.task("Install dependencies", data), ...(await this.getPostInstallTasks(cmdName, data)));
}
data = this.mapData(cmdName, data, $ctx);
$ctx.set("data", data);
const result = await tasksRunner(tasks, data);
$ctx.set("data", data);
return result;
}
}
/**
* Run prompt for a given command
* @param cmdName
* @param data
* @param $ctx
*/
async prompt(cmdName, data = {}, $ctx) {
const provider = this.commands.get(cmdName);
const instance = inject(provider.token);
$ctx.set("data", data);
if (instance.$prompt) {
const questions = await instance.$prompt(data);
if (questions) {
const answers = await this.promptRunner.run(questions, data);
data = {
...data,
...answers
};
}
}
$ctx.set("data", data);
return data;
}
/**
* Run lifecycle
* @param cmdName
* @param data
*/
async getTasks(cmdName, data) {
const $ctx = getContext();
const provider = this.commands.get(cmdName);
const instance = inject(provider.token);
data = this.mapData(cmdName, data, $ctx);
return concat(await instance.$exec(data), await $asyncAlter(`$alter${pascalCase(cmdName)}Tasks`, [], [data]));
}
async getPostInstallTasks(cmdName, data) {
const provider = this.commands.get(cmdName);
const instance = inject(provider.token);
data = this.mapData(cmdName, data, getContext());
return concat(await instance.$postInstall?.(data), await $asyncAlter(`$alter${pascalCase(cmdName)}PostInstallTasks`, [], [data]), await instance.$afterPostInstall?.(data));
}
createCommand(metadata) {
const { name, description, alias, inputSchema } = metadata;
if (this.commands.has(name)) {
return this.commands.get(name).command;
}
const { args, options, allowUnknownOption } = metadata.getOptions();
const onAction = (commandName) => {
const [, ...rawArgs] = cmd.args;
const mappedArgs = mapCommanderArgs(args, this.program.args.filter((arg) => commandName === arg));
const allOpts = mapCommanderOptions(commandName, this.program.commands);
let data = {
...allOpts,
verbose: !!this.program.opts().verbose,
...mappedArgs,
...cmd.opts(),
rawArgs
};
if (inputSchema) {
const schema = isArrowFn(inputSchema) ? inputSchema() : inputSchema;
const { isValid, errors, value } = validate(data, schema);
if (isValid) {
data = value;
}
else {
logger().error({
event: "VALIDATION_ERROR",
errors
});
throw new Error("Validation error");
}
}
const $ctx = new DIContext({
id: v4(),
injector: injector(),
logger: logger(),
level: logger().level,
maxStackSize: 0,
platform: "CLI"
});
$ctx.set("data", data);
$ctx.set("command", metadata);
configuration().set("command.metadata", metadata);
return this.runLifecycle(name, data, $ctx);
};
let cmd = this.program.command(name);
if (alias) {
cmd = cmd.alias(alias);
}
cmd = cmd.description(description);
cmd = this.buildArguments(cmd, args);
cmd = cmd.action(onAction);
if (options) {
cmd = this.buildOption(cmd, options, !!allowUnknownOption);
}
return cmd;
}
load() {
injector()
.getProviders("command")
.forEach((provider) => this.build(provider));
}
mapData(cmdName, data, $ctx) {
const provider = this.commands.get(cmdName);
const instance = inject(provider.token);
const verbose = data.verbose;
data.commandName ||= cmdName;
if (instance.$mapContext) {
data = instance.$mapContext(JSON.parse(JSON.stringify(data)));
data.verbose = verbose;
}
if (data.verbose) {
logger().level = "debug";
}
else {
logger().level = "info";
}
data.renderMode = $ctx.get("command")?.renderMode;
data.logger = logger();
$ctx.set("data", data);
return data;
}
/**
* Build command and sub-commands
* @param provider
*/
build(provider) {
const metadata = getCommandMetadata(provider.token);
if (metadata.name) {
if (this.commands.has(metadata.name)) {
throw Error(`The ${metadata.name} command is already registered. Change your command name used by the class ${classOf(provider.useClass)}`);
}
provider.command = this.createCommand(metadata);
this.commands.set(metadata.name, provider);
}
}
/**
* Build sub-command options
* @param subCommand
* @param options
* @param allowUnknownOptions
*/
buildOption(subCommand, options, allowUnknownOptions) {
Object.entries(options).reduce((subCommand, [flags, { description, required, customParser, defaultValue, ...options }]) => {
const fn = (v) => {
return parseOption(v, options);
};
if (options.type === Boolean) {
defaultValue = false;
}
return required
? subCommand.requiredOption(flags, description, fn, defaultValue)
: subCommand.option(flags, description, fn, defaultValue);
}, subCommand);
subCommand.option("-r, --root-dir <path>", "Project root directory");
subCommand.option("--verbose", "Verbose mode", () => true);
if (allowUnknownOptions) {
subCommand.allowUnknownOption(true);
}
return subCommand;
}
buildArguments(cmd, args) {
return Object.entries(args).reduce((cmd, [key, { description, required, defaultValue }]) => {
const argument = new Argument(required ? `<${key}>` : `[${key}]`, description);
if (defaultValue !== undefined) {
argument.default(defaultValue);
}
return cmd.addArgument(argument);
}, cmd);
}
}
injectable(CliService);