taler-typescript-core

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

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:
Apackages/taler-wallet-cli/src/exchanges-pretty.test.ts | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-cli/src/exchanges-pretty.ts | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-cli/src/index.ts | 65++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
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", {