UNPKG

brocolito

Version:

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

579 lines (576 loc) 18.6 kB
"use strict"; Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); const path = require("node:path"); const process = require("node:process"); const pc = require("picocolors"); const node_util = require("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 } = node_util.parseArgs({ args, options, tokens: true, allowPositionals: true, strict: false, allowNegative: true }); return { values, positionals }; }; const parseGlobalOptions = (args) => { const { values } = 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 }; }; 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 }; exports.pc = pc; exports.CLI = CLI;