taler-typescript-core

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

commit 275eefd1b3338fe3788db29fe1c972dd425110e5
parent 42e42055b74cf1f194263c42d935c503b993bd22
Author: Florian Dold <dold@taler.net>
Date:   Thu,  6 Aug 2026 14:21:41 +0200

wallet-cli: add wait-tx and make the other commands scriptable

Scripts had no way to wait for a transaction state, and errors, prompts
and exit codes were unusable without a terminal.

Diffstat:
Mpackages/taler-wallet-cli/package.json | 2+-
Mpackages/taler-wallet-cli/src/index.ts | 963++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Apackages/taler-wallet-cli/src/waitspec.test.ts | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-cli/src/waitspec.ts | 227+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 1179 insertions(+), 175 deletions(-)

diff --git a/packages/taler-wallet-cli/package.json b/packages/taler-wallet-cli/package.json @@ -17,7 +17,7 @@ "type": "module", "scripts": { "compile": "tsc && ./build-node.mjs", - "test": "tsc", + "test": "tsc && node --test lib/", "coverage": "tsc && c8 ./bin/taler-wallet-cli.mjs", "typedoc": "pnpm dlx typedoc --out dist/typedoc ./src/", "clean": "rm -rf lib dist tsconfig.tsbuildinfo", diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts @@ -36,23 +36,37 @@ import { InitRequest, j2s, Logger, + matchTransactionState, NotificationType, Paytos, Result, setDangerousTimetravel, setGlobalLogLevelFromString, summarizeTalerErrorDetail, + TalerErrorCode, TalerUriAction, TalerUris, + TestingWaitTxStateSpec, + Transaction, TransactionIdStr, TransactionMajorState, TransactionMinorState, + TransactionState, + TransactionStatePattern, TransactionType, WalletNotification, WithdrawalType, } from "@gnu-taler/taler-util"; import { clk } from "@gnu-taler/taler-util/clk"; import { + CliUsageError, + formatTxState, + formatTxStateSpec, + parseTimeoutSpec, + parseTxStateSpec, + TX_STATE_SPEC_SYNTAX, +} from "./waitspec.js"; +import { getenv, pathHomedir, processExit, @@ -89,9 +103,51 @@ const logger = new Logger("taler-wallet-cli.ts"); let observabilityEventFile: string | undefined = undefined; +const EXIT_USAGE = 2; const EXIT_EXCEPTION = 4; const EXIT_API_ERROR = 5; const EXIT_INPUT_REQUIRED = 6; +const EXIT_TIMEOUT = 7; +const EXIT_TX_UNSUCCESSFUL = 8; + +/** + * Run the body of a command, turning what it throws into a message and + * an exit code. + * + * Without this, an error from wallet-core ends up as a raw exception dump + * from the argument parser. + */ +async function runCliAction(f: () => Promise<number | void>): Promise<void> { + let code: number; + try { + code = (await f()) ?? 0; + } catch (e) { + if (e instanceof CliUsageError) { + console.error(`error: ${e.message}`); + if (e.hint) { + console.error(` ${e.hint}`); + } + code = EXIT_USAGE; + } else { + const ed = getErrorDetailFromException(e); + console.error(`error: ${summarizeTalerErrorDetail(ed)}`); + if (ed.hint) { + console.error(` ${ed.hint}`); + } + if (logger.shouldLogTrace()) { + console.error(JSON.stringify(ed, undefined, 2)); + } + code = + ed.code === TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION + ? EXIT_EXCEPTION + : EXIT_API_ERROR; + } + } + // Only exit once the wallet has been shut down cleanly. + if (code !== 0) { + processExit(code); + } +} setUnhandledRejectionHandler((error: any) => { logger.error("unhandledRejection", error.message); @@ -244,10 +300,36 @@ async function doPay( await doHandlePayTransaction(wallet, r.transactionId, options); } +let globalNonInteractive = false; + +function isNonInteractive(): boolean { + return ( + globalNonInteractive || + checkEnvFlag("TALER_WALLET_NONINTERACTIVE") || + !process.stdin.isTTY + ); +} + +/** + * Ask the user something, unless nobody is there to answer. + * + * Without this, a command run from a script blocks forever + * instead of reporting that it needs input. + */ +async function promptOrFail(question: string): Promise<string> { + if (isNonInteractive()) { + console.error( + `error: input required, but running non-interactively: ${question}`, + ); + processExit(EXIT_INPUT_REQUIRED); + } + return await readlinePrompt(question); +} + async function askChoice(n: number): Promise<number> { while (true) { const choice = Number.parseInt( - await clk.prompt(`Select choice (0-${n - 1}):`), + await promptOrFail(`Select choice (0-${n - 1}):`), ); if (choice >= 0 && choice < n) { return choice; @@ -259,7 +341,7 @@ async function askChoice(n: number): Promise<number> { async function askYesNo(): Promise<boolean> { while (true) { - const yesNoResp = (await clk.prompt("Pay? [Y/n]")).toLowerCase(); + const yesNoResp = (await promptOrFail("Pay? [Y/n]")).toLowerCase(); if (yesNoResp === "" || yesNoResp === "y" || yesNoResp === "yes") { return true; } else if (yesNoResp === "n" || yesNoResp === "no") { @@ -315,6 +397,12 @@ export const walletCli = clk .maybeOption("inhibit", ["--inhibit"], clk.STRING, { help: "Inhibit running certain operations, useful for debugging and testing.", }) + .flag("nonInteractive", ["--non-interactive"], { + help: "Never ask for input; fail instead. Implied when stdin is not a terminal.", + onPresentHandler: () => { + globalNonInteractive = true; + }, + }) .flag("noThrottle", ["--no-throttle"], { help: "Don't do any request throttling.", }) @@ -362,6 +450,15 @@ export interface WalletContext { waitForNotificationCond<T>( cond: (n: WalletNotification) => T | false | undefined, ): Promise<T>; + + /** + * Call f for every notification of the wallet until the returned + * function is called. + * + * Note that a remote wallet sends the notifications of all its + * clients, so listeners must filter. + */ + addNotificationListener(f: (n: WalletNotification) => void): () => void; } interface CreateWalletResult { @@ -449,10 +546,21 @@ async function withWallet<T>( f: (ctx: WalletContext) => Promise<T>, ): Promise<T> { const waiter = makeNotificationWaiter(); + const notifListeners = new Set<(n: WalletNotification) => void>(); const onNotif = (notif: WalletNotification) => { waiter.notify(notif); writeObservabilityLog(notif); + for (const listener of notifListeners) { + listener(notif); + } + }; + + const addNotificationListener = (f: (n: WalletNotification) => void) => { + notifListeners.add(f); + return () => { + notifListeners.delete(f); + }; }; let walletSocketPath: string | undefined = undefined; @@ -482,6 +590,7 @@ async function withWallet<T>( }, client: getClientFromRemoteWallet(w), waitForNotificationCond: waiter.waitForNotificationCond, + addNotificationListener, }; const res = await f(ctx); w.close(); @@ -491,6 +600,7 @@ async function withWallet<T>( const ctx: WalletContext = { client: wh.wallet.client, waitForNotificationCond: waiter.waitForNotificationCond, + addNotificationListener, makeCoreApiRequest(operation, payload) { return wh.wallet.handleCoreApiRequest(operation, "my-req", payload); }, @@ -518,7 +628,10 @@ async function withWallet<T>( walletCli .subcommand("balance", "balance", { help: "Show wallet balance." }) .flag("json", ["--json"], { - help: "Show raw JSON.", + help: "Show raw JSON (the default).", + }) + .flag("human", ["--human"], { + help: "Show one line per currency instead of JSON.", }) .action(async (args) => { await withWallet( @@ -531,7 +644,18 @@ walletCli WalletApiOperation.GetBalances, {}, ); - console.log(JSON.stringify(balance, undefined, 2)); + // JSON stays the default: scripts rely on it. + if (!args.balance.human || args.balance.json) { + console.log(JSON.stringify(balance, undefined, 2)); + return; + } + for (const bal of balance.balances) { + console.log( + `${bal.scopeInfo.currency}: available=${bal.available} ` + + `pending-incoming=${bal.pendingIncoming} ` + + `pending-outgoing=${bal.pendingOutgoing}`, + ); + } }, ); }); @@ -544,67 +668,95 @@ walletCli help: "Exit with non-zero status code when request fails instead of returning error JSON.", }) .action(async (args) => { - await withWallet(args, {}, async (wallet) => { - let requestJson; - logger.info(`handling 'api' request (${args.api.operation})`); - const jsonContent = args.api.request.startsWith("@") - ? readFile(args.api.request.substring(1)) - : args.api.request; - try { - requestJson = JSON.parse(jsonContent); - } catch (e) { - console.error("Invalid JSON"); - processExit(1); - } - try { - const resp = await wallet.makeCoreApiRequest( - args.api.operation, - requestJson, - ); - console.log(JSON.stringify(resp, undefined, 2)); - if (resp.type === "error") { - if (args.api.expectSuccess) { - processExit(EXIT_API_ERROR); - } else { + // Exit only after withWallet returned, so that the wallet + // is shut down cleanly. + await runCliAction(() => + withWallet(args, {}, async (wallet) => { + let requestJson; + logger.info(`handling 'api' request (${args.api.operation})`); + const jsonContent = args.api.request.startsWith("@") + ? readFile(args.api.request.substring(1)) + : args.api.request; + try { + requestJson = JSON.parse(jsonContent); + } catch (e) { + throw new CliUsageError("invalid JSON in request"); + } + try { + const resp = await wallet.makeCoreApiRequest( + args.api.operation, + requestJson, + ); + console.log(JSON.stringify(resp, undefined, 2)); + if (resp.type === "error") { + if (args.api.expectSuccess) { + return EXIT_API_ERROR; + } logger.warn("api request resulted in error response"); } + } catch (e) { + logger.error(`Got exception while handling API request ${e}`); + return EXIT_EXCEPTION; } - } catch (e) { - logger.error(`Got exception while handling API request ${e}`); - processExit(EXIT_EXCEPTION); - } - }); - logger.info("finished handling API request"); + logger.info("finished handling API request"); + return 0; + }), + ); }); +const TX_STATE_FILTERS = [ + "final", + "nonfinal", + "done", + "nonfinal-approved", + "nonfinal-dialog", +] as const; + const transactionsCli = walletCli .subcommand("transactions", "transactions", { help: "Manage transactions." }) .maybeOption("currency", ["--currency"], clk.STRING, { help: "Filter by currency.", }) - .maybeOption("search", ["--search"], clk.STRING, { - help: "Filter by search string", + .maybeOption("filterByState", ["--state"], clk.STRING, { + help: `Only list transactions in a state category (${TX_STATE_FILTERS.join( + ", ", + )}).`, }) .flag("includeRefreshes", ["--include-refreshes"]); // Default action transactionsCli.action(async (args) => { - await withWallet( - args, - { - lazyTaskLoop: true, - }, - async (wallet) => { - const pending = await wallet.client.call( - WalletApiOperation.GetTransactionsV2, - { - currency: args.transactions.currency, - includeRefreshes: args.transactions.includeRefreshes, - }, + await runCliAction(async () => { + const filterByState = args.transactions.filterByState; + if ( + filterByState != null && + !TX_STATE_FILTERS.includes(filterByState as any) + ) { + throw new CliUsageError( + `invalid value '${filterByState}' for --state`, + `expected one of ${TX_STATE_FILTERS.join(", ")}`, ); - console.log(JSON.stringify(pending, undefined, 2)); - }, - ); + } + await withWallet( + args, + { + lazyTaskLoop: true, + }, + async (wallet) => { + const pending = await wallet.client.call( + WalletApiOperation.GetTransactionsV2, + { + currency: args.transactions.currency, + includeRefreshes: args.transactions.includeRefreshes, + filterByState: filterByState as + | (typeof TX_STATE_FILTERS)[number] + | undefined, + }, + ); + console.log(JSON.stringify(pending, undefined, 2)); + }, + ); + }); }); transactionsCli @@ -750,6 +902,407 @@ transactionsCli }); }); +/** + * States that a transaction does not leave on its own and that + * mean that it did not succeed. + */ +const UNSUCCESSFUL_STATE_PATTERNS: TransactionStatePattern[] = [ + { major: TransactionMajorState.Failed, minor: "*", working: "*" }, + { major: TransactionMajorState.Aborted, minor: "*", working: "*" }, + { major: TransactionMajorState.Expired, minor: "*", working: "*" }, +]; + +interface WaitTxRequest { + transactionId: TransactionIdStr; + txState: TestingWaitTxStateSpec; + /** + * Timeout in milliseconds, or undefined to wait forever. + */ + timeoutMs?: number; + bailStates?: TransactionStatePattern[]; + bailOnError?: boolean; + requireError?: boolean; + logId?: string; +} + +type WaitTxOutcome = + | { result: "match" | "bail"; txState: TransactionState; stId: number } + | { result: "timeout" }; + +/** + * Wait until a transaction reaches one of the states we're interested in. + * + * The timeout is passed to wallet-core (which cancels the waiter, so that + * nothing is left behind in a wallet that keeps running) and additionally + * raced here, in case the wallet does not answer at all. + */ +async function waitForTxState( + ctx: WalletContext, + req: WaitTxRequest, +): Promise<WaitTxOutcome> { + const timeoutMs = req.timeoutMs; + const deadline = + timeoutMs == null + ? undefined + : AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromMilliseconds(timeoutMs), + ); + // Give wallet-core's own timeout a head start, so that it is normally + // the one that ends the wait. + const graceMs = + timeoutMs == null ? 0 : Math.min(5000, Math.max(1000, timeoutMs / 10)); + let timer: ReturnType<typeof setTimeout> | undefined; + const clientTimeout = new Promise<"timeout">((resolve) => { + if (timeoutMs != null) { + timer = setTimeout(() => resolve("timeout"), timeoutMs + graceMs); + } + }); + try { + const res = await Promise.race([ + ctx.client.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: req.transactionId, + txState: req.txState, + bailStates: req.bailStates, + bailOnError: req.bailOnError, + requireError: req.requireError, + logId: req.logId, + timeout: + timeoutMs == null + ? undefined + : { seconds: Math.ceil(timeoutMs / 1000) }, + }), + clientTimeout, + ]); + if (res === "timeout") { + return { result: "timeout" }; + } + return { + result: res.matched === "bail" ? "bail" : "match", + txState: res.txState, + stId: res.stId, + }; + } catch (e) { + const ed = getErrorDetailFromException(e); + // Older wallets report the timeout as a plain exception, hence the + // fallback to our own deadline. + if ( + ed.code === TalerErrorCode.GENERIC_TIMEOUT || + (deadline != null && AbsoluteTime.isExpired(deadline)) + ) { + return { result: "timeout" }; + } + throw e; + } finally { + if (timer != null) { + clearTimeout(timer); + } + } +} + +async function lookupTxOrNull( + ctx: WalletContext, + transactionId: TransactionIdStr, +): Promise<Transaction | undefined> { + try { + return await ctx.client.call(WalletApiOperation.GetTransactionById, { + transactionId, + }); + } catch (e) { + // The transaction might have been deleted while we were waiting. + logger.warn(`could not look up transaction: ${e}`); + return undefined; + } +} + +/** + * Which of the requested patterns did the transaction match? + * + * Undefined for the shorthands and for a state ID, which don't + * name a single pattern. + */ +function findMatchingPattern( + spec: TestingWaitTxStateSpec, + st: TransactionState, +): TransactionStatePattern | undefined { + if (typeof spec === "string" || typeof spec === "number") { + return undefined; + } + const pats = Array.isArray(spec) ? spec : [spec]; + return pats.find((pat) => matchTransactionState(st, pat)); +} + +function txDetailsForJson(tx: Transaction | undefined): unknown { + if (tx == null) { + return null; + } + return { + type: tx.type, + txState: tx.txState, + stId: tx.stId, + txActions: tx.txActions, + amountRaw: tx.amountRaw, + amountEffective: tx.amountEffective, + timestamp: tx.timestamp, + error: tx.error ?? null, + abortReason: tx.abortReason ?? null, + failReason: tx.failReason ?? null, + kycUrl: tx.kycUrl ?? null, + kycAccessToken: tx.kycAccessToken ?? null, + }; +} + +interface WaitTxArgs { + transactionId: string; + state?: string; + stid?: number; + timeout?: string; + failOn?: string; + logId?: string; + requireError: boolean; + json: boolean; + follow: boolean; + quiet: boolean; +} + +async function runWaitTx( + cliArgs: WalletCliArgsType, + a: WaitTxArgs, +): Promise<number> { + if (a.stid != null && a.state != null) { + throw new CliUsageError("--state and --stid are mutually exclusive"); + } + const txState: TestingWaitTxStateSpec = + a.stid != null ? a.stid : parseTxStateSpec(a.state ?? "final"); + const timeout = a.timeout != null ? parseTimeoutSpec(a.timeout) : "forever"; + const timeoutMs = timeout === "forever" ? undefined : timeout; + let bailStates: TransactionStatePattern[] | undefined; + let bailOnError: boolean | undefined; + switch (a.failOn ?? "unsuccessful") { + case "unsuccessful": + bailStates = UNSUCCESSFUL_STATE_PATTERNS; + break; + case "error": + bailOnError = true; + break; + case "both": + bailStates = UNSUCCESSFUL_STATE_PATTERNS; + bailOnError = true; + break; + case "none": + break; + default: + throw new CliUsageError( + `invalid value '${a.failOn}' for --fail-on`, + "expected 'unsuccessful', 'error', 'both' or 'none'", + ); + } + const transactionId = a.transactionId as TransactionIdStr; + return await withWallet(cliArgs, { lazyTaskLoop: false }, async (ctx) => { + let cancelFollow: (() => void) | undefined; + if (a.follow) { + cancelFollow = ctx.addNotificationListener((n) => { + if ( + n.type === NotificationType.TransactionStateTransition && + n.transactionId === transactionId + ) { + console.error(JSON.stringify(n)); + } + }); + } + const start = AbsoluteTime.now(); + let outcome: WaitTxOutcome; + try { + outcome = await waitForTxState(ctx, { + transactionId, + txState, + timeoutMs, + bailStates, + bailOnError, + requireError: a.requireError ? true : undefined, + logId: a.logId, + }); + } finally { + if (cancelFollow != null) { + cancelFollow(); + } + } + const waitedMs = Duration.toMilliseconds( + AbsoluteTime.difference(start, AbsoluteTime.now()), + ); + const tx = await lookupTxOrNull(ctx, transactionId); + const state = outcome.result === "timeout" ? tx?.txState : outcome.txState; + const matchedPattern = + outcome.result === "match" && state != null + ? findMatchingPattern(txState, state) + : undefined; + + if (a.json) { + console.log( + JSON.stringify( + { + result: outcome.result, + transactionId, + waitedMs, + requestedState: txState, + matchedPattern: matchedPattern ?? null, + transaction: txDetailsForJson(tx), + }, + undefined, + 2, + ), + ); + } + + if (!a.quiet && !a.json) { + const stateStr = state != null ? formatTxState(state) : "unknown"; + switch (outcome.result) { + case "match": + console.log( + `matched ${stateStr} after ${(waitedMs / 1000).toFixed(1)}s`, + ); + break; + case "bail": + console.error( + `gave up: transaction is in ${stateStr} after ${( + waitedMs / 1000 + ).toFixed(1)}s`, + ); + break; + case "timeout": + console.error( + `timeout: waited ${(waitedMs / 1000).toFixed( + 1, + )}s for ${formatTxStateSpec(txState)}`, + ); + console.error(`last observed state: ${stateStr}`); + break; + } + if (tx != null) { + if (outcome.result !== "match") { + console.error(`possible actions: ${tx.txActions.join(", ")}`); + } + for (const [label, ed] of [ + ["error", tx.error], + ["abort reason", tx.abortReason], + ["fail reason", tx.failReason], + ] as const) { + if (ed != null) { + console.error(`${label}: ${summarizeTalerErrorDetail(ed)}`); + if (ed.hint) { + console.error(` hint: ${ed.hint}`); + } + } + } + if (tx.kycUrl != null) { + console.log(`kyc required: ${tx.kycUrl}`); + } + } + } + + switch (outcome.result) { + case "match": + return 0; + case "bail": + return EXIT_TX_UNSUCCESSFUL; + case "timeout": + return EXIT_TIMEOUT; + } + }); +} + +/** + * Options that commands creating a transaction share, so that + * they can be used non-interactively without a second command. + */ +interface CreateWaitArgs { + wait: boolean; + timeout?: string; +} + +/** + * Wait for a transaction that the command just created, if --wait + * was given. Returns the exit code for the command. + */ +async function waitForCreatedTx( + ctx: WalletContext, + transactionId: TransactionIdStr, + a: CreateWaitArgs, +): Promise<number> { + if (!a.wait) { + return 0; + } + const timeout = a.timeout != null ? parseTimeoutSpec(a.timeout) : "forever"; + const outcome = await waitForTxState(ctx, { + transactionId, + txState: "final", + timeoutMs: timeout === "forever" ? undefined : timeout, + }); + if (outcome.result === "timeout") { + console.error(`timeout: transaction ${transactionId} is not finished`); + return EXIT_TIMEOUT; + } + console.log( + `transaction ${transactionId} finished in state ${formatTxState( + outcome.txState, + )}`, + ); + const unsuccessful = UNSUCCESSFUL_STATE_PATTERNS.some((pat) => + matchTransactionState(outcome.txState, pat), + ); + return unsuccessful ? EXIT_TX_UNSUCCESSFUL : 0; +} + +const waitTxHelp = [ + "Block until a transaction is in one of the given states.", + "Meant for scripts: the state that ended the wait determines the exit code", + "(0 = matched, 7 = timeout, 8 = transaction did not succeed).", +].join(" "); + +function addWaitTxCommand(parent: any, argKey: string, name: string): void { + parent + .subcommand(argKey, name, { help: waitTxHelp }) + .requiredArgument("transactionId", clk.STRING, { + metavar: "TRANSACTION_ID", + help: "Identifier of the transaction to wait for.", + }) + .maybeOption("state", ["-s", "--state"], clk.STRING, { + help: `State(s) to wait for (default: final). ${TX_STATE_SPEC_SYNTAX}`, + }) + .maybeOption("stid", ["--stid"], clk.INT, { + help: "Wait for this wallet-internal state ID instead of a state pattern.", + }) + .maybeOption("timeout", ["-t", "--timeout"], clk.STRING, { + help: "Give up after this duration (e.g. '30s', '5m'). Waits forever by default; a wait without a timeout can't be cancelled in a wallet that keeps running.", + }) + .maybeOption("failOn", ["--fail-on"], clk.STRING, { + help: "Give up early on 'unsuccessful' (default) states, on any recorded 'error' (including transient ones), on 'both' or on 'none'.", + }) + .maybeOption("logId", ["--log-id"], clk.STRING, { + help: "Identifier for this wait in the wallet's log.", + }) + .flag("requireError", ["--require-error"], { + help: "Only accept the state when the transaction also has an error.", + }) + .flag("json", ["--json"], { + help: "Print the outcome as JSON on stdout.", + }) + .flag("follow", ["--follow"], { + help: "Print every state transition of the transaction as JSON on stderr.", + }) + .flag("quiet", ["-q", "--quiet"], { + help: "Don't print anything, just set the exit code.", + }) + .action(async (args: any) => { + await runCliAction(() => runWaitTx(args, args[argKey] as WaitTxArgs)); + }); +} + +addWaitTxCommand(walletCli, "waitTx", "wait-tx"); +// The same command, for discoverability next to the other +// transaction subcommands. The argument key must differ. +addWaitTxCommand(transactionsCli, "transactionsWait", "wait"); + walletCli .subcommand("finishPendingOpt", "run-until-done", { help: "Run until no more work is left.", @@ -904,7 +1457,7 @@ async function cliHandleTos( return true; } if (exch.tosStatus === ExchangeTosStatus.Proposed) { - const res = await readlinePrompt( + const res = await promptOrFail( `Accept terms of service of exchange ${exchangeBaseUrl}? [y/N/info]: `, ); switch (res.toLowerCase()) { @@ -962,7 +1515,7 @@ async function cliPeerPushCredit( txDet.txState.minor === TransactionMinorState.Proposed ) { while (true) { - const res = await readlinePrompt( + const res = await promptOrFail( `Accept payment of ${prepRes.amountEffective}? [y/N/info/delete]: `, ); let done = false; @@ -1028,7 +1581,9 @@ walletCli .flag("noWait", ["--no-wait"]) .maybeOption("choiceIndex", ["--choice-index"], clk.INT) .maybeOption("restrictAge", ["--restrict-age"], clk.INT) - .flag("nonInteractive", ["--non-interactive"]) + .flag("nonInteractive", ["--non-interactive"], { + help: "Deprecated, use the global --non-interactive.", + }) .flag("autoYes", ["-y", "--yes"]) .action(async (args) => { await withWallet(args, { lazyTaskLoop: true }, async (wallet) => { @@ -1036,7 +1591,7 @@ walletCli if (args.handleUri.uri) { uri = args.handleUri.uri; } else { - uri = await readlinePrompt("Taler URI: "); + uri = await promptOrFail("Taler URI: "); } const parsedTalerUri = Result.orUndefined(TalerUris.parse(uri)); if (!parsedTalerUri) { @@ -1047,7 +1602,7 @@ walletCli await doPayTemplate(wallet.client, uri, { alwaysYes: args.handleUri.autoYes, choiceIndex: args.handleUri.choiceIndex, - nonInteractive: args.handleUri.nonInteractive, + nonInteractive: args.handleUri.nonInteractive || isNonInteractive(), noWait: args.handleUri.noWait, }); break; @@ -1055,7 +1610,7 @@ walletCli await doPay(wallet.client, uri, { alwaysYes: args.handleUri.autoYes, choiceIndex: args.handleUri.choiceIndex, - nonInteractive: args.handleUri.nonInteractive, + nonInteractive: args.handleUri.nonInteractive || isNonInteractive(), noWait: args.handleUri.noWait, }); break; @@ -1096,7 +1651,7 @@ walletCli await wallet.client.call(WalletApiOperation.SetExchangeTosAccepted, { exchangeBaseUrl, }); - const res = await readlinePrompt(`Amount (in ${exch.currency}): `); + const res = await promptOrFail(`Amount (in ${exch.currency}): `); const amount = Amounts.stringify(Amounts.parseOrThrow(res)); const w = await wallet.client.call( WalletApiOperation.AcceptManualWithdrawal, @@ -1122,7 +1677,7 @@ walletCli if (withdrawInfo.amount) { console.log(`Default amount: ${withdrawInfo.amount}`); } - const res = await readlinePrompt( + const res = await promptOrFail( `Amount (in ${withdrawInfo.currency}): `, ); amount = Amounts.stringify(Amounts.parseOrThrow(res)); @@ -1178,53 +1733,70 @@ withdrawCli }) .maybeOption("forcedReservePriv", ["--forced-reserve-priv"], clk.STRING, {}) .maybeOption("restrictAge", ["--restrict-age"], clk.INT) + .flag("wait", ["--wait"], { + help: "Wait until the transaction is in a final state.", + }) + .maybeOption("timeout", ["--timeout"], clk.STRING, { + help: "Give up waiting after this duration (e.g. '30s', '5m').", + }) .action(async (args) => { - await withWallet(args, { lazyTaskLoop: true }, async (wallet) => { - const exchangeBaseUrl = args.withdrawManually.exchange; - const amount = args.withdrawManually.amount; - const d = await wallet.client.call( - WalletApiOperation.GetWithdrawalDetailsForAmount, - { - amount: args.withdrawManually.amount, - exchangeBaseUrl: exchangeBaseUrl, - }, - ); - const acct = d.withdrawalAccountsList[0]; - if (!acct) { - console.log("exchange has no accounts"); - return; - } - const resp = await wallet.client.call( - WalletApiOperation.AcceptManualWithdrawal, - { - amount, - exchangeBaseUrl, - restrictAge: args.withdrawManually.restrictAge, - forceReservePriv: args.withdrawManually.forcedReservePriv, - }, - ); - await wallet.client.call(WalletApiOperation.TestingWaitTransactionState, { - transactionId: resp.transactionId, - txState: { - major: TransactionMajorState.Pending, - minor: TransactionMinorState.ExchangeWaitReserve, - }, - }); - const txDet = await wallet.client.call( - WalletApiOperation.GetTransactionById, - { - transactionId: resp.transactionId, - }, - ); - if (txDet.type !== TransactionType.Withdrawal) { - throw Error("assertion failed"); - } - if (txDet.withdrawalDetails.type !== WithdrawalType.ManualTransfer) { - throw Error("assertion failed"); - } - console.log("transfer accounts:"); - console.log(j2s(txDet.withdrawalDetails.exchangeCreditAccountDetails)); - }); + await runCliAction(() => + withWallet(args, { lazyTaskLoop: true }, async (wallet) => { + const exchangeBaseUrl = args.withdrawManually.exchange; + const amount = args.withdrawManually.amount; + const d = await wallet.client.call( + WalletApiOperation.GetWithdrawalDetailsForAmount, + { + amount: args.withdrawManually.amount, + exchangeBaseUrl: exchangeBaseUrl, + }, + ); + const acct = d.withdrawalAccountsList[0]; + if (!acct) { + console.log("exchange has no accounts"); + return EXIT_API_ERROR; + } + const resp = await wallet.client.call( + WalletApiOperation.AcceptManualWithdrawal, + { + amount, + exchangeBaseUrl, + restrictAge: args.withdrawManually.restrictAge, + forceReservePriv: args.withdrawManually.forcedReservePriv, + }, + ); + await wallet.client.call( + WalletApiOperation.TestingWaitTransactionState, + { + transactionId: resp.transactionId, + txState: { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.ExchangeWaitReserve, + }, + }, + ); + const txDet = await wallet.client.call( + WalletApiOperation.GetTransactionById, + { + transactionId: resp.transactionId, + }, + ); + if (txDet.type !== TransactionType.Withdrawal) { + throw Error("assertion failed"); + } + if (txDet.withdrawalDetails.type !== WithdrawalType.ManualTransfer) { + throw Error("assertion failed"); + } + console.log(`transaction ${resp.transactionId}`); + console.log("transfer accounts:"); + console.log(j2s(txDet.withdrawalDetails.exchangeCreditAccountDetails)); + return await waitForCreatedTx( + wallet, + resp.transactionId, + args.withdrawManually, + ); + }), + ); }); const exchangesCli = walletCli.subcommand("exchangesCmd", "exchanges", { @@ -1469,17 +2041,32 @@ depositCli .subcommand("createDepositArgs", "create") .requiredArgument("amount", clk.AMOUNT) .requiredArgument("targetPayto", clk.STRING) + .flag("wait", ["--wait"], { + help: "Wait until the transaction is in a final state.", + }) + .maybeOption("timeout", ["--timeout"], clk.STRING, { + help: "Give up waiting after this duration (e.g. '30s', '5m').", + }) .action(async (args) => { - await withWallet(args, { lazyTaskLoop: true }, async (wallet) => { - const resp = await wallet.client.call( - WalletApiOperation.CreateDepositGroup, - { - amount: args.createDepositArgs.amount, - depositPaytoUri: args.createDepositArgs.targetPayto, - }, - ); - console.log(`Created deposit ${resp.depositGroupId}`); - }); + await runCliAction(() => + withWallet(args, { lazyTaskLoop: true }, async (wallet) => { + const resp = await wallet.client.call( + WalletApiOperation.CreateDepositGroup, + { + amount: args.createDepositArgs.amount, + depositPaytoUri: args.createDepositArgs.targetPayto, + }, + ); + console.log(`Created deposit ${resp.depositGroupId}`); + // The transaction ID is what the other commands take. + console.log(`transaction ${resp.transactionId}`); + return await waitForCreatedTx( + wallet, + resp.transactionId, + args.createDepositArgs, + ); + }), + ); }); depositCli @@ -1596,6 +2183,12 @@ peerCli }) .maybeOption("purseExpiration", ["--purse-expiration"], clk.STRING) .maybeOption("exchangeBaseUrl", ["--exchange"], clk.STRING) + .flag("wait", ["--wait"], { + help: "Wait until the transaction is in a final state.", + }) + .maybeOption("timeout", ["--timeout"], clk.STRING, { + help: "Give up waiting after this duration (e.g. '30s', '5m').", + }) .action(async (args) => { let purseExpiration: AbsoluteTime; @@ -1611,20 +2204,28 @@ peerCli ); } - await withWallet(args, { lazyTaskLoop: true }, async (wallet) => { - const resp = await wallet.client.call( - WalletApiOperation.InitiatePeerPullCredit, - { - exchangeBaseUrl: args.initiatePayPull.exchangeBaseUrl, - partialContractTerms: { - amount: args.initiatePayPull.amount, - summary: args.initiatePayPull.summary ?? "Invoice", - purse_expiration: AbsoluteTime.toProtocolTimestamp(purseExpiration), + await runCliAction(() => + withWallet(args, { lazyTaskLoop: true }, async (wallet) => { + const resp = await wallet.client.call( + WalletApiOperation.InitiatePeerPullCredit, + { + exchangeBaseUrl: args.initiatePayPull.exchangeBaseUrl, + partialContractTerms: { + amount: args.initiatePayPull.amount, + summary: args.initiatePayPull.summary ?? "Invoice", + purse_expiration: + AbsoluteTime.toProtocolTimestamp(purseExpiration), + }, }, - }, - ); - console.log(JSON.stringify(resp, undefined, 2)); - }); + ); + console.log(JSON.stringify(resp, undefined, 2)); + return await waitForCreatedTx( + wallet, + resp.transactionId, + args.initiatePayPull, + ); + }), + ); }); peerCli @@ -1653,6 +2254,12 @@ peerCli help: "Summary to use in the contract terms.", }) .maybeOption("purseExpiration", ["--purse-expiration"], clk.STRING) + .flag("wait", ["--wait"], { + help: "Wait until the transaction is in a final state.", + }) + .maybeOption("timeout", ["--timeout"], clk.STRING, { + help: "Give up waiting after this duration (e.g. '30s', '5m').", + }) .action(async (args) => { let purseExpiration: AbsoluteTime; @@ -1668,19 +2275,23 @@ peerCli ); } - await withWallet(args, { lazyTaskLoop: true }, async (wallet) => { - const resp = await wallet.client.call( - WalletApiOperation.InitiatePeerPushDebit, - { - partialContractTerms: { - amount: args.payPush.amount, - summary: args.payPush.summary ?? "Payment", - purse_expiration: AbsoluteTime.toProtocolTimestamp(purseExpiration), + await runCliAction(() => + withWallet(args, { lazyTaskLoop: true }, async (wallet) => { + const resp = await wallet.client.call( + WalletApiOperation.InitiatePeerPushDebit, + { + partialContractTerms: { + amount: args.payPush.amount, + summary: args.payPush.summary ?? "Payment", + purse_expiration: + AbsoluteTime.toProtocolTimestamp(purseExpiration), + }, }, - }, - ); - console.log(JSON.stringify(resp, undefined, 2)); - }); + ); + console.log(JSON.stringify(resp, undefined, 2)); + return await waitForCreatedTx(wallet, resp.transactionId, args.payPush); + }), + ); }); const advancedCli = walletCli.subcommand("advancedArgs", "advanced", { @@ -2281,30 +2892,34 @@ const testCli = walletCli.subcommand("testingArgs", "testing", { testCli .subcommand("withdrawTestkudos", "withdraw-testkudos") - .flag("wait", ["--wait"]) + .flag("wait", ["--wait"], { + help: "Wait until the transaction is in a final state.", + }) + .maybeOption("timeout", ["--timeout"], clk.STRING, { + help: "Give up waiting after this duration (e.g. '30s', '5m').", + }) .action(async (args) => { - await withWallet(args, { lazyTaskLoop: true }, async (wallet) => { - const resp = await wallet.client.call( - WalletApiOperation.WithdrawTestkudos, - {}, - ); - if (args.withdrawTestkudos.wait) { - await wallet.client.call( - WalletApiOperation.TestingWaitTransactionState, - { - transactionId: resp.transactionId, - txState: { - major: TransactionMajorState.Done, - }, - }, + await runCliAction(() => + withWallet(args, { lazyTaskLoop: true }, async (wallet) => { + const resp = await wallet.client.call( + WalletApiOperation.WithdrawTestkudos, + {}, ); - } - }); + console.log(`transaction ${resp.transactionId}`); + return await waitForCreatedTx( + wallet, + resp.transactionId, + args.withdrawTestkudos, + ); + }), + ); }); testCli .subcommand("withdrawKudos", "withdraw-kudos") - .flag("wait", ["--wait"]) + .flag("wait", ["--wait"], { + help: "Wait until the transaction is in a final state.", + }) .requiredOption("amount", ["--amount"], clk.AMOUNT, { help: "Amount to withdraw (default: 50 KUDOS)", default: "KUDOS:50", @@ -2317,28 +2932,28 @@ testCli help: "Exchange to use for operations (default: https://exchange.demo.taler.net/).", default: "https://exchange.demo.taler.net/", }) + .maybeOption("timeout", ["--timeout"], clk.STRING, { + help: "Give up waiting after this duration (e.g. '30s', '5m').", + }) .action(async (args) => { - await withWallet(args, { lazyTaskLoop: true }, async (wallet) => { - const resp = await wallet.client.call( - WalletApiOperation.WithdrawTestBalance, - { - amount: args.withdrawKudos.amount as AmountString, - corebankApiBaseUrl: args.withdrawKudos.bank, - exchangeBaseUrl: args.withdrawKudos.exchange, - }, - ); - if (args.withdrawKudos.wait) { - await wallet.client.call( - WalletApiOperation.TestingWaitTransactionState, + await runCliAction(() => + withWallet(args, { lazyTaskLoop: true }, async (wallet) => { + const resp = await wallet.client.call( + WalletApiOperation.WithdrawTestBalance, { - transactionId: resp.transactionId, - txState: { - major: TransactionMajorState.Done, - }, + amount: args.withdrawKudos.amount as AmountString, + corebankApiBaseUrl: args.withdrawKudos.bank, + exchangeBaseUrl: args.withdrawKudos.exchange, }, ); - } - }); + console.log(`transaction ${resp.transactionId}`); + return await waitForCreatedTx( + wallet, + resp.transactionId, + args.withdrawKudos, + ); + }), + ); }); class PerfTimer { diff --git a/packages/taler-wallet-cli/src/waitspec.test.ts b/packages/taler-wallet-cli/src/waitspec.test.ts @@ -0,0 +1,162 @@ +/* + 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 { + TransactionMajorState, + TransactionMinorState, +} from "@gnu-taler/taler-util"; +import assert from "node:assert"; +import { test } from "node:test"; +import { + CliUsageError, + formatTxState, + formatTxStateSpec, + parseTimeoutSpec, + parseTxStateSpec, +} from "./waitspec.js"; + +test("state spec shorthands", (t) => { + assert.strictEqual(parseTxStateSpec("nonpending"), "nonpending"); + assert.strictEqual(parseTxStateSpec("final"), "final"); + assert.strictEqual(parseTxStateSpec(" final "), "final"); +}); + +test("major state only implies a minor wildcard", (t) => { + assert.deepStrictEqual(parseTxStateSpec("done"), [ + { major: TransactionMajorState.Done, minor: "*", working: "*" }, + ]); +}); + +test("major and minor state", (t) => { + assert.deepStrictEqual(parseTxStateSpec("pending:withdraw"), [ + { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.Withdraw, + working: "*", + }, + ]); +}); + +test("wildcards, spelled both ways", (t) => { + const expected = [{ major: "*", minor: "*", working: "*" }]; + assert.deepStrictEqual(parseTxStateSpec("*:*"), expected); + assert.deepStrictEqual(parseTxStateSpec("any:any"), expected); + assert.deepStrictEqual(parseTxStateSpec("*"), expected); +}); + +test("'-' requires the absence of a minor state", (t) => { + assert.deepStrictEqual(parseTxStateSpec("aborted:-"), [ + { major: TransactionMajorState.Aborted, minor: undefined, working: "*" }, + ]); +}); + +test("working qualifier", (t) => { + assert.deepStrictEqual(parseTxStateSpec("pending:withdraw/working"), [ + { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.Withdraw, + working: true, + }, + ]); + assert.deepStrictEqual(parseTxStateSpec("pending/idle"), [ + { major: TransactionMajorState.Pending, minor: "*", working: false }, + ]); + assert.deepStrictEqual(parseTxStateSpec("pending:any/any"), [ + { major: TransactionMajorState.Pending, minor: "*", working: "*" }, + ]); +}); + +test("alternatives are comma-separated", (t) => { + assert.deepStrictEqual(parseTxStateSpec("done, failed/idle ,aborted:bank"), [ + { major: TransactionMajorState.Done, minor: "*", working: "*" }, + { major: TransactionMajorState.Failed, minor: "*", working: false }, + { + major: TransactionMajorState.Aborted, + minor: TransactionMinorState.Bank, + working: "*", + }, + ]); +}); + +test("minor states with hyphens are not split", (t) => { + assert.deepStrictEqual( + parseTxStateSpec("suspended-aborting:bank-register-reserve"), + [ + { + major: TransactionMajorState.SuspendedAborting, + minor: TransactionMinorState.BankRegisterReserve, + working: "*", + }, + ], + ); +}); + +test("invalid state specs are rejected", (t) => { + const bad = [ + "", + " ", + "dnoe", + "done:kyc-required", + "done,", + ",done", + "pending:withdraw/maybe", + "pending:withdraw:extra", + ]; + for (const spec of bad) { + assert.throws(() => parseTxStateSpec(spec), CliUsageError, spec); + } +}); + +test("rejection of an unknown state suggests alternatives", (t) => { + try { + parseTxStateSpec("done:kyc-required"); + assert.fail("should have thrown"); + } catch (e) { + assert.ok(e instanceof CliUsageError); + assert.match(e.message, /kyc-required/); + assert.match(e.hint ?? "", /kyc/); + } +}); + +test("timeout spec", (t) => { + assert.strictEqual(parseTimeoutSpec("forever"), "forever"); + assert.strictEqual(parseTimeoutSpec("30s"), 30000); + assert.strictEqual(parseTimeoutSpec("5m"), 5 * 60 * 1000); + assert.strictEqual(parseTimeoutSpec("2h"), 2 * 60 * 60 * 1000); + for (const spec of ["", "5x", "later", "-1s", "0s"]) { + assert.throws(() => parseTimeoutSpec(spec), CliUsageError, spec); + } +}); + +test("formatting a state round-trips into the same spec", (t) => { + assert.strictEqual( + formatTxState({ + major: TransactionMajorState.Pending, + minor: TransactionMinorState.Withdraw, + working: true, + }), + "pending:withdraw/working", + ); + assert.strictEqual( + formatTxState({ major: TransactionMajorState.Done }), + "done", + ); + assert.strictEqual( + formatTxStateSpec(parseTxStateSpec("done,pending:withdraw/idle")), + "done:*,pending:withdraw/idle", + ); + assert.strictEqual(formatTxStateSpec("final"), "final"); + assert.strictEqual(formatTxStateSpec(7), "state ID 7"); +}); diff --git a/packages/taler-wallet-cli/src/waitspec.ts b/packages/taler-wallet-cli/src/waitspec.ts @@ -0,0 +1,227 @@ +/* + 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 + * Parsing of the command line syntax for transaction states, + * used by the "wait-tx" command. + */ + +/** + * Imports. + */ +import { + Duration, + TestingWaitTxStateSpec, + TransactionMajorState, + TransactionMinorState, + TransactionState, + TransactionStatePattern, +} from "@gnu-taler/taler-util"; + +/** + * Error caused by bad command line arguments. + * + * Reported without a stack trace, and with an exit code that + * distinguishes it from a failed operation. + */ +export class CliUsageError extends Error { + constructor( + message: string, + public hint?: string, + ) { + super(message); + Object.setPrototypeOf(this, CliUsageError.prototype); + } +} + +const MAJOR_VALUES: string[] = Object.values(TransactionMajorState); +const MINOR_VALUES: string[] = Object.values(TransactionMinorState); + +/** + * Syntax of a state spec, as shown in the command line help. + */ +export const TX_STATE_SPEC_SYNTAX = [ + "MAJOR[:MINOR][/working|/idle], multiple alternatives separated by ','.", + "MAJOR and MINOR may be '*' (or 'any'); MINOR defaults to 'any' and may be", + "'-' to require a state without a minor state.", + "The shorthands 'nonpending' and 'final' stand for whole categories.", +].join(" "); + +/** + * Suggest valid values for a token that wasn't recognized. + */ +function suggest(bad: string, candidates: string[]): string { + const near = candidates.filter( + (c) => + (bad.length >= 3 && c.startsWith(bad.substring(0, 3))) || + c.includes(bad) || + bad.includes(c), + ); + if (near.length > 0) { + return `did you mean: ${near.slice(0, 5).join(", ")}?`; + } + return `valid values: ${candidates.join(", ")}`; +} + +function parseWorking(w: string): boolean | "*" { + switch (w) { + case "working": + return true; + case "idle": + return false; + case "*": + case "any": + return "*"; + default: + throw new CliUsageError( + `invalid working qualifier '${w}'`, + "expected 'working', 'idle' or 'any'", + ); + } +} + +function parseAlternative(alt: string): TransactionStatePattern { + let head = alt; + let working: boolean | "*" = "*"; + const slash = alt.indexOf("/"); + if (slash >= 0) { + head = alt.substring(0, slash); + working = parseWorking(alt.substring(slash + 1)); + } + + // No enum value contains a colon, so the first one separates + // the major from the minor state. + const colon = head.indexOf(":"); + const majorStr = colon < 0 ? head : head.substring(0, colon); + const minorStr = colon < 0 ? undefined : head.substring(colon + 1); + + let major: TransactionMajorState | "*"; + if (majorStr === "*" || majorStr === "any") { + major = "*"; + } else if (MAJOR_VALUES.includes(majorStr)) { + major = majorStr as TransactionMajorState; + } else { + throw new CliUsageError( + `unknown transaction major state '${majorStr}'`, + suggest(majorStr, MAJOR_VALUES), + ); + } + + // An omitted minor state means "any" here, whereas wallet-core + // takes it to mean "no minor state at all". Spell out the wildcard, + // and let '-' request what wallet-core does with an omitted value. + let minor: TransactionMinorState | "*" | undefined; + if (minorStr === undefined || minorStr === "*" || minorStr === "any") { + minor = "*"; + } else if (minorStr === "-") { + minor = undefined; + } else if (MINOR_VALUES.includes(minorStr)) { + minor = minorStr as TransactionMinorState; + } else { + throw new CliUsageError( + `unknown transaction minor state '${minorStr}'`, + suggest(minorStr, MINOR_VALUES), + ); + } + + return { major, minor, working }; +} + +/** + * Parse the state spec of the "wait-tx" command into what + * wallet-core accepts for the state to wait for. + */ +export function parseTxStateSpec(spec: string): TestingWaitTxStateSpec { + const s = spec.trim(); + if (s === "") { + throw new CliUsageError("empty state specification"); + } + if (s === "nonpending" || s === "final") { + return s; + } + return s.split(",").map((alt) => { + const trimmed = alt.trim(); + if (trimmed === "") { + throw new CliUsageError( + `empty alternative in state specification '${spec}'`, + ); + } + return parseAlternative(trimmed); + }); +} + +/** + * Parse a timeout as accepted on the command line. + * + * Returns the timeout in milliseconds, or "forever". + */ +export function parseTimeoutSpec(spec: string): number | "forever" { + const s = spec.trim(); + if (s === "forever") { + return "forever"; + } + let d: Duration; + try { + d = Duration.fromPrettyString(s); + } catch (e) { + throw new CliUsageError( + `invalid timeout '${spec}'`, + "expected a duration such as '30s', '5m' or '2h', or 'forever'", + ); + } + if (d.d_ms === "forever") { + return "forever"; + } + if (d.d_ms <= 0) { + throw new CliUsageError(`timeout '${spec}' must be positive`); + } + return d.d_ms; +} + +/** + * Render a transaction state the way it is written on the command line. + */ +export function formatTxState(st: TransactionState): string { + const base = st.minor != null ? `${st.major}:${st.minor}` : `${st.major}`; + return st.working ? `${base}/working` : base; +} + +/** + * Render a state spec for messages, in the command line syntax. + */ +export function formatTxStateSpec(spec: TestingWaitTxStateSpec): string { + if (typeof spec === "string") { + return spec; + } + if (typeof spec === "number") { + return `state ID ${spec}`; + } + const pats = Array.isArray(spec) ? spec : [spec]; + return pats + .map((pat) => { + const minor = pat.minor === undefined ? "-" : pat.minor; + const base = `${pat.major}:${minor}`; + if (pat.working === true) { + return `${base}/working`; + } + if (pat.working === false) { + return `${base}/idle`; + } + return base; + }) + .join(","); +}