brocolito
Version:
Create type-safe CLIs to align local development and pipeline workflows
551 lines (548 loc) • 18.7 kB
JavaScript
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
//#region \0rolldown/runtime.js
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
key = keys[i];
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
get: ((k) => from[k]).bind(null, key),
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
});
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
value: mod,
enumerable: true
}) : target, mod));
//#endregion
let node_path = require("node:path");
node_path = __toESM(node_path, 1);
let node_process = require("node:process");
node_process = __toESM(node_process, 1);
let picocolors = require("picocolors");
picocolors = __toESM(picocolors, 1);
let node_util = require("node:util");
//#region src/meta.ts
var { name, dir, version, aliases } = globalThis.__BROCOLITO__ || {
dir: node_path.default.resolve("."),
name: "dummy",
version: "dummy",
aliases: void 0
};
var Meta = {
name,
dir,
version,
aliases
};
//#endregion
//#region src/completion/tabtab.ts
/**
* Utility to figure out the shell used on the system.
*
* Sadly, we can't use `echo $0` in node, maybe with more work. So we rely on
* process.env.SHELL.
*/
var systemShell = () => (node_process.default.env.SHELL || "").split("/").at(-1);
/**
* Public: Main utility to extract information from command line arguments and
* Environment variables, namely COMP args in "plumbing" mode.
*/
var parseEnv = (env) => {
let cword = Number(env.COMP_CWORD);
let point = Number(env.COMP_POINT);
const line = env.COMP_LINE || "";
const firstArg = line.split(" ")[0];
const alias = Meta.aliases?.[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
}
};
};
/**
* Helper to normalize String and Objects with { name, description } when logging out.
*/
var completionItem = (item) => {
if (typeof item !== "string") return item;
const shell = systemShell();
let name = item;
let description = "";
const matching = /^(.*?)(\\)?:(.*)$/.exec(item);
if (matching) [, name, , description] = matching;
if (shell === "zsh" && /\\/.test(item)) name += "\\";
return {
name,
description
};
};
/**
* Main logging utility to pass completion items.
*
* This is simply a helper to log to stdout with each item separated by a new
* line.
*
* Bash needs in addition to filter out the args for the completion to work
* (zsh, fish don't need this).
*/
var 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, description } = item;
let str = name;
if (shell === "zsh" && description) str = `${name.replace(/:/g, "\\:")}:${description}`;
else if (shell === "fish" && description) str = `${name}\t${description}`;
return str;
});
if (shell === "bash") {
const env = parseEnv(node_process.default.env);
normalizedArgs = normalizedArgs.filter((arg) => arg.indexOf(env.full.last) === 0);
}
for (const arg of normalizedArgs) console.log(`${arg}`);
};
/**
* Copied from: https://github.com/pnpm/tabtab
* Logging utility to trigger the filesystem autocomplete.
*
* This function just returns a constant string that is then interpreted by the
* completion scripts as an instruction to trigger the built-in filesystem
* completion.
*/
var logFiles = () => {
console.log("__tabtab_complete_files__");
};
var Tabtab = {
log,
parseEnv,
logFiles
};
var State = { commands: {} };
//#endregion
//#region src/help.ts
var _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})` : ""}`;
};
var _getRows = (label, values, key) => {
if (!values.length) return "";
const longestLabelLength = values.reduce((prev, cur) => Math.max(prev, cur[key].length), 0);
return `\n${label}:\n${values.map((v) => _getRow(v[key], {
description: v.description,
alias: v.alias
}, longestLabelLength)).join("\n")}\n`;
};
var _getHelp = (command) => {
if (!command) {
const commandRows = _getRows("Commands", Object.values(State.commands), "name");
return `Usage:
$ ${Meta.name} <command> [options]
${commandRows}`;
}
const commands = Object.values(command.subcommands);
const commandRows = _getRows("Commands", commands, "name");
const argsRows = _getRows("Args", command.args, "usage");
const options = Object.values(command.options);
const optionRows = _getRows("Options", options, "usage");
const usage = [
`${Meta.name} ${command.line}`,
commands.length ? command._action ? "[<command>]" : "<command>" : void 0,
command.args.map(({ usage }) => usage).join(" "),
options.length || commands.length ? "[options]" : void 0
].filter(Boolean).join(" ");
return `Help:
${command.description}
Usage:
$ ${usage}
${commandRows}${argsRows}${optionRows}`;
};
var show = (command) => console.log(_getHelp(command));
var Help = { show };
//#endregion
//#region src/completion/install.ts
var 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 command = `. ${Meta.dir}/build/${shell}_completion.sh`;
console.log(`
To install auto-completion in ${picocolors.default.bold(picocolors.default.yellow(shell))} add to your ${picocolors.default.blue(shellRC)} the following:
${command}`);
};
//#endregion
//#region src/completion/completion.ts
var FileCompletion = ["__files__"];
var toCompleteItems = (commands) => Object.entries(commands).map(([key, { description }]) => ({
name: key,
description
}));
var completeArgType = async ({ type, completion }, lastArg) => {
const customCompletion = await completion?.(lastArg);
if (customCompletion?.length) return customCompletion;
if (type === "file") return FileCompletion;
if (Array.isArray(type)) return type;
return [];
};
var _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, short }) => "--" + name === partial.prev || short && "-" + short === partial.prev);
const availableOptionItems = options.filter(({ name, multi, short }) => multi || !relevantArgs.includes("--" + name) && (!short || !relevantArgs.includes("-" + short))).map(({ name, description }) => ({
name: "--" + name,
description
}));
if (startedOption && startedOption.type !== "boolean") return await completeArgType(startedOption, lastArg);
else if (commandArgs.length) return await completeArgType(commandArgs[0], lastArg);
else if (lastArgInfo?.multi) return await completeArgType(lastArgInfo, lastArg);
else return availableOptionItems;
};
var completion = async (env) => {
const result = await _completion(env);
return result === FileCompletion ? Tabtab.logFiles() : Tabtab.log(result);
};
var run = async () => {
const env = Tabtab.parseEnv(node_process.default.env);
if (env.complete) return completion(env);
await showInstallInstruction();
};
var Completion = { run };
//#endregion
//#region src/arguments.ts
var camelize = (str) => str.replace(/(-[a-zA-Z])/g, (w) => w[1].toUpperCase());
var deriveInfo = (usage) => {
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 = match[2]?.substring(1) ?? "string";
return {
name: camelize(match[1]),
type: m === "string" || m === "file" ? m : m.split("|"),
multi: !!match[3]
};
};
var deriveOptionInfo = (usage) => {
const match = usage.match(/^--(?<name>[a-z0-9-]+)(\|-(?<short>[a-z]))?(?<mandatory>!)?(?: (?<optionType>.+))?$/i);
if (!match?.groups) throw new Error(`Invalid usage specified for option '${usage}'. Required pattern: --[a-z0-9-]+(|-[a-z])?!?( .+)?`);
const name = match.groups.name;
const short = match.groups.short;
const mandatory = !!match.groups.mandatory;
const optionType = match.groups.optionType;
if (!optionType) return {
name,
short,
type: "boolean",
mandatory,
multi: false
};
const optionTypeMatch = optionType.match(/^<(.+?)(\.{3})?>$/);
if (!optionTypeMatch) return {
name,
short,
type: "string",
mandatory,
multi: false
};
const m = optionTypeMatch[1] ?? "string";
return {
name,
short,
type: m === "string" || m === "file" ? m : m.split("|"),
mandatory,
multi: !!optionTypeMatch[2]
};
};
var parse$1 = (command, args) => {
const { values, positionals } = (0, node_util.parseArgs)({
args,
options: Object.values(command.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;
}, {}),
tokens: true,
allowPositionals: true,
strict: false,
allowNegative: true
});
return {
values,
positionals
};
};
var parseGlobalOptions = (args) => {
const { values } = (0, node_util.parseArgs)({
args,
options: {
help: {
type: "boolean",
short: "h"
},
version: {
type: "boolean",
short: "v"
}
},
allowPositionals: true,
strict: false
});
return {
wantsHelp: !!values.help,
wantsVersion: !!values.version
};
};
var Arguments = {
camelize,
deriveInfo,
deriveOptionInfo,
parse: parse$1,
parseGlobalOptions
};
//#endregion
//#region src/parse.ts
var _findByNameOrAlias = (name, commands) => {
if (name in commands) return commands[name];
return Object.values(commands).find(({ alias }) => alias === name);
};
var _findSubcommand = (command, positionals, depth) => {
if (!Object.keys(command.subcommands).length) return {
command,
depth
};
if (!positionals.length) return {
command,
error: "Specify a subcommand to be used.",
completionSubcommands: command.subcommands,
depth
};
const subcommand = positionals.length ? _findByNameOrAlias(positionals[0], command.subcommands) : void 0;
if (!subcommand) return {
command,
error: `Unknown subcommand ${picocolors.default.yellow(positionals[0])} specified.`,
depth
};
return _findSubcommand(subcommand, positionals.slice(1), depth + 1);
};
var findCommand = (args) => {
const firstNonPositionalIndex = args.findIndex((arg) => arg.startsWith("-"));
const commandPositionals = firstNonPositionalIndex >= 0 ? args.slice(0, firstNonPositionalIndex) : args;
const command = _findByNameOrAlias(commandPositionals[0], State.commands);
if (!command) return { error: `Command "${commandPositionals[0]}" does not exist` };
const cmd = _findSubcommand(command, 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
};
};
var _parseArgs = (command, args) => {
const throwError = (reason, remainingArgs) => {
Help.show(command);
throw new Error(`${reason}: Expected ${command.args.length} arguments, but was invoked with ${args.length}.${remainingArgs ? `
The following arguments could not be processed: ${picocolors.default.yellow(remainingArgs.join(" "))}` : ""}`);
};
let usedArgs = [...args];
const result = Object.fromEntries(command.args.map(({ usage, name, type, multi }) => {
if (!usedArgs.length) return multi ? [name, []] : 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 [name, arg];
} else {
const copy = [...usedArgs];
copy.forEach(checkValiditiy);
usedArgs = [];
return [name, copy];
}
}));
if (usedArgs.length) return throwError("Too many arguments given", usedArgs);
return result;
};
var _parseOptions = (command, options) => {
const opts = {};
Object.entries(command.options).forEach(([camelName, { name, type, mandatory }]) => {
const value = options[name];
delete options[name];
if (mandatory && value === void 0) throw new Error(`Mandatory options was not provided: --${name}`);
if (typeof value === "boolean") {
if (type !== "boolean") throw new Error(`Parameter missing for option --${name}`);
opts[camelName] = value;
} else if (type === "boolean") {
if (typeof value === "string" && value !== "true" && value !== "false") throw new Error(`Invalid value "${value}" provided for flag --${name}`);
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 --${name}`);
if (Array.isArray(type) && !type.includes(v)) throw new Error(`Invalid value "${v}" provided for flag --${name}. 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((name) => "--" + name).join(", ")}`);
return opts;
};
var parse = async (argv = node_process.default.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
});
};
//#endregion
//#region src/brocolito.ts
node_process.default.on("unhandledRejection", (err) => {
const errMsg = String(err instanceof Error ? node_process.default.env.DEBUG ? err.stack : err.message : err);
if (node_process.default.env.NODE_ENV === "test") throw new Error(errMsg);
else {
console.log(picocolors.default.red(errMsg));
node_process.default.exit(1);
}
});
var createAction = (command) => (a) => {
command._action = a;
};
var createOption = (command) => (usage, description, opts) => {
const newCommand = command;
const info = Arguments.deriveOptionInfo(usage);
newCommand.options[Arguments.camelize(info.name)] = {
usage,
description,
...info,
...opts
};
return newCommand;
};
var createSubcommand = (command) => (name, options, cb) => {
const opts = typeof options === "string" ? { description: options } : options;
const subcommand = {
...command,
name,
line: `${command.line} ${name}`,
args: [],
description: opts.description,
alias: opts.alias,
options: { ...command.options }
};
subcommand.arg = createArg(subcommand);
subcommand.action = createAction(subcommand);
subcommand.option = createOption(subcommand);
subcommand.subcommand = createSubcommand(subcommand);
subcommand.subcommands = {};
command.subcommands[name] = subcommand;
cb(subcommand);
const { arg: _, args: __, ...rest } = command;
return rest;
};
var createArg = (command) => (usage, description, opts) => {
const { subcommand: _, subcommands: __, ...newCommand } = command;
const info = Arguments.deriveInfo(usage);
newCommand.args.push({
usage,
description,
...info,
...opts
});
return newCommand;
};
var command = (name, options) => {
const opts = typeof options === "string" ? { description: options } : options;
const command = {
name,
line: name,
description: opts.description,
action: null,
arg: null,
args: [],
option: null,
options: {},
subcommand: null,
subcommands: {},
alias: opts.alias
};
command.action = createAction(command);
command.arg = createArg(command);
command.option = createOption(command);
command.subcommand = createSubcommand(command);
State.commands[name] = command;
return command;
};
var CLI = {
command,
parse,
_state: State,
meta: Meta
};
//#endregion
exports.CLI = CLI;
exports.pc = picocolors.default;