brocolito
Version:
Create type-safe CLIs to align local development and pipeline workflows
580 lines (577 loc) • 18.5 kB
JavaScript
import path from "node:path";
import process from "node:process";
import pc from "picocolors";
import { default as default2 } from "picocolors";
import { parseArgs } from "node:util";
const injected = globalThis.__BROCOLITO__ || {
dir: path.resolve("."),
name: "dummy",
version: "dummy",
aliases: void 0
};
const { name, dir, version, aliases } = injected;
const Meta = {
name,
dir,
version,
aliases
};
const systemShell = () => (process.env.SHELL || "").split("/").at(-1);
const parseEnv = (env) => {
var _a;
let cword = Number(env.COMP_CWORD);
let point = Number(env.COMP_POINT);
const line = env.COMP_LINE || "";
const firstArg = line.split(" ")[0];
const alias = (_a = Meta.aliases) == null ? void 0 : _a[firstArg];
if (firstArg && !alias && firstArg !== Meta.name) {
throw new Error(`Completion invoked with not configured alias ${firstArg}.
Use e.g. -> CLI.alias('${firstArg}', '${Meta.name} my-subcommand')
`);
}
const expandedLine = alias ? line.replace(new RegExp(`^${firstArg}`), alias) : line;
if (Number.isNaN(cword)) cword = 0;
if (Number.isNaN(point)) point = 0;
if (alias) point += alias.length - firstArg.length;
const parts = expandedLine.split(" ");
const prev = parts.slice(0, -1).slice(-1)[0];
const last = parts.slice(-1).join("");
const partial = expandedLine.slice(0, point);
const partialParts = partial.split(" ");
const prevPartial = partialParts.slice(0, -1).slice(-1)[0];
const lastPartial = partialParts.slice(-1).join("");
let complete = true;
if (!env.COMP_CWORD || !env.COMP_POINT || !env.COMP_LINE) {
complete = false;
}
return {
complete,
words: cword,
point,
full: {
line: expandedLine,
last,
prev,
parts
},
partial: {
line: partial,
last: lastPartial,
prev: prevPartial,
parts: partialParts
}
};
};
const completionItem = (item) => {
if (typeof item !== "string") return item;
const shell = systemShell();
let name2 = item;
let description = "";
const matching = /^(.*?)(\\)?:(.*)$/.exec(item);
if (matching) {
[, name2, , description] = matching;
}
if (shell === "zsh" && /\\/.test(item)) {
name2 += "\\";
}
return {
name: name2,
description
};
};
const log = (args) => {
const shell = systemShell();
if (!Array.isArray(args)) {
throw new Error("log: Invalid arguments, must be an array");
}
let normalizedArgs = args.map(completionItem).map((item) => {
const { name: name2, description } = item;
let str = name2;
if (shell === "zsh" && description) {
str = `${name2.replace(/:/g, "\\:")}:${description}`;
} else if (shell === "fish" && description) {
str = `${name2} ${description}`;
}
return str;
});
if (shell === "bash") {
const env = parseEnv(process.env);
normalizedArgs = normalizedArgs.filter(
(arg) => arg.indexOf(env.full.last) === 0
);
}
for (const arg of normalizedArgs) {
console.log(`${arg}`);
}
};
const logFiles = () => {
console.log("__tabtab_complete_files__");
};
const Tabtab = { log, parseEnv, logFiles };
const commands = {};
const State = { commands };
const _getRow = (label, { description, alias }, longestLabelLength) => {
const labelPart = ` ${label}${" ".repeat(
3 + longestLabelLength - label.length
)}`;
const [firstDescLine, ...otherLines] = description.split("\n");
return `${labelPart}${firstDescLine}${otherLines.map((l) => "\n" + " ".repeat(labelPart.length) + l).join()}${alias ? ` (alias: ${alias})` : ""}`;
};
const _getRows = (label, values, key) => {
if (!values.length) return "";
const longestLabelLength = values.reduce(
(prev, cur) => Math.max(prev, cur[key].length),
0
);
const rows = values.map(
(v) => _getRow(
v[key],
{ description: v.description, alias: v.alias },
longestLabelLength
)
).join("\n");
return `
${label}:
${rows}
`;
};
const _getHelp = (command2) => {
if (!command2) {
const commands22 = Object.values(State.commands);
const commandRows2 = _getRows("Commands", commands22, "name");
return `Usage:
$ ${Meta.name} <command> [options]
${commandRows2}`;
}
const commands2 = Object.values(command2.subcommands);
const commandRows = _getRows("Commands", commands2, "name");
const argsRows = _getRows("Args", command2.args, "usage");
const options = Object.values(command2.options);
const optionRows = _getRows("Options", options, "usage");
const commandLine = `${Meta.name} ${command2.line}`;
const commandHint = commands2.length ? command2._action ? "[<command>]" : "<command>" : void 0;
const argsHint = command2.args.map(({ usage: usage2 }) => usage2).join(" ");
const optionsHint = options.length || commands2.length ? "[options]" : void 0;
const usage = [commandLine, commandHint, argsHint, optionsHint].filter(Boolean).join(" ");
return `Help:
${command2.description}
Usage:
$ ${usage}
${commandRows}${argsRows}${optionRows}`;
};
const show = (command2) => console.log(_getHelp(command2));
const Help = { show };
const showInstallInstruction = async () => {
const shell = systemShell();
if (shell !== "bash" && shell !== "zsh") {
throw new Error(
'Completion is only supported for "zsh" and "bash" shells. Detected: ' + shell
);
}
const shellRC = `.${shell}rc`;
const command2 = `. ${Meta.dir}/build/${shell}_completion.sh`;
console.log(`
To install auto-completion in ${pc.bold(
pc.yellow(shell)
)} add to your ${pc.blue(shellRC)} the following:
${command2}`);
};
const FileCompletion = ["__files__"];
const toCompleteItems = (commands2) => Object.entries(commands2).map(([key, { description }]) => ({
name: key,
description
}));
const completeArgType = async ({
type,
completion: completion2
}, lastArg) => {
const customCompletion = await (completion2 == null ? void 0 : completion2(lastArg));
if (customCompletion == null ? void 0 : customCompletion.length) return customCompletion;
if (type === "file") return FileCompletion;
if (Array.isArray(type)) return type;
return [];
};
const _completion = async ({
partial
}) => {
if (partial.prev === Meta.name) {
return toCompleteItems(State.commands).concat(["--help"]);
}
const relevantArgs = partial.parts.slice(1);
const lastArg = relevantArgs.pop();
const cmd = findCommand(relevantArgs);
if ("error" in cmd)
return cmd.completionSubcommands ? toCompleteItems(cmd.completionSubcommands) : [];
const commandArgs = cmd.command.args.slice(cmd.positionals.length);
const lastArgInfo = cmd.command.args.at(-1);
const options = Object.values(cmd.command.options);
const startedOption = options.find(
({ name: name2, short }) => "--" + name2 === partial.prev || short && "-" + short === partial.prev
);
const availableOptionItems = options.filter(
({ name: name2, multi, short }) => multi || !relevantArgs.includes("--" + name2) && (!short || !relevantArgs.includes("-" + short))
).map(
({ name: name2, description }) => ({
name: "--" + name2,
description
})
);
if (startedOption && startedOption.type !== "boolean") {
return await completeArgType(
// TS cannot correctly infer that "boolean" case was filtered
startedOption,
lastArg
);
} else if (commandArgs.length) {
return await completeArgType(commandArgs[0], lastArg);
} else if (lastArgInfo == null ? void 0 : lastArgInfo.multi) {
return await completeArgType(lastArgInfo, lastArg);
} else {
return availableOptionItems;
}
};
const completion = async (env) => {
const result = await _completion(env);
return result === FileCompletion ? Tabtab.logFiles() : Tabtab.log(result);
};
const run = async () => {
const env = Tabtab.parseEnv(process.env);
if (env.complete) return completion(env);
await showInstallInstruction();
};
const Completion = { run };
const camelize = (str) => str.replace(/(-[a-zA-Z])/g, (w) => w[1].toUpperCase());
const deriveInfo = (usage) => {
var _a;
const match = usage.match(/^<([a-z0-9-]+)(:.+?)?(\.{3})?>$/i);
if (!match) {
throw new Error(
`Invalid usage specified for arg '${usage}'. Required pattern: <[a-z0-9-]+(\\.{3})?>`
);
}
const m = ((_a = match[2]) == null ? void 0 : _a.substring(1)) ?? "string";
return {
name: camelize(match[1]),
type: m === "string" || m === "file" ? m : m.split("|"),
multi: !!match[3]
};
};
const deriveOptionInfo = (usage) => {
const match = usage.match(
/^--(?<name>[a-z0-9-]+)(\|-(?<short>[a-z]))?(?<mandatory>!)?(?: (?<optionType>.+))?$/i
);
if (!(match == null ? void 0 : match.groups)) {
throw new Error(
`Invalid usage specified for option '${usage}'. Required pattern: --[a-z0-9-]+(|-[a-z])?!?( .+)?`
);
}
const name2 = match.groups.name;
const short = match.groups.short;
const mandatory = !!match.groups.mandatory;
const optionType = match.groups.optionType;
if (!optionType) {
return { name: name2, short, type: "boolean", mandatory, multi: false };
}
const optionTypeMatch = optionType.match(/^<(.+?)(\.{3})?>$/);
if (!optionTypeMatch) {
return { name: name2, short, type: "string", mandatory, multi: false };
}
const m = optionTypeMatch[1] ?? "string";
return {
name: name2,
short,
type: m === "string" || m === "file" ? m : m.split("|"),
mandatory,
multi: !!optionTypeMatch[2]
};
};
const parse$1 = (command2, args) => {
const options = Object.values(command2.options).reduce(
(red, meta) => {
red[meta.name] = {
type: meta.type === "boolean" ? "boolean" : "string",
multiple: meta.multi
};
if (meta.short) red[meta.name].short = meta.short;
return red;
},
{}
);
const { values, positionals } = parseArgs({
args,
options,
tokens: true,
allowPositionals: true,
strict: false,
allowNegative: true
});
return { values, positionals };
};
const parseGlobalOptions = (args) => {
const { values } = parseArgs({
args,
options: {
help: {
type: "boolean",
short: "h"
},
version: {
type: "boolean",
short: "v"
}
},
allowPositionals: true,
strict: false
});
return { wantsHelp: !!values.help, wantsVersion: !!values.version };
};
const Arguments = {
camelize,
deriveInfo,
deriveOptionInfo,
parse: parse$1,
parseGlobalOptions
};
const _findByNameOrAlias = (name2, commands2) => {
if (name2 in commands2) return commands2[name2];
return Object.values(commands2).find(({ alias }) => alias === name2);
};
const _findSubcommand = (command2, positionals, depth) => {
if (!Object.keys(command2.subcommands).length) {
return { command: command2, depth };
}
if (!positionals.length) {
return {
command: command2,
error: "Specify a subcommand to be used.",
completionSubcommands: command2.subcommands,
depth
};
}
const subcommand = positionals.length ? _findByNameOrAlias(positionals[0], command2.subcommands) : void 0;
if (!subcommand) {
return {
command: command2,
error: `Unknown subcommand ${pc.yellow(positionals[0])} specified.`,
depth
};
}
return _findSubcommand(subcommand, positionals.slice(1), depth + 1);
};
const findCommand = (args) => {
const firstNonPositionalIndex = args.findIndex((arg) => arg.startsWith("-"));
const commandPositionals = firstNonPositionalIndex >= 0 ? args.slice(0, firstNonPositionalIndex) : args;
const command2 = _findByNameOrAlias(commandPositionals[0], State.commands);
if (!command2) {
return { error: `Command "${commandPositionals[0]}" does not exist` };
}
const cmd = _findSubcommand(command2, commandPositionals.slice(1), 1);
if ("error" in cmd) return cmd;
const { values, positionals } = Arguments.parse(
cmd.command,
args.slice(cmd.depth)
);
return { ...cmd, values, positionals };
};
const _parseArgs = (command2, args) => {
const throwError = (reason, remainingArgs) => {
Help.show(command2);
throw new Error(
`${reason}: Expected ${command2.args.length} arguments, but was invoked with ${args.length}.${remainingArgs ? `
The following arguments could not be processed: ${pc.yellow(
remainingArgs.join(" ")
)}` : ""}`
);
};
let usedArgs = [...args];
const result = Object.fromEntries(
command2.args.map(
({ usage, name: name2, type, multi }) => {
if (!usedArgs.length) {
return multi ? [name2, []] : throwError("Too few arguments given");
}
const checkValiditiy = (v) => {
if (Array.isArray(type) && !type.includes(v)) {
throw new Error(`Invalid value "${v}" provided for arg ${usage}.`);
}
};
if (!multi) {
const arg = usedArgs.shift();
checkValiditiy(arg);
return [name2, arg];
} else {
const copy = [...usedArgs];
copy.forEach(checkValiditiy);
usedArgs = [];
return [name2, copy];
}
}
)
);
if (usedArgs.length) return throwError("Too many arguments given", usedArgs);
return result;
};
const _parseOptions = (command2, options) => {
const opts = {};
Object.entries(command2.options).forEach(
([camelName, { name: name2, type, mandatory }]) => {
const value = options[name2];
delete options[name2];
if (mandatory && value === void 0) {
throw new Error(`Mandatory options was not provided: --${name2}`);
}
if (typeof value === "boolean") {
if (type !== "boolean") {
throw new Error(`Parameter missing for option --${name2}`);
}
opts[camelName] = value;
} else if (type === "boolean") {
if (typeof value === "string" && value !== "true" && value !== "false") {
throw new Error(
`Invalid value "${value}" provided for flag --${name2}`
);
}
opts[camelName] = value === "true";
} else if (value !== void 0) {
const checkValiditiy = (v) => {
if (typeof v === "boolean") {
throw new Error(
`Invalid boolean in list of values provided for flag --${name2}`
);
}
if (Array.isArray(type) && !type.includes(v)) {
throw new Error(
`Invalid value "${v}" provided for flag --${name2}. Must be one of: ${type.join(
" | "
)}`
);
}
};
if (Array.isArray(value)) {
value.forEach(checkValiditiy);
opts[camelName] = value;
} else {
checkValiditiy(value);
opts[camelName] = value;
}
}
}
);
const remainingArgs = Object.keys(options);
if (remainingArgs.length) {
throw new Error(
`Unrecognized options were used: ${remainingArgs.map((name2) => "--" + name2).join(", ")}`
);
}
return opts;
};
const parse = async (argv = process.argv) => {
const relevantArgs = argv.slice(2);
const { wantsHelp, wantsVersion } = Arguments.parseGlobalOptions(relevantArgs);
const firstArg = relevantArgs[0];
if (firstArg === "completion") return Completion.run();
if (!firstArg || firstArg[0] === "-") {
if (wantsVersion) {
return console.log(Meta.version);
}
return Help.show();
}
const cmd = findCommand(relevantArgs);
if (wantsHelp) return Help.show(cmd.command);
if ("error" in cmd) {
if (cmd.command) Help.show(cmd.command);
throw new Error(cmd.error);
}
const options = _parseOptions(cmd.command, cmd.values);
const parsedArgs = _parseArgs(cmd.command, cmd.positionals);
const action = cmd.command._action;
if (!action) {
throw new Error(
`Configuration error: No action for given command "${cmd.command.line}" specified`
);
}
return await action({ ...options, ...parsedArgs });
};
process.on("unhandledRejection", (err) => {
const errMsg = String(
err instanceof Error ? process.env.DEBUG ? err.stack : err.message : err
);
if (process.env.NODE_ENV === "test") {
throw new Error(errMsg);
} else {
console.log(pc.red(errMsg));
process.exit(1);
}
});
const createAction = (command2) => (a) => {
command2._action = a;
};
const createOption = (command2) => (usage, description, opts) => {
const newCommand = command2;
const info = Arguments.deriveOptionInfo(usage);
newCommand.options[Arguments.camelize(info.name)] = {
usage,
description,
...info,
...opts
};
return newCommand;
};
const createSubcommand = (command2) => (name2, options, cb) => {
const opts = typeof options === "string" ? { description: options } : options;
const subcommand = {
...command2,
name: name2,
line: `${command2.line} ${name2}`,
args: [],
description: opts.description,
alias: opts.alias,
options: { ...command2.options }
};
subcommand.arg = createArg(subcommand);
subcommand.action = createAction(subcommand);
subcommand.option = createOption(subcommand);
subcommand.subcommand = createSubcommand(subcommand);
subcommand.subcommands = {};
command2.subcommands[name2] = subcommand;
cb(subcommand);
const { arg: _, args: __, ...rest } = command2;
return rest;
};
const createArg = (command2) => (usage, description, opts) => {
const { subcommand: _, subcommands: __, ...newCommand } = command2;
const info = Arguments.deriveInfo(usage);
newCommand.args.push({ usage, description, ...info, ...opts });
return newCommand;
};
const command = (name2, options) => {
const opts = typeof options === "string" ? { description: options } : options;
const command2 = {
name: name2,
line: name2,
description: opts.description,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
action: null,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
arg: null,
args: [],
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
option: null,
options: {},
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
subcommand: null,
subcommands: {},
alias: opts.alias
};
command2.action = createAction(command2);
command2.arg = createArg(command2);
command2.option = createOption(command2);
command2.subcommand = createSubcommand(command2);
State.commands[name2] = command2;
return command2;
};
const CLI = { command, parse, _state: State, meta: Meta };
export {
CLI,
default2 as pc
};