@cloud-copilot/cli
Version:
A standardized library for CLI building TypeScript CLI applications
494 lines • 21.3 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.parseCliArguments = parseCliArguments;
exports.printHelpContents = printHelpContents;
const utils_js_1 = require("./utils.js");
/**
* Parse CLI Arguments and return the parsed typesafe results.
*
* @param command the name of the command arguments are being parsed for.
* @param subcommands the list of subcommands that can be used, if any.
* @param cliArgs the configuration options for the CLI command.
* @param additionalOptions additional arguments to be used for parsing and displaying help.
* @returns the parsed arguments, operands, and subcommand if applicable.
*/
async function parseCliArguments(command, subcommands, cliArgs, additionalOptions) {
const args = additionalOptions?.args ?? process.argv.slice(2);
const env = additionalOptions?.env ?? process.env;
const parsedArgs = {};
const operands = [];
const booleanOptions = {};
const subcommandKeys = Object.keys(subcommands);
const numberOfSubcommands = subcommandKeys.length;
const combinedOptions = { ...cliArgs };
const logger = additionalOptions?.consoleLogger ?? console;
let subcommand;
const expectOperands = Object.hasOwn(additionalOptions || {}, 'expectOperands')
? !!additionalOptions?.expectOperands
: true;
if (args.length === 0 && additionalOptions?.showHelpIfNoArgs) {
printHelpContents(command, subcommands, cliArgs, additionalOptions);
(0, utils_js_1.exit)(0, undefined);
return {};
}
const allDefaults = {};
// Step 1: Initialize defaults
const parsedEnvironmentArgs = {};
initializeOptionDefaults(allDefaults, booleanOptions, cliArgs);
// Step 2: Handle environment variables
await parseEnvironmentVariables(cliArgs, parsedEnvironmentArgs, env, additionalOptions?.envPrefix);
// Step 3: Group arguments into objects
const commandChunks = groupArguments(args);
// Step 4: Validation and parsing arguments
for (const { first, rest, isLast, isFirst } of commandChunks) {
// Handle --help and --version
if (first === '--help') {
printHelpContents(command, subcommands, cliArgs, additionalOptions, subcommand);
(0, utils_js_1.exit)(0, undefined);
return {};
}
if (first === '--version') {
if (additionalOptions?.version) {
await printVersion(additionalOptions.version, logger);
(0, utils_js_1.exit)(0, undefined);
return {};
}
}
// Handle commands if applicable
if (isFirst && !first.startsWith('-')) {
if (numberOfSubcommands === 0) {
if (isLast) {
operands.push(first, ...rest);
}
else {
(0, utils_js_1.exit)(2, `Invalid: ${first}, all arguments must specified using a --argument`);
}
}
else {
if (!isLast && rest.length > 0) {
(0, utils_js_1.exit)(2, `Arguments must be specified using a --argument, ${rest.join(' ')} is ambiguous`);
}
const matchingCommands = subcommandKeys.filter((cmd) => cmd.toLowerCase().startsWith(first.toLowerCase()));
if (matchingCommands.length === 0) {
(0, utils_js_1.exit)(2, `Unknown command: ${first}`);
return {};
}
else if (matchingCommands.length > 1) {
(0, utils_js_1.exit)(2, `Ambiguous command: ${first}`);
return {};
}
subcommand = matchingCommands.at(0);
const subcommandOptions = subcommands[subcommand].arguments;
initializeOptionDefaults(allDefaults, booleanOptions, subcommandOptions);
await parseEnvironmentVariables(subcommandOptions, parsedEnvironmentArgs, env, additionalOptions?.envPrefix);
for (const [key, option] of Object.entries(subcommandOptions)) {
combinedOptions[key] = option;
}
operands.push(...rest);
}
}
// Validate options
if (first == '--') {
if (!expectOperands) {
(0, utils_js_1.exit)(2, `Operands are not expected but '--' separator was used`);
}
operands.push(...rest);
}
else if (first.startsWith('--')) {
const key = first.slice(2).replaceAll('-', '').toLowerCase();
const matchingOptions = Object.keys(combinedOptions).filter((k) => k.toLowerCase().startsWith(key));
let matchingOption = undefined;
if (matchingOptions.length === 1) {
matchingOption = matchingOptions[0];
}
else if (matchingOptions.length > 1) {
//Look for an exact match, it's possible that one option name is the beginning of another
const exactMatch = matchingOptions.find((k) => k.toLowerCase() === key);
if (!exactMatch) {
(0, utils_js_1.exit)(2, `Ambiguous argument: ${first}`);
}
matchingOption = exactMatch;
}
else {
if ('--help'.startsWith(first)) {
printHelpContents(command, subcommands, cliArgs, additionalOptions, subcommand);
(0, utils_js_1.exit)(0, undefined);
return {};
}
else if ('--version'.startsWith(first) && additionalOptions?.version) {
await printVersion(additionalOptions.version, logger);
(0, utils_js_1.exit)(0, undefined);
return {};
}
(0, utils_js_1.exit)(2, `Unknown argument: ${first}`);
}
if (!matchingOption) {
(0, utils_js_1.exit)(2, `Unknown argument: ${first}`);
return {};
}
const selectedArgument = matchingOption;
const fullArgumentName = `--${camelToKebabCase(selectedArgument)}`;
const optionConfig = combinedOptions[selectedArgument];
initializeDefault(parsedArgs, combinedOptions, selectedArgument);
if (optionConfig.present) {
parsedArgs[selectedArgument] = await optionConfig.present(parsedArgs[selectedArgument]);
}
if (rest.length > 0 && optionConfig.character) {
if (!isLast) {
(0, utils_js_1.exit)(2, `Validation error for ${fullArgumentName}: does not accept values but received ${rest.join(', ')}`);
return {};
}
else {
// If we're at the last argument and there are values, check if operands are expected
if (!expectOperands) {
(0, utils_js_1.exit)(2, `Validation error for ${fullArgumentName}: does not accept values but received ${rest.join(', ')}`);
return {};
}
else {
operands.push(...rest);
}
}
}
else {
const acceptsMultiple = optionConfig.acceptMultipleValues
? optionConfig.acceptMultipleValues()
: false;
let theRest = rest;
if (!acceptsMultiple && rest.length > 1) {
if (isLast && expectOperands) {
theRest = [rest[0]];
operands.push(...rest.slice(1));
}
}
const currentValue = parsedArgs[selectedArgument];
const validation = await optionConfig.validateValues(currentValue, theRest);
if (!validation.valid) {
(0, utils_js_1.exit)(2, `Validation error for ${fullArgumentName}: ${validation.message}`);
return {};
}
else {
parsedArgs[selectedArgument] = await optionConfig.reduceValues(currentValue, validation.value);
}
}
}
else if (first.startsWith('-')) {
if (rest.length > 0 && !isLast) {
(0, utils_js_1.exit)(2, `Boolean flag(s) ${first} should not have values but received ${rest.join(', ')}`);
return {};
}
else if (isLast && rest.length > 0) {
// If we're at the last argument and there are values, check if operands are expected
if (!expectOperands) {
(0, utils_js_1.exit)(2, `Boolean flag(s) ${first} should not have values but received ${rest.join(', ')}`);
return {};
}
else {
operands.push(...rest);
}
}
// Short options (-s, -spq)
for (const char of first.slice(1)) {
if (!(char in booleanOptions)) {
(0, utils_js_1.exit)(2, `Unknown flag: -${char}`);
}
const key = booleanOptions[char];
parsedArgs[key] = true;
}
}
}
if (numberOfSubcommands > 0 && additionalOptions?.requireSubcommand && !subcommand) {
(0, utils_js_1.exit)(2, `A subcommand is required`);
}
// Step 5: Validate operands if expectOperands is false
if (!expectOperands && operands.length > 0) {
(0, utils_js_1.exit)(2, `Operands are not expected but received: ${operands.join(', ')}`);
}
// Step 4: Return results
return {
args: { ...allDefaults, ...parsedEnvironmentArgs, ...parsedArgs },
operands: expectOperands ? operands : [],
subcommand: subcommand,
anyValues: args.length > 0,
printHelp: () => {
printHelpContents(command, subcommands, cliArgs, additionalOptions, subcommand);
}
};
}
/**
* Initialize the default values for arguments.
*
* Will populate the parsedArgs object with default values from the cliArguments.
* Will also populate the booleanOptions map with single character boolean options.
*
* @param parsedArgs the parsed arguments to default the values in
* @param booleanOptions a map of single character boolean options to their full names
* @param cliArguments the configuration options for the CLI commands
*/
function initializeOptionDefaults(parsedArgs, booleanOptions, cliArguments) {
for (const [key, option] of Object.entries(cliArguments)) {
parsedArgs[key] = option.defaultValue;
if (option.character) {
booleanOptions[option.character.toLowerCase()] = key;
}
}
}
/**
* Initialize the default value for a specific argument if it is not already set.
*
* If the argument is not already present in parsedArgs, it will be set to the default value from cliArguments.
*
* @param parsedArgs the parsed arguments so far.
* @param cliArguments the CLI arguments configuration
* @param key the specific argument key to initialize
*/
function initializeDefault(parsedArgs, cliArguments, key) {
if (!Object.hasOwn(parsedArgs, key)) {
parsedArgs[key] = cliArguments[key].defaultValue;
}
}
/**
* Parse environment variables and set the values in the parsed arguments.
*
* @param cliArguments the configuration options for the CLI commands
* @param parsedArgs the parsed arguments to set the values in
* @param env the environment variables to get values from
* @param envPrefix the prefix to use for environment variables, if any
*/
async function parseEnvironmentVariables(cliArguments, parsedArgs, env, envPrefix) {
if (!envPrefix) {
return;
}
const prefix = envPrefix + '_';
const envToKeys = Object.keys(cliArguments).reduce((acc, key) => {
acc[camelToCapitalSnakeCase(key)] = key;
return acc;
}, {});
for (const [key, value] of Object.entries(env)) {
if (key.startsWith(prefix)) {
const optionKey = key.slice(prefix.length);
const option = envToKeys[optionKey];
if (option) {
const config = cliArguments[option];
initializeDefault(parsedArgs, cliArguments, option);
if (config.present) {
parsedArgs[option] = await config.present(parsedArgs[option]);
}
if (!config.character) {
const values = value.split(' ');
const validation = await config.validateValues(parsedArgs[option], values);
if (!validation.valid) {
const s = values.length > 1 ? 's' : '';
(0, utils_js_1.exit)(2, `Invalid value${s} for environment ${key}: ${validation.message}`);
return;
}
parsedArgs[option] = await config.reduceValues(parsedArgs[option], validation.value);
}
}
}
}
}
/**
* Groups arguments into chunks based on the -- separator.
*
* @param args
* @returns
*/
function groupArguments(args) {
const grouped = [];
let currentChunk = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--') {
grouped.push({
first: arg,
rest: args.slice(i + 1),
isFirst: grouped.length === 0,
isLast: true
});
break;
}
if (arg.startsWith('-') && currentChunk.length > 0) {
grouped.push({
first: currentChunk.at(0),
rest: currentChunk.slice(1),
isFirst: grouped.length === 0,
isLast: false
});
currentChunk = [];
}
currentChunk.push(arg);
}
if (currentChunk.length > 0) {
grouped.push({
first: currentChunk.at(0),
rest: currentChunk.slice(1),
isFirst: grouped.length === 0,
isLast: true
});
}
return grouped;
}
function camelToCapitalSnakeCase(input) {
return input
.replace(/([a-z])([A-Z])/g, '$1_$2') // Insert underscore before capital letters
.toUpperCase(); // Convert to uppercase
}
function camelToKebabCase(input) {
return input
.replace(/([a-z])([A-Z])/g, '$1-$2') // Insert underscore before capital letters
.toLowerCase(); // Convert to uppercase
}
function printHelpContents(command, subcommands, cliOptions, additionalArgs, selectedSubcommand) {
const logger = additionalArgs?.consoleLogger ?? console;
const operandsExpected = additionalArgs?.expectOperands != undefined ? additionalArgs?.expectOperands : true;
const operandsName = additionalArgs?.operandsName ?? 'operand';
const operandsString = operandsExpected ? ` [--] [${operandsName}1] [${operandsName}2]` : '';
const anyGlobalFlags = Object.values(cliOptions).some((option) => option.character);
if (selectedSubcommand) {
const anyCommandFlags = Object.values(subcommands[selectedSubcommand].arguments).some((option) => option.character);
const flags = anyGlobalFlags || anyCommandFlags ? ' [flags]' : '';
let usageString = `Usage: ${command} ${selectedSubcommand} [options]${flags}${operandsString}`;
if (additionalArgs?.allowOperandsFromStdin) {
usageString += `\n <${operandsName}s to stdout> | ${command} ${selectedSubcommand} [options]${flags}`;
}
logger.log(usageString);
logger.log('');
logger.log(`${subcommands[selectedSubcommand].description}`);
logger.log(`${selectedSubcommand} Options:`);
printOptions(subcommands[selectedSubcommand].arguments, logger);
logger.log('');
}
else {
const anyCommandFlags = Object.values(subcommands).some((subcommand) => Object.values(subcommand.arguments).some((option) => option.character));
const flags = anyGlobalFlags || anyCommandFlags ? ' [flags]' : '';
let singleUseString = `${command}`;
const subcommandKeys = Object.keys(subcommands);
if (subcommandKeys.length > 0 && additionalArgs?.requireSubcommand) {
singleUseString += ' <subcommand>';
}
else if (subcommandKeys.length > 0) {
singleUseString += ' [subcommand]';
}
singleUseString += ` [options]${flags}`;
let usageString = `Usage: ${singleUseString}${operandsString}`;
if (additionalArgs?.allowOperandsFromStdin) {
usageString += `\n <${operandsName}s to stdout> | ${singleUseString}`;
}
logger.log(usageString);
const longestCommand = subcommandKeys.reduce((acc, cmd) => Math.max(acc, cmd.length), 0);
if (subcommandKeys.length > 0) {
logger.log('Subcommands:');
for (const cmd of subcommandKeys) {
const description = subcommands[cmd].description;
logger.log(` ${(cmd + ':').padEnd(longestCommand + 1)} ${description}`);
}
logger.log('');
logger.log(` Use ${command} <subcommand> --help for more information about a subcommand`);
}
}
logger.log('Global Options:');
const globalOptions = {
...cliOptions,
...{
help: {
description: 'Print the help contents and exit'
}
}
};
if (additionalArgs?.version) {
globalOptions['version'] = {
description: 'Print the version and exit'
};
}
printOptions(globalOptions, logger);
}
function printOptions(cliOptions, logger) {
const longestOption = Object.keys(cliOptions).reduce((acc, key) => Math.max(acc, camelToKebabCase(key).length + 2), 0) + 1;
const terminalWidth = process.stdout.columns ?? 80;
const nonBooleanBuffer = ' ';
for (const [key, option] of Object.entries(cliOptions)) {
let optionString = ` --${camelToKebabCase(key)}:`.padEnd(longestOption + 3);
if (option.character) {
optionString += `(-${option.character}) `;
}
else {
optionString += nonBooleanBuffer;
}
const leftBar = optionString.length;
optionString += option.description;
logger.log(optionString.slice(0, terminalWidth));
let stringToPrint = optionString.slice(terminalWidth);
const secondLineLength = terminalWidth - leftBar;
while (stringToPrint.length > 0) {
logger.log(' '.repeat(leftBar) + stringToPrint.slice(0, secondLineLength).trimStart());
stringToPrint = stringToPrint.slice(secondLineLength);
}
}
}
async function printVersion(versionInfo, logger) {
if (!versionInfo) {
logger.log('Version information not available. This is a bug.');
return;
}
if (typeof versionInfo === 'string') {
logger.log(versionInfo);
return;
}
let currentVersion = null;
if (typeof versionInfo.currentVersion === 'string') {
currentVersion = versionInfo.currentVersion;
}
else if (typeof versionInfo.currentVersion === 'function') {
currentVersion = await versionInfo.currentVersion();
}
if (!currentVersion) {
logger.log('Current version not available');
return;
}
let latestVersion = null;
if (typeof versionInfo.checkForUpdates == 'string') {
latestVersion = await getLatestVersionFromNpm(versionInfo.checkForUpdates);
}
else if (typeof versionInfo.checkForUpdates === 'function') {
latestVersion = await versionInfo.checkForUpdates();
}
let updateMessage = undefined;
if (currentVersion && latestVersion && currentVersion !== latestVersion) {
if (versionInfo.updateMessage) {
updateMessage = versionInfo.updateMessage(currentVersion, latestVersion);
}
else if (typeof versionInfo.checkForUpdates === 'string') {
updateMessage = `Latest: ${latestVersion}. To update run: npm update -g ${versionInfo.checkForUpdates}`;
}
else {
updateMessage = `Latest: ${latestVersion}.`;
}
}
else if (currentVersion && latestVersion && currentVersion === latestVersion) {
// updateMessage = 'You are using the latest version.'
}
logger.log(currentVersion);
if (updateMessage) {
logger.log(updateMessage);
}
}
/**
* Fetch the latest version of a package from the npm registry.
*
* @param packageName the name of the npm package to check, e.g. "my-cli-tool" or "@my-org/my-cli-tool"
* @returns the latest version of the package published on npm or null if any error occurs
*/
async function getLatestVersionFromNpm(packageName) {
try {
const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`);
if (res.ok) {
const data = await res.json();
return data.version || null;
}
}
catch (e) {
// Ignore errors fetching latest version
}
return null;
}
//# sourceMappingURL=cli.js.map