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:
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);