taler-typescript-core

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

commit 5a98bd310b15b580ee973297ff8c790469c8b48c
parent a41d1c3114515b50c7a41ab18c8c70b24666b551
Author: Florian Dold <dold@taler.net>
Date:   Thu,  6 Aug 2026 16:47:26 +0200

util: allow marking commands experimental, legacy or hidden

Diffstat:
Mpackages/taler-util/src/clk.test.ts | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/clk.ts | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
2 files changed, 261 insertions(+), 9 deletions(-)

diff --git a/packages/taler-util/src/clk.test.ts b/packages/taler-util/src/clk.test.ts @@ -111,3 +111,138 @@ test("CLK-4: a subcommand may not reuse an ancestor's argument key", (t) => { sub.action(() => {}); assert.throws(() => prog.run(["prog", "sub"])); }); + +// Like captureRun, but keeps the help output so it can be asserted on. +function captureHelp(fn: () => void): string { + const origExit = process.exit; + const origLog = console.log; + const origErr = console.error; + const lines: string[] = []; + console.log = (...a: unknown[]) => { + lines.push(a.map(String).join(" ")); + }; + console.error = () => {}; + process.exit = ((code?: number): never => { + throw new Error(`clk exited with ${code ?? 0}`); + }) as typeof process.exit; + try { + fn(); + } catch { + // The help path always exits. + } finally { + process.exit = origExit; + console.log = origLog; + console.error = origErr; + } + return lines.join("\n"); +} + +/** + * A policy that lists only the marks given, and records what it was asked + * about. Refuses "experimental" the way the wallet CLI does. + */ +function testPolicy(listed: clk.CommandMark[]): clk.CommandMarkPolicy & { + checked: [clk.CommandMark, string][]; +} { + const checked: [clk.CommandMark, string][] = []; + return { + checked, + isListed: (mark) => listed.includes(mark), + check: (mark, path) => { + checked.push([mark, path]); + if (mark === "experimental" && !listed.includes(mark)) { + throw new Error(`refused ${path}`); + } + }, + }; +} + +test("CLK-5: marked commands are listed only when the policy says so", (t) => { + const policy = testPolicy([]); + const prog = clk.program("m1", { help: "H", markPolicy: policy }); + prog.subcommand("plain", "plain", { help: "a plain one" }).action(() => {}); + prog + .subcommand("old", "old", { help: "an old one", mark: "legacy" }) + .action(() => {}); + prog + .subcommand("secret", "secret", { help: "a secret one", mark: "hidden" }) + .action(() => {}); + const out = captureHelp(() => prog.run(["prog", "--help"])); + assert.ok(out.includes("plain")); + assert.ok(!out.includes("old")); + assert.ok(!out.includes("secret")); +}); + +test("CLK-6: an enabled mark is listed and annotated", (t) => { + const policy = testPolicy(["legacy"]); + const prog = clk.program("m2", { help: "H", markPolicy: policy }); + prog + .subcommand("old", "old", { help: "an old one", mark: "legacy" }) + .action(() => {}); + const out = captureHelp(() => prog.run(["prog", "--help"])); + assert.ok(out.includes("old")); + assert.ok(out.includes("[legacy]")); +}); + +test("CLK-7: a mark on a group applies to the commands under it", (t) => { + const policy = testPolicy(["experimental"]); + const prog = clk.program("m3", { markPolicy: policy }); + const grp = prog.subcommand("grp", "grp", { mark: "experimental" }); + grp.subcommand("leaf", "leaf").action(() => {}); + captureHelp(() => prog.run(["prog", "grp", "leaf"])); + assert.deepStrictEqual(policy.checked, [["experimental", "grp leaf"]]); +}); + +test("CLK-8: the strictest mark on the path is the one reported", (t) => { + const policy = testPolicy(["experimental", "legacy", "hidden"]); + const prog = clk.program("m4", { markPolicy: policy }); + const grp = prog.subcommand("grp", "grp", { mark: "hidden" }); + grp.subcommand("leaf", "leaf", { mark: "experimental" }).action(() => {}); + captureHelp(() => prog.run(["prog", "grp", "leaf"])); + assert.deepStrictEqual(policy.checked, [["experimental", "grp leaf"]]); +}); + +test("CLK-9: an unmarked command is never checked", (t) => { + const policy = testPolicy([]); + const prog = clk.program("m5", { markPolicy: policy }); + prog.subcommand("plain", "plain").action(() => {}); + captureHelp(() => prog.run(["prog", "plain"])); + assert.deepStrictEqual(policy.checked, []); +}); + +test("CLK-10: --help is not gated", (t) => { + const policy = testPolicy([]); + const prog = clk.program("m6", { markPolicy: policy }); + const grp = prog.subcommand("grp", "grp", { + help: "gated group", + mark: "experimental", + }); + grp.subcommand("leaf", "leaf", { help: "a leaf" }).action(() => {}); + const out = captureHelp(() => prog.run(["prog", "grp", "leaf", "--help"])); + assert.deepStrictEqual(policy.checked, []); + assert.ok(out.includes("a leaf")); +}); + +test("CLK-11: a marked command shows its mark in its own help", (t) => { + const policy = testPolicy(["legacy"]); + const prog = clk.program("m7", { markPolicy: policy }); + prog + .subcommand("old", "old", { help: "an old one", mark: "legacy" }) + .action(() => {}); + const out = captureHelp(() => prog.run(["prog", "old", "--help"])); + assert.ok(out.includes("[legacy] an old one")); +}); + +test("CLK-12: without a policy, marks change nothing", (t) => { + let ran = false; + const prog = clk.program("m8", { help: "H" }); + prog + .subcommand("old", "old", { help: "an old one", mark: "experimental" }) + .action(() => { + ran = true; + }); + const out = captureHelp(() => prog.run(["prog", "--help"])); + assert.ok(out.includes("old")); + captureHelp(() => prog.run(["prog", "old"])); + assert.ok(ran); +}); diff --git a/packages/taler-util/src/clk.ts b/packages/taler-util/src/clk.ts @@ -44,8 +44,37 @@ export namespace clk { default?: T; } + /** + * Status of a command, for programs that don't want to offer all of + * their commands 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. + */ + export type CommandMark = "experimental" | "legacy" | "hidden"; + + /** + * Marks in the order of how much they restrict a command, so that the + * strictest one along a command's path is the one that applies. + */ + const MARK_STRENGTH: CommandMark[] = ["hidden", "legacy", "experimental"]; + + export interface CommandMarkPolicy { + /** + * Is a command carrying this mark listed in its parent's 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. + */ + check(mark: CommandMark, commandPath: string): void; + } + export interface SubcommandArgs { help?: string; + mark?: CommandMark; } export interface FlagArgs { @@ -54,6 +83,11 @@ export namespace clk { export interface ProgramArgs { help?: string; + /** + * How to treat marked commands. Without one, marks are inert and + * every command is listed and runnable. + */ + markPolicy?: CommandMarkPolicy; } interface ArgumentDef { @@ -139,8 +173,20 @@ export namespace clk { private argKey: string, private name: string | null, private scArgs: SubcommandArgs, + /** + * Inherited from the program, so that every command can consult it + * without walking back up the tree. + */ + private markPolicy?: CommandMarkPolicy, ) {} + /** + * The mark of this command, if it has one. + */ + getMark(): CommandMark | undefined { + return this.scArgs.mark; + } + action(f: ActionFn<TG>): void { if (this.myAction) { throw Error("only one action supported per command"); @@ -268,7 +314,13 @@ export namespace clk { name: string, args: SubcommandArgs = {}, ): CommandGroup<GN, TG> { - const cg = new CommandGroup<GN, {}>(argKey as string, name, args); + // The policy comes from the program, so it has to be handed down. + const cg = new CommandGroup<GN, {}>( + argKey as string, + name, + args, + this.markPolicy, + ); const def: SubcommandDef = { commandGroup: cg, name: name as string, @@ -293,6 +345,45 @@ export namespace clk { return cg as any; } + /** + * Ask the policy about the strictest mark on the way to this command. + * + * A group's mark covers everything below it, so that marking a group + * is enough and its subcommands don't have to repeat it. + */ + private checkMarkPolicy( + parents: CommandGroup<any, any>[], + progname: string, + ): void { + const policy = this.markPolicy; + if (policy == null) { + return; + } + let strictest: CommandMark | undefined; + for (const cg of [...parents, this]) { + const mark = cg.scArgs.mark; + if (mark == null) { + continue; + } + if ( + strictest == null || + MARK_STRENGTH.indexOf(mark) > MARK_STRENGTH.indexOf(strictest) + ) { + strictest = mark; + } + } + if (strictest == null) { + return; + } + // Name the command the way the user typed it, minus the program + // name, which the root carries as a null name. + const commandPath = [...parents, this] + .map((cg) => cg.name) + .filter((n) => n != null) + .join(" "); + policy.check(strictest, commandPath === "" ? progname : commandPath); + } + printHelp(progName: string, parents: CommandGroup<any, any>[]): void { let usageSpec = ""; for (const p of parents) { @@ -311,9 +402,14 @@ export namespace clk { } usageSpec = usageSpec.trimRight(); console.log(`Usage: ${usageSpec}`); - if (this.scArgs.help) { + const ownMark = this.scArgs.mark; + if (this.scArgs.help || ownMark) { console.log(); - console.log(this.scArgs.help); + console.log( + [ownMark ? `[${ownMark}]` : undefined, this.scArgs.help] + .filter((x) => x) + .join(" "), + ); } // Only when something has been documented: without help texts this // would just repeat the usage line. @@ -337,11 +433,25 @@ export namespace clk { } } - if (this.subcommands.length != 0) { + // 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), + ); + if (listed.length != 0) { console.log(); console.log("Commands:"); - for (const subcmd of this.subcommands) { - console.log(formatListing(subcmd.name, subcmd.args.help)); + for (const subcmd of listed) { + const help = [ + subcmd.args.mark ? `[${subcmd.args.mark}]` : undefined, + subcmd.args.help, + ] + .filter((x) => x) + .join(" "); + console.log(formatListing(subcmd.name, help)); } } } @@ -516,6 +626,10 @@ export namespace clk { parsedArgs, ); } else if (this.myAction) { + // Only now that something is actually going to run, and after + // --help has been handled above. Unlike the listing, a mark on + // any ancestor applies here. + this.checkMarkPolicy(parents, progname); let r; try { r = this.myAction(parsedArgs); @@ -541,9 +655,12 @@ export namespace clk { private mainCommand: CommandGroup<any, any>; constructor(argKey: string, args: ProgramArgs = {}) { - this.mainCommand = new CommandGroup<any, any>(argKey, null, { - help: args.help, - }); + this.mainCommand = new CommandGroup<any, any>( + argKey, + null, + { help: args.help }, + args.markPolicy, + ); this.mainCommand.flag("help", ["-h", "--help"], { help: "Show this message and exit.", });