nodejs-bash-completion/this

285 lines
9.1 KiB
JavaScript

#!/usr/bin/env node
const os = require('os');
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
let cmds = [];
this.listMethods().forEach((method) => {
let cmd = this._parseDocStringToCmd(this[method]);
// exclude start function as commandline cmd, because its the scripts main entry point
if (cmd && cmd != 'start') {
cmds.push(cmd);
}
});
// add functions for shell completion
this.program
.command('compgen')
.option('-s, --shell <name>', 'You can select between [bash]', 'bash')
.action((arg) => {
console.log(cmds.join(os.EOL));
return process.exit();
});
}
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()
*/
this.program.exitOverride();
try {
this.program.parse();
if (this.debugLevel == 0) {
process.exit(1);
}
} 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}'`);
}
}
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('<message>')
// * @option('-c, --color <name>', '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\('(?<flag>.+?)',\s*'(?<description>.+?)'(?:,\s*'(?<defaultValue>.+?)')?\)/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']);
}
}
// return name of function, if docstring could be parsed successfully
return fn.name;
}
// 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;