taler-typescript-core

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

commit 107f89219b113ee55371d7b5c9388d837c51a95e
parent 8db12300f2970ff63906f4ea9cbde712359ff6b5
Author: Florian Dold <dold@taler.net>
Date:   Mon, 10 Aug 2026 00:26:54 +0200

wallet-cli: refine transaction command help

Diffstat:
Mpackages/taler-util/src/clk.test.ts | 18++++++++++++++++++
Mpackages/taler-util/src/clk.ts | 30+++++++++++++++++++++++++++++-
Mpackages/taler-wallet-cli/src/index.ts | 80++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mpackages/taler-wallet-cli/src/transactions-pretty.test.ts | 37+++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-cli/src/transactions-pretty.ts | 42+++++++++++++++++++++++++++++++++++-------
5 files changed, 168 insertions(+), 39 deletions(-)

diff --git a/packages/taler-util/src/clk.test.ts b/packages/taler-util/src/clk.test.ts @@ -105,6 +105,24 @@ test("CLK-3: a group can have both positional arguments and subcommands", (t) => assert.strictEqual(captured?.p3?.region, "myregion"); }); +test("CLK-3a: aliases run the canonical command without appearing in help", () => { + const prog = clk.program("alias"); + let ran = false; + prog + .subcommand("transactions", "transactions", { + help: "Manage transactions.", + }) + .alias("tx") + .action(() => { + ran = true; + }); + captureRun(() => prog.run(["prog", "tx"])); + assert.ok(ran); + const out = captureHelp(() => prog.run(["prog", "--help"])); + assert.ok(out.includes("transactions")); + assert.ok(!out.includes("\n tx")); +}); + test("CLK-4: a subcommand may not reuse an ancestor's argument key", (t) => { const prog = clk.program("dup"); const sub = prog.subcommand("dup", "sub"); diff --git a/packages/taler-util/src/clk.ts b/packages/taler-util/src/clk.ts @@ -189,7 +189,13 @@ export namespace clk { const ownLine = res.length >= 25; const indent = ownLine ? 4 : 25; const pad = " ".repeat(indent); - const body = wrapText(value, helpWidth(), indent).join("\n" + pad); + // Newlines in command help deliberately create a new help line. This is + // useful for a parent command that needs to expose its most important + // subcommands without turning the whole listing into a paragraph. + const body = value + .split("\n") + .flatMap((line) => wrapText(line, helpWidth(), indent)) + .join("\n" + pad); if (ownLine) { return res + "\n" + pad + body; } @@ -264,6 +270,7 @@ export namespace clk { * without walking back up the tree. */ private markPolicy?: CommandMarkPolicy, + private parent?: CommandGroup<any, any>, ) {} /** @@ -406,6 +413,7 @@ export namespace clk { name, args, this.markPolicy, + this, ); const def: SubcommandDef = { commandGroup: cg, @@ -432,6 +440,26 @@ export namespace clk { } /** + * Make this command available under another name without listing that + * name in the parent's help. The alias intentionally shares all options + * and nested subcommands with the canonical command. + */ + alias(name: string): this { + if (this.parent == null || this.name == null) { + throw Error("only a subcommand can have an alias"); + } + if (this.parent.subcommandMap[name] != null) { + throw Error(`command alias '${name}' is already in use`); + } + const def = this.parent.subcommandMap[this.name]; + if (def == null) { + throw Error(`command '${this.name}' is not registered with its parent`); + } + this.parent.subcommandMap[name] = def; + return this; + } + + /** * 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 diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts @@ -882,7 +882,12 @@ const TX_STATE_FILTERS = [ ] as const; const transactionsCli = walletCli - .subcommand("transactions", "transactions", { help: "Manage transactions." }) + .subcommand("transactions", "transactions", { + help: [ + "Manage transactions; subcommands:", + "continue, show, retry, abort, suspend, resume, fail, delete, wait", + ].join("\n"), + }) .maybeOption("currency", ["--currency"], clk.STRING, { help: "Filter by currency.", }) @@ -901,10 +906,12 @@ const transactionsCli = walletCli .flag("verbose", ["-v", "--verbose"], { help: "Include transaction IDs, times and diagnostic details in pretty output.", }) - .flag("online", ["--online"], { - help: "Run the wallet task loop while listing transactions.", + .flag("oneline", ["--oneline"], { + help: "Print one compact line per transaction in pretty output.", }); +transactionsCli.alias("tx"); + // Default action transactionsCli.action(async (args) => { await runCliAction(async () => { @@ -921,7 +928,7 @@ transactionsCli.action(async (args) => { await withWallet( args, { - lazyTaskLoop: !args.transactions.online, + lazyTaskLoop: true, }, async (wallet) => { const pending = await wallet.client.call( @@ -955,6 +962,7 @@ transactionsCli.action(async (args) => { tx, await continuationForTx(wallet, tx), verbose, + args.transactions.oneline, ), ), ); @@ -1052,33 +1060,43 @@ transactionsCli ); }); -transactionsCli - .subcommand("lookup", "lookup", { - help: "Look up a single transaction based on the transaction identifier.", - }) - .requiredArgument("transactionId", clk.STRING, { - metavar: "TRANSACTION_ID", - help: `Transaction to look up. ${TX_REF_SYNTAX}`, - }) - .flag("includeContractTerms", ["--include-contract-terms"]) - .action(async (args) => { - await runCliAction(() => - withWallet(args, { lazyTaskLoop: true }, async (wallet) => { - const transactionId = await resolveTxRef( - wallet, - args.lookup.transactionId, - ); - const tx = await wallet.client.call( - WalletApiOperation.GetTransactionById, - { - transactionId, - includeContractTerms: args.lookup.includeContractTerms ?? false, - }, - ); - console.log(j2s(tx)); - }), - ); - }); +function addShowTransactionCommand( + argKey: string, + name: string, + mark?: clk.CommandMark, +): void { + transactionsCli + .subcommand(argKey, name, { + help: "Show a single transaction based on its identifier.", + mark, + }) + .requiredArgument("transactionId", clk.STRING, { + metavar: "TRANSACTION_ID", + help: `Transaction to show. ${TX_REF_SYNTAX}`, + }) + .flag("includeContractTerms", ["--include-contract-terms"]) + .action(async (args) => { + await runCliAction(() => + withWallet(args, { lazyTaskLoop: true }, async (wallet) => { + const transactionId = await resolveTxRef( + wallet, + args[argKey].transactionId, + ); + const tx = await wallet.client.call( + WalletApiOperation.GetTransactionById, + { + transactionId, + includeContractTerms: args[argKey].includeContractTerms ?? false, + }, + ); + console.log(j2s(tx)); + }), + ); + }); +} + +addShowTransactionCommand("show", "show"); +addShowTransactionCommand("lookup", "lookup", "legacy"); transactionsCli .subcommand("abortTransaction", "abort", { diff --git a/packages/taler-wallet-cli/src/transactions-pretty.test.ts b/packages/taler-wallet-cli/src/transactions-pretty.test.ts @@ -84,3 +84,40 @@ test("pretty output only adds diagnostic details with --verbose", () => { assert.ok(lines.includes(" Balance change: KUDOS:5.01")); assert.ok(lines.includes(" Internal state ID: 42")); }); + +test("bank confirmation links do not make the next-step line unwieldy", () => { + const lines = formatPrettyTransaction(makeTx(), { + kind: ContinuationKind.ConfirmWithBank, + actionable: true, + automatic: false, + summary: + "confirm the withdrawal with your bank: https://bank.example/confirm/a-very-long-token", + details: { + bankConfirmationUrl: "https://bank.example/confirm/a-very-long-token", + }, + }); + + assert.ok(lines.includes(" Next: confirm the withdrawal with your bank")); + assert.ok( + lines.includes(" https://bank.example/confirm/a-very-long-token"), + ); + assert.ok(!lines.some((x) => x.startsWith(" bankConfirmationUrl:"))); +}); + +test("the oneline view stays compact", () => { + const lines = formatPrettyTransaction( + makeTx({ localTransactionId: "#payment:7" }), + { + kind: ContinuationKind.ConfirmPayment, + actionable: true, + automatic: true, + summary: "the merchant's offer is waiting to be accepted", + }, + false, + true, + ); + + assert.deepStrictEqual(lines, [ + "#payment:7 payment KUDOS:5.01 [dialog:proposed] — Lunch — The Café — Next: the merchant's offer is waiting to be accepted", + ]); +}); diff --git a/packages/taler-wallet-cli/src/transactions-pretty.ts b/packages/taler-wallet-cli/src/transactions-pretty.ts @@ -104,26 +104,54 @@ export function formatPrettyTransaction( tx: Transaction, continuation: Continuation, verbose = false, + oneline = false, ): string[] { - const lines = [ - `${tx.localTransactionId != null ? `${tx.localTransactionId} ` : ""}${ - tx.type - } ${tx.amountEffective} [${formatTxState(tx.txState)}]`, - ]; + const headline = `${tx.localTransactionId != null ? `${tx.localTransactionId} ` : ""}${ + tx.type + } ${tx.amountEffective} [${formatTxState(tx.txState)}]`; const description = descriptionOf(tx); + if (oneline) { + const parts = [headline]; + if (description != null) { + parts.push(description); + } + if (continuation.actionable) { + // Links and transfer details are deliberately kept out of this compact + // form; the normal pretty view presents them on separate actionable + // lines. + parts.push(`Next: ${continuation.summary.split(": ")[0]}`); + } + if (tx.error != null) { + parts.push(`Error: ${summarizeTalerErrorDetail(tx.error)}`); + } + return [parts.join(" — ")]; + } + + const lines = [headline]; if (description != null) { lines.push(` ${description}`); } if (continuation.actionable) { - lines.push(` Next: ${continuation.summary}`); + const bankConfirmationUrl = continuation.details?.bankConfirmationUrl; + const summaryWithoutBankConfirmationUrl = + typeof bankConfirmationUrl === "string" && + continuation.summary.endsWith(`: ${bankConfirmationUrl}`) + ? continuation.summary.slice(0, -`: ${bankConfirmationUrl}`.length) + : undefined; + lines.push( + ` Next: ${summaryWithoutBankConfirmationUrl ?? continuation.summary}`, + ); + if (summaryWithoutBankConfirmationUrl != null) { + lines.push(` ${bankConfirmationUrl}`); + } if (continuation.requiresTos) { lines.push( ` Before continuing: accept the terms of service of ${continuation.tosExchangeBaseUrl}.`, ); } for (const [key, value] of Object.entries(continuation.details ?? {})) { - if (value != null) { + if (value != null && key !== "bankConfirmationUrl") { lines.push(` ${printDetail(key, value)}`); } }