#!/usr/bin/env node const path = require('path'); const { Command } = require('commander'); const shell = require('shelljs'); const util = require('util'); class This { constructor() { this.version = '0.0.1'; // Default version, can be overridden in subclasses this.description = 'This is the parent class all other scripts should extend.'; this.scriptName = path.parse(process.argv[1]).base; this.workingDir = path.parse(process.argv[1]).dir; this.debugLevel = 0; } init() { // implement commander abilities this.program = new Command() .name(this.scriptName) .version(this.version) .description(this.description) .configureOutput({ // Visibly override write routines as example! //writeOut: (str) => process.stdout.write(`[OUT] ${str}`), //writeErr: (str) => process.stdout.write(`[ERR] ${str}`), // Highlight errors in color. outputError: (str, write) => write(this._echoInRed(str)), }); // dynamically add all methods with appropriate docstring to commander this.listMethods().forEach((method) => { this._parseDocStringToCmd(this[method]); }); } getClassName() { return this.constructor.name; } listMethods() { const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(this)).filter( (prop) => typeof this[prop] === 'function' && prop !== 'constructor' && !prop.startsWith('_') ); return methods; } listProperties() { const properties = Object.keys(this).filter((prop) => typeof this[prop] !== 'function'); return properties; } discover() { const methods = util.inspect(this.listMethods(), { depth: null, colors: true, showHidden: true }); const properties = util.inspect(this.listProperties(), { depth: null, colors: true, showHidden: true }); this.execCmd( `${this.workingDir}/log echo 'My name is "${this.getClassName()}" and I have the version: ${this.version}'` ); this.execCmd(`${this.workingDir}/log echo 'My methods are: ${methods}'`); this.execCmd(`${this.workingDir}/log echo 'My properties are: ${properties}'`); } // Method to create a context manager for this instance withContext(callback) { callback(this); } start() { /** * @program() */ if (this.debugLevel > 0) { this.program.exitOverride(); try { this.program.parse(); // show help even if there are no commands registered if (this.program.commands.length == 0) { this.program.outputHelp(); } } catch (err) { // this.execCmd(`${this.workingDir}/log echo '\n'`); // this.execCmd(`${this.workingDir}/log error '${err}'`); process.exit(1); } if (Object.keys(this.program.opts()).length || this.program.args.length) { // Debugging commander options and arguments const opts = util.inspect(this.program.opts(), { depth: null, colors: true, showHidden: true }); const args = util.inspect(this.program.args, { depth: null, colors: true, showHidden: true }); this.execCmd(`${this.workingDir}/log echo '\n'`); this.execCmd(`${this.workingDir}/log echo 'Options: ${opts}'`); this.execCmd(`${this.workingDir}/log echo 'Remaining arguments: ${args}'`); } } else { this.program.parse(); // show help even if there are no commands registered if (this.program.commands.length == 0) { this.program.outputHelp(); } } } execCmd(cmd) { // Run external tool synchronously if (shell.exec(cmd).code !== 0) { shell.echo('[ERR] ' + this._echoInRed(`Command '${cmd}' failed`)); shell.exit(1); } } _echoInRed(str) { // Add ANSI escape codes to display text in red. return `\x1b[31m${str}\x1b[0m`; } _echoInYellow(str) { // Add ANSI escape codes to display text in yellow. return `\x1b[33m${str}\x1b[0m`; } _echoInGreen(str) { // Add ANSI escape codes to display text in green. return `\x1b[32m${str}\x1b[0m`; } _getCurrentFunctionName() { // Create an Error object (but don't throw it) const err = new Error(); // Extract the current stack trace Error.captureStackTrace(err, this._getCurrentFunctionName); // Extract the function name from the stack trace const callerName = err.stack.split('\n')[1].trim().split(' ')[1]; return callerName; } // Utility function to extract docstring from a function, e.g. // // start() { // /** // * @program() // * @usage([options] [command]) // * @option('-d, --debug', 'Enable debug mode') // .. // } // // // echo(str) { // /** // * @program() // * @command() // * @argument('') // * @option('-c, --color ', 'Prints in color mode') // */ // console.log(str); // } // // Attention: // In JavaScript, when a script is executed as a bash script with a shebang (#!/usr/bin/env node), // the toString method on functions does not include comments preceding the function definition. _getFunctionDocString(fn) { const fnStr = fn.toString(); const docStringMatch = fnStr.match(/\/\*[\s\S]*?\*\//); return docStringMatch ? docStringMatch[0] : null; } // Parser function to convert docstring to commander statement _parseDocStringToCmd(fn) { const docString = this._getFunctionDocString(fn); if (!docString) { // No docstring found return; } // Extract command and arguments const programRegex = /@program\(\)/; const usageRegex = /@usage\('(.+?)'\)/; const commandRegex = /@command(?:\('(.+?)'\))?/; const aliasRegex = /@alias\('(.+?)'\)/; const argumentsRegex = /@argument\('(.+?)'\)/g; const optionsRegex = /@option\('(?.+?)',\s*'(?.+?)'(?:,\s*'(?.+?)')?\)/g; const defaultRegex = /@default/; const executableRegex = /@executable\('(.+?)'\)/; // get regex matches with capture groups const programMatch = docString.match(programRegex); const usageMatch = docString.match(usageRegex); const commandMatch = docString.match(commandRegex); const aliasMatch = docString.match(aliasRegex); const defaultMatch = docString.match(defaultRegex); const executableMatch = docString.match(executableRegex); if (!programMatch) { // No @program() tag found return; } if (commandMatch) { // Generate the commander statement const commandName = fn.name; const commandDescription = commandMatch ? commandMatch[1] : ''; const alias = aliasMatch ? aliasMatch[1] : ''; const defaultCommand = defaultMatch ? true : false; const executable = executableMatch ? executableMatch[1] : ''; // Get the function reference const func = this[commandName].bind(this); // Create a command // See https://github.com/tj/commander.js?tab=readme-ov-file#quick-start // When `.command()` is invoked with a description argument, // this tells Commander that you're going to use a stand-alone executable for the subcommand. let command; if (commandDescription) { // build stand-alone command command = this.program.command(commandName, commandDescription, { isDefault: defaultCommand, executableFile: executable, }); if (alias) { command.alias(alias); } } else { // build command with function name as action command = this.program.command(commandName, { isDefault: defaultCommand }); // Set the action for the command command.action((arg) => { func(arg); }); } // If the commandName expects arguments, we add them here (docString.match(argumentsRegex) || []) .map((e) => e.replace(argumentsRegex, '$1')) .forEach((arg) => { command.argument(arg); }); // If the commandName expects options, we add them here // See https://github.com/tj/commander.js?tab=readme-ov-file#options for (const match of docString.matchAll(optionsRegex)) { command.option(match.groups['flag'], match.groups['description'], match.groups['defaultValue']); } } else { // Add usage if (usageMatch) { this.program.usage(usageMatch[1]); } // If the program expects options, we add them here // See https://github.com/tj/commander.js?tab=readme-ov-file#options for (const match of docString.matchAll(optionsRegex)) { this.program.option(match.groups['flag'], match.groups['description'], match.groups['defaultValue']); } } } // Higher-order function to decorate other functions and provide logging _logDecorator(fn) { return function (...args) { console.log(`Calling ${fn.name} with arguments:`, args); const result = fn.apply(this, args); // Use apply to maintain context console.log(`Result of ${fn.name}:`, result); return result; }; } } module.exports = This;