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:
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(",");
+}