commit c1018ebb8a653ad48ddc284424675e8325cfad88
parent 5a98bd310b15b580ee973297ff8c790469c8b48c
Author: Florian Dold <dold@taler.net>
Date: Thu, 6 Aug 2026 16:47:55 +0200
wallet-cli: keep experimental and legacy commands out of the default help
Diffstat:
3 files changed, 199 insertions(+), 6 deletions(-)
diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts
@@ -64,6 +64,7 @@ import {
ContinuationKind,
} from "./continuation.js";
import { formatTxRef, parseTxRef, TX_REF_SYNTAX } from "./txref.js";
+import { CLI_ENABLE_VAR, parseEnabledMarks } from "./marks.js";
import {
CliUsageError,
formatTxState,
@@ -361,9 +362,61 @@ function printVersion(): void {
processExit(0);
}
+function readEnabledMarks(): Set<clk.CommandMark> {
+ try {
+ return parseEnabledMarks(getenv(CLI_ENABLE_VAR));
+ } catch (e) {
+ if (e instanceof CliUsageError) {
+ console.error(`error: ${e.message}`);
+ if (e.hint) {
+ console.error(` ${e.hint}`);
+ }
+ processExit(EXIT_USAGE);
+ }
+ throw e;
+ }
+}
+
+const enabledMarks = readEnabledMarks();
+
+/**
+ * What the command marks mean for this CLI.
+ *
+ * Experimental commands are refused outright, since relying on one is the
+ * mistake worth preventing. Legacy commands still work, so that scripts
+ * keep running, but say that they are on their way out.
+ */
+const walletCliMarkPolicy: clk.CommandMarkPolicy = {
+ isListed(mark) {
+ return enabledMarks.has(mark);
+ },
+ check(mark, commandPath) {
+ if (enabledMarks.has(mark)) {
+ return;
+ }
+ switch (mark) {
+ case "experimental":
+ console.error(`error: '${commandPath}' is experimental`);
+ console.error(` set ${CLI_ENABLE_VAR}=experimental to enable it`);
+ processExit(EXIT_USAGE);
+ break;
+ case "legacy":
+ console.error(
+ `warning: '${commandPath}' is deprecated and may be removed`,
+ );
+ console.error(` set ${CLI_ENABLE_VAR}=legacy to silence this`);
+ break;
+ case "hidden":
+ // Kept out of the help listing, but otherwise an ordinary command.
+ break;
+ }
+ },
+};
+
export const walletCli = clk
.program("wallet", {
help: "Command line interface for the GNU Taler wallet.",
+ markPolicy: walletCliMarkPolicy,
})
.maybeOption("walletDbFile", ["--wallet-db"], clk.STRING, {
help: "Location of the wallet database file",
@@ -1813,6 +1866,7 @@ addContinueTxCommand(transactionsCli, "transactionsContinue", "continue");
walletCli
.subcommand("finishPendingOpt", "run-until-done", {
help: "Run until no more work is left.",
+ mark: "legacy",
})
.action(async (args) => {
await withWallet(args, { lazyTaskLoop: false }, async (ctx) => {
@@ -1823,6 +1877,7 @@ walletCli
walletCli
.subcommand("payPush", "peer-send", {
help: "Initiate a peer-push payment.",
+ mark: "legacy",
})
.requiredArgument("amount", clk.AMOUNT, {
help: "Amount to pay",
@@ -1886,7 +1941,7 @@ const withdrawCli = walletCli.subcommand("withdraw", "withdraw", {
});
withdrawCli
- .subcommand("withdrawCheckUri", "check-uri")
+ .subcommand("withdrawCheckUri", "check-uri", { mark: "legacy" })
.requiredArgument("uri", clk.STRING)
.maybeOption("restrictAge", ["--restrict-age"], clk.INT)
.action(async (args) => {
@@ -1927,7 +1982,7 @@ withdrawCli
});
withdrawCli
- .subcommand("withdrawAcceptUri", "accept-uri")
+ .subcommand("withdrawAcceptUri", "accept-uri", { mark: "legacy" })
.requiredArgument("uri", clk.STRING)
.requiredOption("exchange", ["--exchange"], clk.STRING)
.maybeOption("restrictAge", ["--restrict-age"], clk.INT)
@@ -2464,6 +2519,7 @@ exchangesCli
const backupCli = walletCli.subcommand("backupArgs", "backup", {
help: "Subcommands for backups",
+ mark: "experimental",
});
backupCli.subcommand("exportDb", "export-db").action(async (args) => {
@@ -2603,6 +2659,7 @@ depositCli
const peerCli = walletCli.subcommand("peerArgs", "p2p", {
help: "Subcommands for peer-to-peer payments.",
+ mark: "legacy",
});
peerCli
@@ -2836,6 +2893,7 @@ const advancedCli = walletCli.subcommand("advancedArgs", "advanced", {
advancedCli
.subcommand("convertDb", "convert-db", {
help: "Convert a wallet database between storage backends.",
+ mark: "experimental",
})
.requiredArgument("source", clk.STRING, {
help: "Existing wallet database file to read.",
@@ -2908,6 +2966,7 @@ advancedCli
advancedCli
.subcommand("genReserve", "gen-reserve", {
help: "Generate a reserve key pair (not stored in the DB).",
+ mark: "hidden",
})
.action(async (args) => {
const pair = await nativeCrypto.createEddsaKeypair({});
@@ -2946,6 +3005,7 @@ advancedCli
advancedCli
.subcommand("sampleTransactions", "sample-transactions", {
help: "Print sample wallet-core transactions",
+ mark: "hidden",
})
.action(async (args) => {
console.log(
@@ -3047,6 +3107,7 @@ advancedCli
advancedCli
.subcommand("runPendingOpt", "run-pending", {
help: "Run pending operations.",
+ mark: "legacy",
})
.action(async (args) => {
logger.error(
@@ -3055,7 +3116,10 @@ advancedCli
});
advancedCli
- .subcommand("pending", "pending", { help: "Show pending operations." })
+ .subcommand("pending", "pending", {
+ help: "Show pending operations.",
+ mark: "legacy",
+ })
.action(async (args) => {
console.error("Subcommand removed due to deprecation.");
process.exit(1);
@@ -3064,6 +3128,7 @@ advancedCli
advancedCli
.subcommand("benchInternal", "bench-internal", {
help: "Run the 'bench-internal' benchmark",
+ mark: "experimental",
})
.action(async (args) => {
const myHttpLib = createPlatformHttpLib();
@@ -3086,7 +3151,7 @@ advancedCli
});
advancedCli
- .subcommand("genSegwit", "gen-segwit")
+ .subcommand("genSegwit", "gen-segwit", { mark: "hidden" })
.requiredArgument("paytoUri", clk.STRING)
.requiredArgument("reservePub", clk.STRING)
.action(async (args) => {
@@ -3096,6 +3161,7 @@ advancedCli
const currenciesCli = walletCli.subcommand("currencies", "currencies", {
help: "Manage currencies.",
+ mark: "experimental",
});
currenciesCli
@@ -3223,6 +3289,7 @@ advancedCli
advancedCli
.subcommand("recycle", "recycle", {
help: "Export, clear and re-import the database via the backup mechanism.",
+ mark: "experimental",
})
.action(async (args) => {
await withWallet(args, { lazyTaskLoop: true }, async (wallet) => {
@@ -3318,6 +3385,7 @@ advancedCli
advancedCli
.subcommand("refresh", "force-refresh", {
help: "Force a refresh on a coin.",
+ mark: "hidden",
})
.requiredArgument("coinPub", clk.STRING)
.action(async (args) => {
@@ -3335,6 +3403,7 @@ advancedCli
advancedCli
.subcommand("dumpCoins", "dump-coins", {
help: "Dump coins in an easy-to-process format.",
+ mark: "hidden",
})
.action(async (args) => {
await withWallet(args, { lazyTaskLoop: true }, async (wallet) => {
@@ -3351,6 +3420,7 @@ const coinPubListCodec = codecForList(codecForString());
advancedCli
.subcommand("suspendCoins", "suspend-coins", {
help: "Mark a coin as suspended, will not be used for payments.",
+ mark: "hidden",
})
.requiredArgument("coinPubSpec", clk.STRING)
.action(async (args) => {
@@ -3376,6 +3446,7 @@ advancedCli
advancedCli
.subcommand("unsuspendCoins", "unsuspend-coins", {
help: "Mark a coin as suspended, will not be used for payments.",
+ mark: "hidden",
})
.requiredArgument("coinPubSpec", clk.STRING)
.action(async (args) => {
@@ -3399,7 +3470,9 @@ advancedCli
});
advancedCli
- .subcommand("hashContractTerms", "hash-contract-terms")
+ .subcommand("hashContractTerms", "hash-contract-terms", {
+ mark: "hidden",
+ })
.action(async (args) => {
const data = await read(process.stdin);
const ct = JSON.parse(data);
@@ -3410,6 +3483,7 @@ advancedCli
advancedCli
.subcommand("coins", "list-coins", {
help: "List coins.",
+ mark: "hidden",
})
.action(async (args) => {
await withWallet(args, { lazyTaskLoop: true }, async (wallet) => {
@@ -3441,6 +3515,7 @@ advancedCli
const testCli = walletCli.subcommand("testingArgs", "testing", {
help: "Subcommands for testing.",
+ mark: "hidden",
});
testCli
@@ -3540,7 +3615,9 @@ class PerfTimer {
}
testCli
- .subcommand("benchmarkAgeRestrictions", "benchmark-age-restrictions")
+ .subcommand("benchmarkAgeRestrictions", "benchmark-age-restrictions", {
+ mark: "experimental",
+ })
.requiredOption("reps", ["--reps"], clk.INT, {
default: 100,
help: "repetitions (default: 100)",
diff --git a/packages/taler-wallet-cli/src/marks.test.ts b/packages/taler-wallet-cli/src/marks.test.ts
@@ -0,0 +1,51 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ 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 <http://www.gnu.org/licenses/>
+ */
+import assert from "node:assert";
+import { test } from "node:test";
+import { parseEnabledMarks } from "./marks.js";
+import { CliUsageError } from "./waitspec.js";
+
+test("nothing is enabled by default", (t) => {
+ assert.strictEqual(parseEnabledMarks(undefined).size, 0);
+ assert.strictEqual(parseEnabledMarks("").size, 0);
+ assert.strictEqual(parseEnabledMarks(" ").size, 0);
+});
+
+test("a single category", (t) => {
+ assert.deepStrictEqual([...parseEnabledMarks("legacy")], ["legacy"]);
+});
+
+test("several categories, in any spelling of the separator", (t) => {
+ const enabled = parseEnabledMarks(" legacy , experimental,hidden ");
+ assert.strictEqual(enabled.size, 3);
+ assert.ok(enabled.has("legacy"));
+ assert.ok(enabled.has("experimental"));
+ assert.ok(enabled.has("hidden"));
+});
+
+test("repeating a category is harmless", (t) => {
+ assert.deepStrictEqual([...parseEnabledMarks("legacy,legacy")], ["legacy"]);
+});
+
+test("empty entries are skipped", (t) => {
+ assert.deepStrictEqual([...parseEnabledMarks(",legacy,,")], ["legacy"]);
+});
+
+test("an unknown category is rejected", (t) => {
+ // A typo would otherwise be indistinguishable from an enabled category.
+ assert.throws(() => parseEnabledMarks("experimenatl"), CliUsageError);
+ assert.throws(() => parseEnabledMarks("legacy,bogus"), CliUsageError);
+});
diff --git a/packages/taler-wallet-cli/src/marks.ts b/packages/taler-wallet-cli/src/marks.ts
@@ -0,0 +1,65 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ 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 <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * @file
+ * The command categories that are not offered by default, and how the
+ * environment switches them back on.
+ */
+
+/**
+ * Imports.
+ */
+import { clk } from "@gnu-taler/taler-util/clk";
+import { CliUsageError } from "./waitspec.js";
+
+/**
+ * Environment variable that brings back the commands which are not
+ * offered by default, as a comma-separated list of categories.
+ */
+export const CLI_ENABLE_VAR = "TALER_WALLET_CLI_ENABLE";
+
+export const COMMAND_MARKS: clk.CommandMark[] = [
+ "experimental",
+ "legacy",
+ "hidden",
+];
+
+/**
+ * Parse the categories enabled through the environment.
+ *
+ * An unknown category is rejected rather than ignored: a typo would
+ * otherwise look exactly like a category that is switched on.
+ */
+export function parseEnabledMarks(
+ spec: string | undefined,
+): Set<clk.CommandMark> {
+ const enabled = new Set<clk.CommandMark>();
+ for (const part of (spec ?? "").split(",")) {
+ const name = part.trim();
+ if (name === "") {
+ continue;
+ }
+ if (!COMMAND_MARKS.includes(name as clk.CommandMark)) {
+ throw new CliUsageError(
+ `unknown category '${name}' in ${CLI_ENABLE_VAR}`,
+ `expected ${COMMAND_MARKS.join(", ")}`,
+ );
+ }
+ enabled.add(name as clk.CommandMark);
+ }
+ return enabled;
+}