@villedemontreal/scripting
Version:
Scripting core utilities
299 lines • 11.8 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.ScriptBase = exports.TESTING_SCRIPT_NAME_PREFIX = void 0;
const caporal_1 = require("@villedemontreal/caporal");
const general_utils_1 = require("@villedemontreal/general-utils");
const _ = require("lodash");
const path = require("path");
const configs_1 = require("./config/configs");
/* eslint-disable @typescript-eslint/no-empty-object-type */
/**
* A script with a name starting with this prefix
* will only be registered on Caporal when tests
* are running (ie: when the NODE_APP_INSTANCE env
* var is "tests").
*/
exports.TESTING_SCRIPT_NAME_PREFIX = 'testing:';
let projectDirectDependencies;
/**
* Base class for a Script.
*
* You can parametrize it so `this.options` and `this.args`
* are typed.
*/
class ScriptBase {
constructor(actionParams) {
this._actionParams = actionParams;
}
get actionParams() {
if (!this._actionParams) {
throw new Error(`No actions parameters specified!`);
}
return this._actionParams;
}
/**
* Dependencies required for the script to run properly.
*/
get requiredDependencies() {
return [];
}
/**
* The script's arguments.
*/
get args() {
return (this.actionParams.args || {});
}
/**
* The script's options.
*/
get options() {
return (this.actionParams.options || {});
}
/**
* The script's logger. Will respect any specified log
* level (for example if the script was called with `--silent`).
*/
get logger() {
return this.actionParams.logger;
}
/**
* Register the script on Caporal
*/
async registerScript(caporal) {
const command = caporal.command(this.name, `${this.description}\n`, this.commandConfig);
await this.addAction(command);
await this.addHelpBody(command);
await this.configure(command);
}
async addAction(command) {
command.action(async (params) => {
const script = new this.constructor(params);
await script.run();
});
}
async addHelpBody(command) {
command.help(this.description); // only the description by default
}
/**
* WARNING: The code in this method only makes sense *when launching
* a new process*! Using this to run code in the current process
* will not result in the proper configs to be used since configs
* are already loaded.
*
* Instead of using this method, you should probably use `invokeShellCommand()`
* with the `useTestsNodeAppInstance` param set to `true`.
*/
async withTestsNodeAppInstance(runner) {
const nodeAppInstanceOriginal = process.env[general_utils_1.globalConstants.envVariables.NODE_APP_INSTANCE];
process.env[general_utils_1.globalConstants.envVariables.NODE_APP_INSTANCE] =
general_utils_1.globalConstants.appInstances.TESTS;
try {
return await runner();
}
finally {
if (nodeAppInstanceOriginal) {
process.env[general_utils_1.globalConstants.envVariables.NODE_APP_INSTANCE] = nodeAppInstanceOriginal;
}
else {
delete process.env[general_utils_1.globalConstants.envVariables.NODE_APP_INSTANCE];
}
}
}
/**
* Invokes the specified script.
*
* @param scriptType the class of a Script to invoke
* @param options specify the target options for the script to invoke. Those options
* will be *merged* to the current global options, if any.
* @param args specify the target args for the script to invoke
*/
async invokeScript(scriptType, options, args) {
const allOptions = this.addGlobalOptions(options);
const actionParams = {
...this.actionParams,
options: allOptions,
args,
};
const script = new scriptType(actionParams);
return await script.run();
}
/**
* Execute a shell command.
*
* This function is a promisified version of Node's `spawn()`
* with extra options added
* ( https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options ).
*
* Will fail if the process returns a code different than
* `options.successExitCode` ("0" by default). The exit
* code would then be available in the generated Error:
* `err.exitCode`.
*
* @param bin The executable program to call.
*
* @param args The arguments for the program.
*
* @param options.successExitCodes The acceptable codes the
* process must exit with to be considered as a success.
* Defaults to [0].
*
* @param options.outputHandler A function that will receive
* the output of the process (stdOut and stdErr).
* This allows you to capture this output and manipulate it.
* No handler by default.
*
* @param options.disableConsoleOutputs Set to `true` in order
* to disable outputs in the current parent process
* (you can still capture them using a `options.dataHandler`).
* Defaults to `false`.
*
* @param options.stdio See https://nodejs.org/api/child_process.html#child_process_options_stdio
* Defaults to `['inherit', 'pipe', 'pipe']`.
*
* @param options.useShellOption See the "shell" option:
* https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options
* Defaults to `true`.
*
* @param options.escapeArgs will automatically escape the submitted args.
* Defaults to `false`.
*
* @param options.useTestsNodeAppInstance Execute the specified command with the
* "NODE_APP_INSTANCE" env var set to "tests". This makes the testing configurations
* be used in the launched process.
*
* @returns The exit code *when the execution is a success*. This may be useful when more
* than one exit codes can be considered as a success (those specified using
* `options.successExitCodes`). Note that if an error occures, an `ExecError` is thrown
* so nothing is returned (see below).
*
* @throws Will fail with a `ExecError` error if the process returns a code different than
* `options.successExitCodes` ("0" by default). The exit code would then be available in the
* generated Error: `err.exitCode.`
*/
async invokeShellCommand(bin, args, options) {
const useTestsNodeAppInstance = options?.useTestsNodeAppInstance ?? false;
const execOptions = { ...options };
delete execOptions?.useTestsNodeAppInstance;
this.logger.info(`Executing: ${bin} ${args}`);
if (useTestsNodeAppInstance) {
return await this.withTestsNodeAppInstance(async () => {
return await general_utils_1.utils.exec(bin, args, execOptions);
});
}
return await general_utils_1.utils.exec(bin, args, execOptions);
}
getCommand() {
const command = configs_1.configs.caporal.getCommands().find((c) => c.name === this.name);
return command;
}
getCommandOptionsNames() {
const optionsNames = new Set();
const command = this.getCommand();
if (command) {
for (const option of command.options) {
option.allNames.forEach((name) => optionsNames.add(name));
}
}
return optionsNames;
}
addGlobalOptions(options) {
const currentGlobalOptions = {};
const commandOptionsnames = this.getCommandOptionsNames();
for (const [key, val] of Object.entries(this.options)) {
if (!commandOptionsnames.has(key)) {
currentGlobalOptions[key] = val;
}
}
return {
...currentGlobalOptions,
...options,
};
}
/**
* Will be used to identify the script
* when outputing console messages.
*/
get outputName() {
return this.name;
}
get commandConfig() {
return {}; // nothing by default
}
/**
* Override this method in order to add
* options or to configure a script that
* requires more information than a name and
* description.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async configure(command) {
// nothing by default
}
/**
* Get the project direct dependencies (those
* explicitly listed in its `package.json`).
*/
async getProjectDirectDependencies() {
if (!projectDirectDependencies) {
const packageJsonObj = await Promise.resolve(`${path.join(configs_1.configs.projectRoot, 'package.json')}`).then(s => require(s));
projectDirectDependencies = [
...Object.keys(packageJsonObj.dependencies || {}),
...Object.keys(packageJsonObj.devDependencies || {}),
];
}
return projectDirectDependencies;
}
/**
* Returns `true` if the specified dependency is a
* direct dependency in the project.
*/
async isProjectDirectDependency(dependencyName) {
return (await this.getProjectDirectDependencies()).includes(dependencyName);
}
/**
* Validate the required dependencies.
*/
async validateRequiredDependencies() {
const requiredDeps = this.requiredDependencies;
const projectDeps = await this.getProjectDirectDependencies();
const missingDirectDeps = _.difference(requiredDeps, projectDeps);
if (missingDirectDeps && missingDirectDeps.length > 0) {
this.logger.warn(`This script requires some dependencies that are not direct dependencies in your project:`);
for (const missingDep of missingDirectDeps) {
this.logger.warn(`- ${missingDep}`);
}
this.logger.warn(`The script may still work if those dependencies are available ${caporal_1.chalk.italic('transitively')}, but it may be a good idea to add them directly to your "${caporal_1.chalk.cyanBright('package.json')}" file.`);
}
}
/**
* Runs the script.
*/
async run() {
const start = new Date();
this.logger.info(`Script "${caporal_1.chalk.cyanBright(this.outputName)}" starting...`);
await this.validateRequiredDependencies();
try {
await this.main();
}
catch (originalError) {
const err = typeof originalError === 'string' ? new Error(originalError) : originalError;
if (err.__reported) {
this.logger.warn(`Script "${caporal_1.chalk.cyanBright(this.outputName)}" was aborted after ${caporal_1.chalk.magenta(calcElapsedTime(start, new Date()))}`);
}
else {
this.logger.error(`Script "${caporal_1.chalk.cyanBright(this.outputName)}" failed after ${caporal_1.chalk.magenta(calcElapsedTime(start, new Date()))} with: ${caporal_1.chalk.red(err.message)}`);
err.__reported = true;
}
throw err;
}
this.logger.info(`Script "${caporal_1.chalk.cyanBright(this.outputName)}" successful after ${caporal_1.chalk.magenta(calcElapsedTime(start, new Date()))}`);
}
}
exports.ScriptBase = ScriptBase;
function calcElapsedTime(from, to) {
const deltaSecs = Math.round(10 * ((to.getTime() - from.getTime()) / 1000.0)) / 10;
const deltaMinutes = Math.round(10 * (deltaSecs / 60.0)) / 10;
const deltaText = deltaSecs > 120 ? `${deltaMinutes} m` : `${deltaSecs} s`;
return deltaText;
}
//# sourceMappingURL=scriptBase.js.map