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:
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.",
});