taler-typescript-core

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

commit 44890ef04a86e5eae08b6f30dcedbb7371e14e29
parent c727431c67ff83792f9147a425b3d84e1a2e6152
Author: Florian Dold <dold@taler.net>
Date:   Thu, 10 Sep 2026 01:31:54 +0200

wallet-cli: add recover-coins with a refreshing progress display

Expose advanced recover-coins for one exchange, with --all-coins to
include spent roots and --progress or -P to display recovery progress.

Render a throttled single line on terminals and start/end messages when
redirected, keeping the JSON result on stdout.

Diffstat:
Apackages/taler-wallet-cli/src/coin-recovery-progress.test.ts | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-cli/src/coin-recovery-progress.ts | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-cli/src/index.ts | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 257 insertions(+), 0 deletions(-)

diff --git a/packages/taler-wallet-cli/src/coin-recovery-progress.test.ts b/packages/taler-wallet-cli/src/coin-recovery-progress.test.ts @@ -0,0 +1,90 @@ +/* + 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/strict"; +import { test } from "node:test"; +import { + CoinRecoveryProgressNotification, + NotificationType, +} from "@gnu-taler/taler-util"; +import { createCoinRecoveryProgress } from "./coin-recovery-progress.js"; + +function progress( + overrides: Partial<CoinRecoveryProgressNotification> = {}, +): CoinRecoveryProgressNotification { + return { + type: NotificationType.CoinRecoveryProgress, + progressToken: "mine", + exchangeBaseUrl: "https://exchange.test/", + phase: "history", + numChecked: 1, + numDiscovered: 3, + numQueued: 2, + numRecovered: 1, + recoveredAmount: "TESTKUDOS:2", + numIssues: 0, + ...overrides, + }; +} + +test("redirected progress has plain start/end lines and ignores foreign tokens", () => { + const chunks: string[] = []; + const p = createCoinRecoveryProgress("mine", { + write: (s) => chunks.push(s), + }); + p.onNotification( + progress({ progressToken: "someone-else", phase: "complete" }), + ); + p.onNotification(progress()); + assert.equal(chunks.length, 1); + p.onNotification(progress({ phase: "incomplete" })); + p.finish(); + assert.equal(chunks.length, 2); + assert.match(chunks[1], /^incomplete: checked 1\/3/); + assert(!chunks.join("").includes("\x1b")); +}); + +test("TTY progress coalesces updates, truncates and stops on completion", async () => { + const chunks: string[] = []; + const p = createCoinRecoveryProgress("mine", { + isTTY: true, + columns: 30, + write: (s) => chunks.push(s), + }); + for (let i = 0; i < 100; i++) p.onNotification(progress({ numChecked: i })); + assert.equal(chunks.length, 1); + await new Promise((resolve) => setTimeout(resolve, 120)); + assert.equal(chunks.length, 2); + assert(chunks[1].startsWith("\r\x1b[2K")); + assert(chunks[1].slice(5).length < 30); + p.onNotification(progress({ phase: "cancelled" })); + const finalCount = chunks.length; + assert.equal(chunks.at(-1), "\n"); + await new Promise((resolve) => setTimeout(resolve, 120)); + p.onNotification(progress()); + p.finish(); + assert.equal(chunks.length, finalCount); +}); + +test("failure before the first notification still prints a terminal line", () => { + const chunks: string[] = []; + const p = createCoinRecoveryProgress("mine", { + write: (s) => chunks.push(s), + }); + p.finish("failed"); + assert.equal(chunks.length, 2); + assert.match(chunks[1], /^failed/); +}); diff --git a/packages/taler-wallet-cli/src/coin-recovery-progress.ts b/packages/taler-wallet-cli/src/coin-recovery-progress.ts @@ -0,0 +1,100 @@ +/* + 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 { + CoinRecoveryProgressNotification, + NotificationType, + WalletNotification, +} from "@gnu-taler/taler-util"; + +interface ProgressOutput { + isTTY?: boolean; + columns?: number; + write(text: string): unknown; +} + +const terminalPhases = new Set([ + "complete", + "incomplete", + "failed", + "cancelled", +]); + +/** stderr only; remote notifications are broadcasts, so always filter the token. */ +export function createCoinRecoveryProgress( + progressToken: string, + output: ProgressOutput = process.stderr, +) { + const started = Date.now(); + let latest: CoinRecoveryProgressNotification | undefined; + let closed = false; + let fallbackPhase: string = "starting"; + let wroteStart = false; + let wroteTerminal = false; + let lastLine = ""; + const line = () => + latest + ? `${latest.phase}: checked ${latest.numChecked}/${latest.numDiscovered}, queued ${latest.numQueued}, recovered ${latest.numRecovered} (${latest.recoveredAmount}), issues ${latest.numIssues}, ${Math.floor((Date.now() - started) / 1000)}s` + : `${fallbackPhase} coin recovery, ${Math.floor((Date.now() - started) / 1000)}s`; + const draw = () => { + if (closed) return; + const text = line(); + if (output.isTTY) { + // Leave one column free to prevent an automatic line wrap. + const width = Math.max(0, (output.columns ?? 80) - 1); + const display = text.replace(/[\x00-\x1f\x7f-\x9f]/g, "").slice(0, width); + if (display !== lastLine) { + output.write(`\r\x1b[2K${display}`); + lastLine = display; + } + } else if ( + !wroteStart || + (terminalPhases.has(latest?.phase ?? fallbackPhase) && !wroteTerminal) + ) { + output.write(text + "\n"); + wroteStart = true; + if (terminalPhases.has(latest?.phase ?? fallbackPhase)) + wroteTerminal = true; + } + }; + draw(); + const timer = output.isTTY ? setInterval(draw, 100) : undefined; + const close = () => { + if (closed) return; + if (timer) clearInterval(timer); + draw(); + if (output.isTTY) output.write("\n"); + closed = true; + }; + return { + onNotification(n: WalletNotification): void { + if ( + closed || + n.type !== NotificationType.CoinRecoveryProgress || + n.progressToken !== progressToken + ) + return; + latest = n; + if (terminalPhases.has(n.phase)) close(); + else if (!output.isTTY) draw(); + }, + finish(phase: "complete" | "incomplete" | "failed" = "failed"): void { + fallbackPhase = phase; + if (!closed && latest) latest = { ...latest, phase }; + close(); + }, + }; +} diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts @@ -89,6 +89,7 @@ import { Continuation, ContinuationKind, } from "./continuation.js"; +import { createCoinRecoveryProgress } from "./coin-recovery-progress.js"; import { CLI_ENABLE_VAR, parseEnabledMarks } from "./marks.js"; import { createWalletHttpLib, @@ -3616,6 +3617,72 @@ advancedCli }); advancedCli + .subcommand("recoverCoins", "recover-coins", { + help: "Recover coins through the exchange link protocol and print the result as JSON.", + }) + .requiredOption("exchange", ["--exchange"], clk.STRING, { + help: "Base URL of the exchange whose coins should be recovered.", + }) + .flag("allCoins", ["--all-coins"], { + help: "Start from every coin status instead of only fresh coins.", + }) + .flag("progress", ["--progress", "-P"], { + help: "Show a refreshing progress line on stderr.", + }) + .action(async (args) => { + await runCliAction(() => + withWallet(args, { lazyTaskLoop: true }, async (wallet) => { + const progressToken = encodeCrock(getRandomBytes(32)); + const progress = args.recoverCoins.progress + ? createCoinRecoveryProgress(progressToken) + : undefined; + const removeListener = + progress && + wallet.addNotificationListener((n) => progress.onNotification(n)); + const cancel = () => { + void wallet.client + .call(WalletApiOperation.CancelProgressToken, { + operation: WalletApiOperation.TestingRecoverCoins, + progressToken, + }) + .catch(() => {}); + }; + process.on("SIGINT", cancel); + let finalPhase: "complete" | "incomplete" | "failed" = "failed"; + try { + const result = await wallet.client.call( + WalletApiOperation.TestingRecoverCoins, + { + exchangeBaseUrl: args.recoverCoins.exchange, + onlyFresh: !args.recoverCoins.allCoins, + progressToken, + }, + ); + finalPhase = result.complete ? "complete" : "incomplete"; + // Remote replies may arrive before their final notification. + progress?.onNotification({ + type: NotificationType.CoinRecoveryProgress, + progressToken, + exchangeBaseUrl: result.exchangeBaseUrl, + phase: finalPhase, + numChecked: result.numChecked, + numDiscovered: result.numDiscovered, + numQueued: result.numQueued, + numRecovered: result.numRecovered, + recoveredAmount: result.recoveredAmount, + numIssues: result.issues.length, + }); + console.log(j2s(result)); + } finally { + process.off("SIGINT", cancel); + removeListener?.(); + progress?.finish(finalPhase); + } + }), + ); + }); + +advancedCli .subcommand("performanceStats", "performance-stats", { help: "Print performance stats.", })