/* This file is part of GNU Taler (C) 2019 GNUnet e.V. GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see */ /** * Imports. */ import process = require("process"); import path = require("path"); import readline = require("readline"); class Converter {} export let INT = new Converter(); export let STRING: Converter = new Converter(); export interface OptionArgs { help?: string; default?: T; } export interface ArgumentArgs { metavar?: string; help?: string; default?: T; } export interface SubcommandArgs { help?: string; } export interface FlagArgs { help?: string; } export interface ProgramArgs { help?: string; } interface ArgumentDef { name: string; conv: Converter; args: ArgumentArgs; required: boolean; } interface SubcommandDef { commandGroup: CommandGroup; name: string; args: SubcommandArgs; } type ActionFn = (x: TG) => void; type SubRecord = { [Y in S]: { [X in N]: V }; }; interface OptionDef { name: string; flagspec: string[]; /** * Converter, only present for options, not for flags. */ conv?: Converter; args: OptionArgs; isFlag: boolean; required: boolean; } function splitOpt(opt: string): { key: string; value?: string } { const idx = opt.indexOf("="); if (idx == -1) { return { key: opt }; } return { key: opt.substring(0, idx), value: opt.substring(idx + 1) }; } function formatListing(key: string, value?: string): string { let res = " " + key; if (!value) { return res; } if (res.length >= 25) { return res + "\n" + " " + value; } else { return res.padEnd(24) + " " + value; } } export class CommandGroup { private shortOptions: { [name: string]: OptionDef } = {}; private longOptions: { [name: string]: OptionDef } = {}; private subcommandMap: { [name: string]: SubcommandDef } = {}; private subcommands: SubcommandDef[] = []; private options: OptionDef[] = []; private arguments: ArgumentDef[] = []; private myAction?: ActionFn; constructor( private argKey: string, private name: string | null, private scArgs: SubcommandArgs, ) {} action(f: ActionFn) { if (this.myAction) { throw Error("only one action supported per command"); } this.myAction = f; } requiredOption( name: N, flagspec: string[], conv: Converter, args: OptionArgs = {}, ): CommandGroup> { const def: OptionDef = { args: args, conv: conv, flagspec: flagspec, isFlag: false, required: true, name: name as string, }; this.options.push(def); for (let flag of flagspec) { if (flag.startsWith("--")) { const flagname = flag.substring(2); this.longOptions[flagname] = def; } else if (flag.startsWith("-")) { const flagname = flag.substring(1); this.shortOptions[flagname] = def; } else { throw Error("option must start with '-' or '--'"); } } return this as any; } maybeOption( name: N, flagspec: string[], conv: Converter, args: OptionArgs = {}, ): CommandGroup> { const def: OptionDef = { args: args, conv: conv, flagspec: flagspec, isFlag: false, required: false, name: name as string, }; this.options.push(def); for (let flag of flagspec) { if (flag.startsWith("--")) { const flagname = flag.substring(2); this.longOptions[flagname] = def; } else if (flag.startsWith("-")) { const flagname = flag.substring(1); this.shortOptions[flagname] = def; } else { throw Error("option must start with '-' or '--'"); } } return this as any; } requiredArgument( name: N, conv: Converter, args: ArgumentArgs = {}, ): CommandGroup> { const argDef: ArgumentDef = { args: args, conv: conv, name: name as string, required: true, }; this.arguments.push(argDef); return this as any; } maybeArgument( name: N, conv: Converter, args: ArgumentArgs = {}, ): CommandGroup> { const argDef: ArgumentDef = { args: args, conv: conv, name: name as string, required: false, }; this.arguments.push(argDef); return this as any; } flag( name: N, flagspec: string[], args: OptionArgs = {}, ): CommandGroup> { const def: OptionDef = { args: args, flagspec: flagspec, isFlag: true, required: false, name: name as string, }; this.options.push(def); for (let flag of flagspec) { if (flag.startsWith("--")) { const flagname = flag.substring(2); this.longOptions[flagname] = def; } else if (flag.startsWith("-")) { const flagname = flag.substring(1); this.shortOptions[flagname] = def; } else { throw Error("option must start with '-' or '--'"); } } return this as any; } subcommand( argKey: GN, name: string, args: SubcommandArgs = {}, ): CommandGroup { const cg = new CommandGroup(argKey as string, name, args); const def: SubcommandDef = { commandGroup: cg, name: name as string, args: args, }; cg.flag("help", ["-h", "--help"], { help: "Show this message and exit.", }); this.subcommandMap[name as string] = def; this.subcommands.push(def); this.subcommands = this.subcommands.sort((x1, x2) => { const a = x1.name; const b = x2.name; if (a === b) { return 0; } else if (a < b) { return -1; } else { return 1; } }); return cg as any; } printHelp(progName: string, parents: CommandGroup[]) { const chain: CommandGroup[] = Array.prototype.concat(parents, [ this, ]); let usageSpec = ""; for (let p of parents) { usageSpec += (p.name ?? progName) + " "; if (p.arguments.length >= 1) { usageSpec += " "; } } usageSpec += (this.name ?? progName) + " "; if (this.subcommands.length != 0) { usageSpec += "COMMAND "; } for (let a of this.arguments) { const argName = a.args.metavar ?? a.name; usageSpec += `<${argName}> `; } usageSpec = usageSpec.trimRight(); console.log(`Usage: ${usageSpec}`); if (this.scArgs.help) { console.log(); console.log(this.scArgs.help); } if (this.options.length != 0) { console.log(); console.log("Options:"); for (let opt of this.options) { let optSpec = opt.flagspec.join(", "); if (!opt.isFlag) { optSpec = optSpec + "=VALUE"; } console.log(formatListing(optSpec, opt.args.help)); } } if (this.subcommands.length != 0) { console.log(); console.log("Commands:"); for (let subcmd of this.subcommands) { console.log(formatListing(subcmd.name, subcmd.args.help)); } } } /** * Run the (sub-)command with the given command line parameters. */ run( progname: string, parents: CommandGroup[], unparsedArgs: string[], parsedArgs: any, ) { let posArgIndex = 0; let argsTerminated = false; let i; let foundSubcommand: CommandGroup | undefined = undefined; const myArgs: any = (parsedArgs[this.argKey] = {}); const foundOptions: { [name: string]: boolean } = {}; const currentName = this.name ?? progname; for (i = 0; i < unparsedArgs.length; i++) { const argVal = unparsedArgs[i]; if (argsTerminated == false) { if (argVal === "--") { argsTerminated = true; continue; } if (argVal.startsWith("--")) { const opt = argVal.substring(2); const r = splitOpt(opt); const d = this.longOptions[r.key]; if (!d) { console.error(`error: unknown option '--${r.key}' for ${currentName}`); process.exit(-1); throw Error("not reached"); } if (d.isFlag) { if (r.value !== undefined) { console.error(`error: flag '--${r.key}' does not take a value`); process.exit(-1); throw Error("not reached"); } myArgs[d.name] = true; } else { if (r.value === undefined) { if (i === unparsedArgs.length - 1) { console.error(`error: option '--${r.key}' needs an argument`); process.exit(-1); throw Error("not reached"); } myArgs[d.name] = unparsedArgs[i+1]; i++; } else { myArgs[d.name] = r.value; } foundOptions[d.name] = true; } continue; } if (argVal.startsWith("-") && argVal != "-") { const optShort = argVal.substring(1); for (let si = 0; si < optShort.length; si++) { const chr = optShort[si]; const opt = this.shortOptions[chr]; if (!opt) { console.error(`error: option '-${chr}' not known`); process.exit(-1); } if (opt.isFlag) { myArgs[opt.name] = true; } else { if (si == optShort.length - 1) { if (i === unparsedArgs.length - 1) { console.error(`error: option '-${chr}' needs an argument`); process.exit(-1); throw Error("not reached"); } else { myArgs[opt.name] = unparsedArgs[i + 1]; i++; } } else { myArgs[opt.name] = optShort.substring(si + 1); } foundOptions[opt.name] = true; break; } } continue; } } if (this.subcommands.length != 0) { const subcmd = this.subcommandMap[argVal]; if (!subcmd) { console.error(`error: unknown command '${argVal}'`); process.exit(-1); throw Error("not reached"); } foundSubcommand = subcmd.commandGroup; break; } else { const d = this.arguments[posArgIndex]; if (!d) { console.error(`error: too many arguments for ${currentName}`); process.exit(-1); throw Error("not reached"); } myArgs[d.name] = unparsedArgs[i]; posArgIndex++; } } for (let i = posArgIndex; i < this.arguments.length; i++) { const d = this.arguments[i]; if (d.required) { if (d.args.default !== undefined) { myArgs[d.name] = d.args.default; } else { console.error(`error: missing positional argument '${d.name}' for ${currentName}`); process.exit(-1); throw Error("not reached"); } } } for (let option of this.options) { if (option.isFlag == false && option.required == true) { if (!foundOptions[option.name]) { if (option.args.default !== undefined) { myArgs[option.name] = option.args.default; } else { const name = option.flagspec.join(",") console.error(`error: missing option '${name}'`); process.exit(-1); throw Error("not reached"); } } } } if (parsedArgs[this.argKey].help) { this.printHelp(progname, parents); process.exit(-1); throw Error("not reached"); } if (foundSubcommand) { foundSubcommand.run( progname, Array.prototype.concat(parents, [this]), unparsedArgs.slice(i + 1), parsedArgs, ); } else if (this.myAction) { let r; try { r = this.myAction(parsedArgs); } catch (e) { console.error(`An error occured while running ${currentName}`); console.error(e); process.exit(1); } Promise.resolve(r).catch((e) => { console.error(`An error occured while running ${currentName}`); console.error(e); process.exit(1); }); } else { this.printHelp(progname, parents); process.exit(-1); throw Error("not reached"); } } } export class Program { private mainCommand: CommandGroup; constructor(argKey: string, args: ProgramArgs = {}) { this.mainCommand = new CommandGroup(argKey, null, { help: args.help, }); this.mainCommand.flag("help", ["-h", "--help"], { help: "Show this message and exit.", }); } run() { const args = process.argv; if (args.length < 2) { console.error( "Error while parsing command line arguments: not enough arguments", ); process.exit(-1); } const progname = path.basename(args[1]); const rest = args.slice(2); this.mainCommand.run(progname, [], rest, {}); } subcommand( argKey: GN, name: string, args: SubcommandArgs = {}, ): CommandGroup { const cmd = this.mainCommand.subcommand(argKey, name as string, args); return cmd as any; } requiredOption( name: N, flagspec: string[], conv: Converter, args: OptionArgs = {}, ): Program> { this.mainCommand.requiredOption(name, flagspec, conv, args); return this as any; } maybeOption( name: N, flagspec: string[], conv: Converter, args: OptionArgs = {}, ): Program> { this.mainCommand.maybeOption(name, flagspec, conv, args); return this as any; } /** * Add a flag (option without value) to the program. */ flag( name: N, flagspec: string[], args: OptionArgs = {}, ): Program> { this.mainCommand.flag(name, flagspec, args); return this as any; } /** * Add a required positional argument to the program. */ requiredArgument( name: N, conv: Converter, args: ArgumentArgs = {}, ): Program> { this.mainCommand.requiredArgument(name, conv, args); return this as any; } /** * Add an optional argument to the program. */ maybeArgument( name: N, conv: Converter, args: ArgumentArgs = {}, ): Program> { this.mainCommand.maybeArgument(name, conv, args); return this as any; } } export type GetArgType = T extends Program ? AT : T extends CommandGroup ? AT : any; export function program( argKey: PN, args: ProgramArgs = {}, ): Program { return new Program(argKey as string, args); } export function prompt(question: string): Promise { const stdinReadline = readline.createInterface({ input: process.stdin, output: process.stdout, }); return new Promise((resolve, reject) => { stdinReadline.question(question, res => { resolve(res); stdinReadline.close(); }); }); }