taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit b8e40ddd669feb5d803315ecb352cdf99ed429b6
parent 73e63363434a5c5aac3cc189304e2a0c3db3db28
Author: Florian Dold <dold@taler.net>
Date:   Thu,  6 Aug 2026 17:08:13 +0200

util: allow marking options experimental, legacy or hidden

Diffstat:
Mpackages/taler-util/src/clk.test.ts | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/clk.ts | 90++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
2 files changed, 158 insertions(+), 15 deletions(-)

diff --git a/packages/taler-util/src/clk.test.ts b/packages/taler-util/src/clk.test.ts @@ -334,3 +334,86 @@ test("CLK-26: a word longer than the width is not broken", (t) => { const out = captureHelp(() => prog.run(["prog", "--help"])); assert.ok(out.includes(uri)); }); + +test("CLK-17: marked options are listed only when the policy says so", (t) => { + const policy = testPolicy([]); + const prog = clk.program("o1", { markPolicy: policy }); + prog + .maybeOption("plain", ["--plain"], clk.STRING, { help: "a plain one" }) + .maybeOption("secret", ["--secret"], clk.STRING, { + help: "a secret one", + mark: "hidden", + }) + .flag("old", ["--old"], { help: "an old one", mark: "legacy" }) + .action(() => {}); + const out = captureHelp(() => prog.run(["prog", "--help"])); + assert.ok(out.includes("--plain")); + assert.ok(!out.includes("--secret")); + assert.ok(!out.includes("--old")); +}); + +test("CLK-18: an enabled option mark is listed and annotated", (t) => { + const policy = testPolicy(["legacy"]); + const prog = clk.program("o2", { markPolicy: policy }); + prog + .flag("old", ["--old"], { help: "an old one", mark: "legacy" }) + .action(() => {}); + const out = captureHelp(() => prog.run(["prog", "--help"])); + assert.ok(out.includes("[legacy] an old one")); +}); + +test("CLK-19: a hidden option still works", (t) => { + const policy = testPolicy([]); + let captured: any; + const prog = clk.program("o3", { markPolicy: policy }); + prog + .maybeOption("secret", ["--secret"], clk.STRING, { mark: "hidden" }) + .action((args) => { + captured = args; + }); + captureHelp(() => prog.run(["prog", "--secret", "x"])); + assert.strictEqual(captured?.o3?.secret, "x"); + assert.deepStrictEqual(policy.checked, [["hidden", "--secret"]]); +}); + +test("CLK-20: a marked option is checked only when it is given", (t) => { + const policy = testPolicy([]); + const prog = clk.program("o4", { markPolicy: policy }); + prog + .flag("old", ["--old"], { mark: "legacy" }) + .maybeOption("other", ["--other"], clk.STRING, { default: "d" }) + .action(() => {}); + captureHelp(() => prog.run(["prog", "--other", "v"])); + assert.deepStrictEqual(policy.checked, []); +}); + +test("CLK-21: an experimental option is refused, under its long flag", (t) => { + const policy = testPolicy([]); + let ran = false; + const prog = clk.program("o5", { markPolicy: policy }); + prog.flag("wip", ["-w", "--wip"], { mark: "experimental" }).action(() => { + ran = true; + }); + captureHelp(() => prog.run(["prog", "-w"])); + assert.deepStrictEqual(policy.checked, [["experimental", "--wip"]]); + assert.ok(!ran); +}); + +test("CLK-22: a marked global option is checked when a subcommand runs", (t) => { + const policy = testPolicy([]); + const prog = clk.program("o6", { markPolicy: policy }); + prog.flag("old", ["--old"], { mark: "legacy" }); + prog.subcommand("sub", "sub").action(() => {}); + captureHelp(() => prog.run(["prog", "--old", "sub"])); + assert.deepStrictEqual(policy.checked, [["legacy", "--old"]]); +}); + +test("CLK-23: --help is not gated by an option mark", (t) => { + const policy = testPolicy([]); + const prog = clk.program("o7", { markPolicy: policy }); + prog.flag("wip", ["--wip"], { mark: "experimental" }); + prog.subcommand("sub", "sub", { help: "a leaf" }).action(() => {}); + const out = captureHelp(() => prog.run(["prog", "--wip", "sub", "--help"])); + assert.deepStrictEqual(policy.checked, []); + assert.ok(out.includes("a leaf")); +}); diff --git a/packages/taler-util/src/clk.ts b/packages/taler-util/src/clk.ts @@ -37,6 +37,7 @@ export namespace clk { help?: string; default?: T; onPresentHandler?: (v: T) => void; + mark?: CommandMark; } export interface ArgumentArgs<T> { @@ -46,8 +47,8 @@ export namespace clk { } /** - * Status of a command, for programs that don't want to offer all of - * their commands on equal terms. + * Status of a command or of an option, for programs that don't want to + * offer all of them on equal terms. * * What a mark means is up to the program's CommandMarkPolicy; clk only * carries it, decides what to list, and asks before running. @@ -60,17 +61,27 @@ export namespace clk { */ const MARK_STRENGTH: CommandMark[] = ["hidden", "legacy", "experimental"]; + /** + * Where a command group remembers which of its options were given on the + * command line rather than defaulted. A symbol, so that it doesn't + * collide with an option name and stays out of the parsed arguments as + * far as every ordinary consumer is concerned. + */ + const FOUND_OPTIONS = Symbol("clk-found-options"); + export interface CommandMarkPolicy { /** - * Is a command carrying this mark listed in its parent's help? + * Is a command or option carrying this mark listed in the help? */ isListed(mark: CommandMark): boolean; /** - * Called before a command runs, with the strictest mark on its path - * and the full command name. May warn, or not return at all. + * Called before a command runs: once with the strictest mark on the + * command's path and the full command name, and once per marked option + * that was actually given, with that option's flag. May warn, or not + * return at all. */ - check(mark: CommandMark, commandPath: string): void; + check(mark: CommandMark, what: string): void; } export interface SubcommandArgs { @@ -426,6 +437,43 @@ export namespace clk { * A group's mark covers everything below it, so that marking a group * is enough and its subcommands don't have to repeat it. */ + /** + * Does something carrying this mark show up in the help? Without a + * policy nothing is held back. + */ + private isListed(args: { mark?: CommandMark }): boolean { + return ( + args.mark == null || + this.markPolicy == null || + this.markPolicy.isListed(args.mark) + ); + } + + /** + * Ask the policy about every marked option that was actually given. + * + * Options are reported one by one under their own flag: unlike a + * command, several of them can apply at once and lumping them together + * would hide which one the policy is talking about. + */ + private checkOptionMarkPolicy(myArgs: any): void { + const policy = this.markPolicy; + if (policy == null) { + return; + } + const found: { [name: string]: boolean } = myArgs[FOUND_OPTIONS] ?? {}; + for (const option of this.options) { + const mark = option.args.mark; + if (mark == null || !found[option.name]) { + continue; + } + // The long flag is what documentation and scripts use. + const flag = + option.flagspec.find((f) => f.startsWith("--")) ?? option.flagspec[0]; + policy.check(mark, flag); + } + } + private checkMarkPolicy( parents: CommandGroup<any, any>[], progname: string, @@ -498,26 +546,32 @@ export namespace clk { console.log(formatListing(argName, arg.args.help)); } } - if (this.options.length != 0) { + // Same rule as for subcommands: a mark decides whether the option + // shows up, not whether it works. + const listedOptions = this.options.filter((opt) => + this.isListed(opt.args), + ); + if (listedOptions.length != 0) { console.log(); console.log("Options:"); - for (const opt of this.options) { + for (const opt of listedOptions) { let optSpec = opt.flagspec.join(", "); if (!opt.isFlag) { optSpec = optSpec + "=VALUE"; } - console.log(formatListing(optSpec, opt.args.help)); + const help = [ + opt.args.mark ? `[${opt.args.mark}]` : undefined, + opt.args.help, + ] + .filter((x) => x) + .join(" "); + console.log(formatListing(optSpec, help ? help : undefined)); } } // A mark only decides whether the command shows up here; it says // nothing about the subcommands underneath it. - const listed = this.subcommands.filter( - (sc) => - sc.args.mark == null || - this.markPolicy == null || - this.markPolicy.isListed(sc.args.mark), - ); + const listed = this.subcommands.filter((sc) => this.isListed(sc.args)); if (listed.length != 0) { console.log(); console.log("Commands:"); @@ -553,6 +607,7 @@ export namespace clk { } const myArgs: any = (parsedArgs[this.argKey] = {}); const foundOptions: { [name: string]: boolean } = {}; + myArgs[FOUND_OPTIONS] = foundOptions; const currentName = this.name ?? progname; const storeOption = (def: OptionDef, value: string) => { foundOptions[def.name] = true; @@ -707,6 +762,11 @@ export namespace clk { // --help has been handled above. Unlike the listing, a mark on // any ancestor applies here. this.checkMarkPolicy(parents, progname); + // Global options live on an ancestor, so the whole path has to be + // asked about its options, not just the command that runs. + for (const cg of [...parents, this]) { + cg.checkOptionMarkPolicy(parsedArgs[cg.argKey]); + } let r; try { r = this.myAction(parsedArgs);