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