workwatch
Version:
A Linux terminal program for honest worktime tracking and billing.
289 lines (280 loc) • 10.8 kB
JavaScript
/**
* 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};