taler-typescript-core

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

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:
Mpackages/taler-wallet-cli/src/index.ts | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Apackages/taler-wallet-cli/src/marks.test.ts | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-cli/src/marks.ts | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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; +}