commit e19b9e818728336ae39783536ec1e29f5f349b08
parent 16becb0e5daa1b87bf5f0fdf7c87156a61022427
Author: Florian Dold <dold@taler.net>
Date: Mon, 10 Aug 2026 00:50:27 +0200
wallet-cli: pretty-print exchanges
Diffstat:
3 files changed, 236 insertions(+), 13 deletions(-)
diff --git a/packages/taler-wallet-cli/src/exchanges-pretty.test.ts b/packages/taler-wallet-cli/src/exchanges-pretty.test.ts
@@ -0,0 +1,101 @@
+/*
+ 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 {
+ ExchangeEntryStatus,
+ ExchangeListItem,
+ ExchangeTosStatus,
+ ExchangeUpdateStatus,
+ ExchangeWalletKycStatus,
+ ScopeType,
+} from "@gnu-taler/taler-util";
+import assert from "node:assert";
+import { test } from "node:test";
+import { formatPrettyExchange } from "./exchanges-pretty.js";
+
+function makeExchange(extra: Record<string, unknown> = {}): ExchangeListItem {
+ return {
+ exchangeBaseUrl: "https://exchange.example/",
+ currency: "KUDOS",
+ masterPub: "MASTER_PUB",
+ paytoUris: [],
+ tosStatus: ExchangeTosStatus.Accepted,
+ exchangeEntryStatus: ExchangeEntryStatus.Used,
+ exchangeUpdateStatus: ExchangeUpdateStatus.Ready,
+ ageRestrictionOptions: [],
+ peerPaymentsDisabled: false,
+ directDepositsDisabled: false,
+ noFees: false,
+ scopeInfo: {
+ type: ScopeType.Exchange,
+ currency: "KUDOS",
+ url: "https://exchange.example/",
+ },
+ lastUpdateTimestamp: undefined,
+ currencySpec: {} as ExchangeListItem["currencySpec"],
+ ...extra,
+ } as ExchangeListItem;
+}
+
+test("pretty exchange output is compact", () => {
+ assert.deepStrictEqual(formatPrettyExchange(makeExchange()), [
+ "KUDOS https://exchange.example/ [ready]",
+ ]);
+});
+
+test("pretty exchange output highlights actions that need attention", () => {
+ const lines = formatPrettyExchange(
+ makeExchange({
+ tosStatus: ExchangeTosStatus.Proposed,
+ unconfirmedKeyChange: {},
+ walletKycStatus: ExchangeWalletKycStatus.Legi,
+ }),
+ );
+
+ assert.ok(lines.includes(" ToS: proposed"));
+ assert.ok(lines.includes(" Key change: confirmation required"));
+ assert.ok(lines.includes(" KYC: legi"));
+});
+
+test("the one-line exchange view includes action notices", () => {
+ assert.deepStrictEqual(
+ formatPrettyExchange(
+ makeExchange({ tosStatus: ExchangeTosStatus.Pending }),
+ false,
+ true,
+ ),
+ ["KUDOS https://exchange.example/ [ready] — ToS: pending"],
+ );
+});
+
+test("verbose exchange output includes diagnostic details", () => {
+ const lines = formatPrettyExchange(
+ makeExchange({
+ noFees: true,
+ peerPaymentsDisabled: true,
+ directDepositsDisabled: true,
+ walletKycUrl: "https://kyc.example/",
+ }),
+ true,
+ );
+
+ assert.ok(lines.includes(" Entry status: used"));
+ assert.ok(lines.includes(" Master public key: MASTER_PUB"));
+ assert.ok(lines.includes(" Fees: none"));
+ assert.ok(lines.includes(" Peer payments: disabled"));
+ assert.ok(lines.includes(" Direct deposits: disabled"));
+ assert.ok(lines.includes(" KYC URL: https://kyc.example/"));
+});
diff --git a/packages/taler-wallet-cli/src/exchanges-pretty.ts b/packages/taler-wallet-cli/src/exchanges-pretty.ts
@@ -0,0 +1,83 @@
+/*
+ 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
+ * Compact, useful rendering of known exchanges.
+ */
+
+import {
+ ExchangeListItem,
+ ExchangeTosStatus,
+ ExchangeWalletKycStatus,
+ summarizeTalerErrorDetail,
+} from "@gnu-taler/taler-util";
+
+/** Render one known exchange for a person reading the CLI output. */
+export function formatPrettyExchange(
+ exchange: ExchangeListItem,
+ verbose = false,
+ oneline = false,
+): string[] {
+ const headline = `${exchange.currency} ${exchange.exchangeBaseUrl} [${exchange.exchangeUpdateStatus}]`;
+ const notices: string[] = [];
+ if (exchange.tosStatus !== ExchangeTosStatus.Accepted) {
+ notices.push(`ToS: ${exchange.tosStatus}`);
+ }
+ if (exchange.unconfirmedKeyChange != null) {
+ notices.push("Key change: confirmation required");
+ }
+ if (
+ exchange.walletKycStatus != null &&
+ exchange.walletKycStatus !== ExchangeWalletKycStatus.Done
+ ) {
+ notices.push(`KYC: ${exchange.walletKycStatus}`);
+ }
+
+ if (oneline) {
+ return [[headline, ...notices].join(" — ")];
+ }
+
+ const lines = [headline, ...notices.map((notice) => ` ${notice}`)];
+ if (!verbose) return lines;
+
+ lines.push(` Entry status: ${exchange.exchangeEntryStatus}`);
+ lines.push(` ToS status: ${exchange.tosStatus}`);
+ if (exchange.masterPub != null) {
+ lines.push(` Master public key: ${exchange.masterPub}`);
+ }
+ if (exchange.ageRestrictionOptions.length > 0) {
+ lines.push(
+ ` Supported age restrictions: ${exchange.ageRestrictionOptions.join(", ")}`,
+ );
+ }
+ if (exchange.noFees) lines.push(" Fees: none");
+ if (exchange.peerPaymentsDisabled) lines.push(" Peer payments: disabled");
+ if (exchange.directDepositsDisabled) {
+ lines.push(" Direct deposits: disabled");
+ }
+ if (exchange.walletKycUrl != null) {
+ lines.push(` KYC URL: ${exchange.walletKycUrl}`);
+ }
+ if (exchange.lastUpdateErrorInfo != null) {
+ lines.push(
+ ` Last update error: ${summarizeTalerErrorDetail(
+ exchange.lastUpdateErrorInfo.error,
+ )}`,
+ );
+ }
+ return lines;
+}
diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts
@@ -106,6 +106,7 @@ import {
import { formatPrettyTransaction } from "./transactions-pretty.js";
import { formatPrettyBalance } from "./balance-pretty.js";
import { formatPrettyBankAccount } from "./bank-accounts-pretty.js";
+import { formatPrettyExchange } from "./exchanges-pretty.js";
import * as fs from "node:fs";
@@ -2675,24 +2676,62 @@ withdrawCli
);
});
-const exchangesCli = walletCli.subcommand("exchangesCmd", "exchanges", {
- help: "Manage exchanges.",
-});
+const exchangesCli = walletCli
+ .subcommand("exchangesCmd", "exchanges", {
+ help: [
+ "Manage exchanges; subcommands:",
+ "list, trusted, update, show, add, delete, accept-tos, tos",
+ ].join("\n"),
+ })
+ .flag("json", ["--json"], {
+ help: "Print JSON, even when stdout is a terminal.",
+ })
+ .flag("pretty", ["--pretty"], {
+ help: "Print a human-readable exchange list, even when stdout is not a terminal.",
+ })
+ .flag("verbose", ["-v", "--verbose"], {
+ help: "Include diagnostic details in pretty output.",
+ })
+ .flag("oneline", ["--oneline"], {
+ help: "Print one compact line per exchange in pretty output.",
+ });
+
+async function listKnownExchanges(args: any): Promise<void> {
+ await withWallet(args, { lazyTaskLoop: true }, async (wallet) => {
+ const exchanges = await wallet.client.call(
+ WalletApiOperation.ListExchanges,
+ {},
+ );
+ // A terminal gets a useful overview, while pipes retain the stable
+ // machine-readable form. Either flag explicitly selects its format;
+ // --json wins if a caller accidentally passes both.
+ const pretty =
+ !args.exchangesCmd.json &&
+ (args.exchangesCmd.pretty || process.stdout.isTTY === true);
+ if (!pretty) {
+ console.log(JSON.stringify(exchanges, undefined, 2));
+ return;
+ }
+ if (exchanges.exchanges.length === 0) {
+ console.log("No exchanges.");
+ return;
+ }
+ const verbose = args.exchangesCmd.verbose || args.wallet.verbose;
+ const rendered = exchanges.exchanges.map((exchange) =>
+ formatPrettyExchange(exchange, verbose, args.exchangesCmd.oneline),
+ );
+ console.log(rendered.map((lines) => lines.join("\n")).join("\n\n"));
+ });
+}
+
+// Listing is the default action, matching transactions and bank accounts.
+exchangesCli.action(listKnownExchanges);
exchangesCli
.subcommand("exchangesListCmd", "list", {
help: "List known exchanges.",
})
- .action(async (args) => {
- console.log("Listing exchanges ...");
- await withWallet(args, { lazyTaskLoop: true }, async (wallet) => {
- const exchanges = await wallet.client.call(
- WalletApiOperation.ListExchanges,
- {},
- );
- console.log(JSON.stringify(exchanges, undefined, 2));
- });
- });
+ .action(listKnownExchanges);
exchangesCli
.subcommand("exchangesListCmd", "trusted", {