UNPKG

brocolito

Version:

Create type-safe CLIs to align local development and pipeline workflows

524 lines (521 loc) 17.3 kB
import path from "node:path"; import process from "node:process"; import pc, { default as pc$1 } from "picocolors"; import { parseArgs } from "node:util"; //#region src/meta.ts var { name, dir, version, aliases } = globalThis.__BROCOLITO__ || { dir: path.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 = () => (process.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(process.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 ${pc$1.bold(pc$1.yellow(shell))} add to your ${pc$1.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(process.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 } = 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 } = 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 ${pc$1.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: ${pc$1.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 = 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 }); }; //#endregion //#region src/brocolito.ts 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); } }); 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 export { CLI, pc };