UNPKG

workwatch

Version:

A Linux terminal program for honest worktime tracking and billing.

289 lines (280 loc) 10.8 kB
/** * This file is part of the WorkWatch, a Linux terminal program for honest * worktime tracking and billing. * * Copyright (C) 2020-2025 by Artur Rutkowski * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. * * WorkWatch is beeing developped and maintained by Artur (locust) Rutkwoski * <locust@mailbox.org> */ /** * This is a main CLI module. It analyses a command-line arguments and runs * commands passing analysed arguments. It also provides helper methods for * --help and --version options but they will be removed in future versions. */ const process = require("node:process"); const {version} = require("../../package.json"); const commands = require("./commands.js"); const term = require("../terminal.js"); function CLI() { // Parses the command-line arguments and returns object containing // nested objects for each command containing passed parameters. this.parse = () => { return new Promise((resolve, reject) => { // Throws when any non-existent option is given. throwIfExtraOptions(); // The arguments array is sliced in order to skip node invocation and // substitutes the JS file name with an underscore sign marking it as // a default command. let cliArguments = process.argv.slice(1, process.argv.length).map( (item, index) => (index === 0)? Object.keys(commands)[0] : item ); // Analyses a short aliases checkig if they are groupped and are they // boolean or not. transcodeAliases(cliArguments) .then(transcodingSuccess => { const commandParameters = {}; const invokedCommands = cliArguments.filter( item => Object.keys(commands).includes(item) ); // Technically, the amount of commands is 2 because it includes a // default command. if (invokedCommands.length > 2) { const error = new Error("It's not allowed to run more than one command."); error.code = "ERR_CLI_ONE_COMMAND_ONLY"; reject(error); } // Discovering wrong command. if ( cliArguments[1] !== undefined && cliArguments[1].length > 0 && !cliArguments[1].startsWith("--") && !Object.keys(commands).includes(cliArguments[1]) ) { const error = new Error(`There's no such command: ${cliArguments[1]}.`); error.code = "ERR_CLI_NO_SUCH_COMMAND"; reject(error); } // Extracting options passed as --xyz=value. let cliArgs = []; cliArguments.forEach(argument => { if (argument.startsWith("--") && argument.includes("=")) { cliArgs.push(...argument.split("=")); } else { cliArgs.push(argument); } }); cliArguments = cliArgs; // Preparing commandParameters object to return. invokedCommands.forEach(command => { let options = cliArguments.filter( option => Object.keys(commands[command].options).includes(option) ); commandParameters[command] = {}; options.forEach(option => { if ( Object.keys(commandParameters[command]).length === 0 || !Object.keys(commandParameters[command]).includes(option) ) { commandParameters[command][option] = ( commands[command].options[option].isBooleanValue )? true : realValue(cliArguments[cliArguments.indexOf(option) + 1]); } }); }); return commandParameters; }) .then(parsedParameters => resolve(parsedParameters)) .catch(transcodingOrParsingError => reject(transcodingOrParsingError)); }); }; function throwIfExtraOptions() { let options = process.argv.filter(option => option.startsWith("--")); Object.keys(commands).forEach(command => { options = options.filter( option => !Object.keys(commands[command].options).includes(option) ); }); if (options.length > 0) { const error = new Error(`No such parameter: ${options[0]}.`); error.code = "ERR_CLI_NO_SUCH_OPTION"; throw error; } } function transcodeAliases(cliArguments) { return new Promise((resolve, reject) => { // Extract aliases and alias groups. let aliasGroups = cliArguments.filter( aliasGroup => aliasGroup.startsWith("-") && !aliasGroup.includes("--") ); // Checking and expanding aliases or alias groups for each command. Object.keys(commands).forEach(command => { aliasGroups.forEach(aliasGroup => { // Split alias group to single aliases. aliasGroup.substring(1, aliasGroup.length).split("").map( alias => `-${alias}` ).forEach(alias => { let option = Object.keys(commands[command].options).find( option => commands[command].options[option].shortAliases.includes(alias) ); if ( option !== undefined && commands[command].options[option].isBooleanValue ) { // Pass the full option at the end of arguments. cliArguments.push(option); } else if ( option !== undefined && !commands[command].options[option].isBooleanValue && aliasGroup.length === 2 ) { // Length 2 means the alias letter and dash sign. cliArguments[cliArguments.indexOf(aliasGroup)] = option; } else if ( option !== undefined && !commands[command].options[option].isBooleanValue && aliasGroup.length > 2 ) { const error = new Error("Only boolean options' aliases can be groupped."); error.code = "ERR_CLI_BOOLEAN_ALIASES_ONLY"; reject(error); } }); }); }); // Cleaning remained boolean aliases after decoding. cliArguments.filter( argument => argument.startsWith("-") && !argument.includes("--") ).forEach( aliasGroup => cliArguments.splice(cliArguments.indexOf(aliasGroup), 1) ); resolve(true); }); } // Decodes a value for an option. function realValue(value) { let ret; if (value === undefined || value === null) { ret = null; } if (typeof value === "string" && value.startsWith("--")) { // If it starts with -- it is another option than argument for // previous one. ret = null; } if ( typeof value === "string" && !value.startsWith("--") && !Object.keys(commands).includes(value) ) { // It has to be an argument not command or option. ret = value; } else { ret = null; } // For numeric arguments. if (!isNaN(Number(value))) { ret = Number(value); } return ret; } // Display short help when you call --help. this.help = () => { let helpText = ""; Object.keys(commands).forEach(command => { if (command === Object.keys(commands)[0]) { helpText = `${commands[command].description}\n\n`; } else { helpText += ` ${command}\t${commands[command].description}\n`; } Object.keys(commands[command].options).forEach(option => { let optionHelpText = `${(commands[command].options[option].shortAliases.length > 0)? commands[command].options[option].shortAliases.split(",").join(", ") + ", " : ""}${option}\t${commands[command].options[option].description}\n`; if (command === Object.keys(commands)[0]) { helpText += optionHelpText; } else { helpText += ` ${optionHelpText}`; } }); helpText += "\n"; }); return helpText; }; // Invoked when you type --version. this.version = () => version; // Runs the application - runs commands. this.run = () => { return new Promise((resolve, reject) => { this.parse() .then(commandParameters => { // Make it object-wide for next promise callbacks. this.commandParameters = commandParameters; // Decide how to behave on different invocations. if ( Object.keys(this.commandParameters[Object.keys(commands)[0]]).includes("--help") ) { console.log(this.help()); return true; } else if ( Object.keys(this.commandParameters[Object.keys(commands)[0]]).includes("--version") ) { console.log(this.version()); return true; } else if ( Object.keys(this.commandParameters).length === 1 ) { console.log(commands[Object.keys(this.commandParameters)[0]].description); return true; } else { // Run default command passing parameters. The default command // accepts third parameter which is a next command name because it // depends on how to load data files. return commands[Object.keys(this.commandParameters)[0]].run( this.commandParameters[Object.keys(this.commandParameters)[0]], CLI.appData, Object.keys(this.commandParameters)[1] ); } }) .then(returnValue => { // Default command finished without errors. if (returnValue === 0) { if (Object.keys(this.commandParameters).length > 1) { // Run next command passing only parameters and appData. return commands[Object.keys(this.commandParameters)[1]].run( this.commandParameters[Object.keys(this.commandParameters)[1]], CLI.appData ); } else { resolve(true); } } }) .then(commandReturnValue => { if (!(commandReturnValue instanceof Error)) { delete this.commandParameters; resolve(true); } }) .catch(commandReturnError => reject(commandReturnError)); }); }; } // A between command storage used to work on data files. // Here appData provides only terminal object for interactive interfaces. CLI.appData = { terminal: new term() }; module.exports = {CLI};