@vonpay/checkout-cli 0.14.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +30 -5
  2. package/dist/index.js +759 -331
  3. package/package.json +8 -3
package/dist/index.js CHANGED
@@ -7,13 +7,12 @@ import { dirname as dirname2, join as join3 } from "path";
7
7
  import { fileURLToPath as fileURLToPath2 } from "url";
8
8
 
9
9
  // src/commands/login.ts
10
- import { Command } from "commander";
10
+ import { Command, Option } from "commander";
11
11
  import { createInterface } from "readline";
12
12
  import chalk from "chalk";
13
- import { isLiveKey } from "@vonpay/checkout-node";
14
13
 
15
14
  // src/config.ts
16
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
15
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
17
16
  import { homedir } from "os";
18
17
  import { join } from "path";
19
18
 
@@ -34,6 +33,17 @@ function emitCliError(json, text) {
34
33
  return process.exit(1);
35
34
  }
36
35
 
36
+ // src/utils/key-mode.ts
37
+ import { isLiveKey } from "@vonpay/checkout-node";
38
+ var TEST_SECRET_KEY_PREFIX = "vp_sk_test_";
39
+ function secretKeyMode(key) {
40
+ if (typeof key !== "string") return null;
41
+ if (isLiveKey(key)) return "live";
42
+ if (key.startsWith(TEST_SECRET_KEY_PREFIX)) return "test";
43
+ return null;
44
+ }
45
+ var NOT_A_SECRET_KEY_MESSAGE = "This is not a Von Payments secret key: it must start with vp_sk_test_ (sandbox) or vp_sk_live_ (live). Publishable keys (vp_pk_\u2026) and keys from other services cannot be used with this CLI.";
46
+
37
47
  // src/config.ts
38
48
  var CONFIG_DIR = join(homedir(), ".vonpay");
39
49
  var CONFIG_FILE = join(CONFIG_DIR, "config.json");
@@ -48,7 +58,20 @@ function loadConfig() {
48
58
  return {};
49
59
  }
50
60
  }
61
+ var LiveKeyNotStoredError = class extends Error {
62
+ constructor() {
63
+ super(
64
+ "Refusing to save a live key (vp_sk_live_\u2026) to ~/.vonpay/config.json: live keys are never stored on disk. Supply it for this terminal session only, through the VON_PAY_SECRET_KEY environment variable."
65
+ );
66
+ this.name = "LiveKeyNotStoredError";
67
+ }
68
+ };
51
69
  function saveConfig(config) {
70
+ const key = config.checkout?.apiKey;
71
+ if (secretKeyMode(key) === "live") {
72
+ if (key !== loadConfig().checkout?.apiKey) throw new LiveKeyNotStoredError();
73
+ warnStoredLiveKey();
74
+ }
52
75
  if (!existsSync(CONFIG_DIR)) {
53
76
  mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
54
77
  }
@@ -56,6 +79,7 @@ function saveConfig(config) {
56
79
  encoding: "utf-8",
57
80
  mode: 384
58
81
  });
82
+ chmodSync(CONFIG_FILE, 384);
59
83
  }
60
84
  function clearStoredApiKey() {
61
85
  const config = loadConfig();
@@ -73,7 +97,17 @@ function getApiKey() {
73
97
  const envKey = process.env.VON_PAY_SECRET_KEY;
74
98
  if (envKey) return envKey;
75
99
  const config = loadConfig();
76
- return config.checkout?.apiKey;
100
+ const stored = config.checkout?.apiKey;
101
+ if (secretKeyMode(stored) === "live") warnStoredLiveKey();
102
+ return stored;
103
+ }
104
+ var warnedStoredLiveKey = false;
105
+ function warnStoredLiveKey() {
106
+ if (warnedStoredLiveKey) return;
107
+ warnedStoredLiveKey = true;
108
+ console.warn(
109
+ "Warning: a LIVE key is saved in plaintext in ~/.vonpay/config.json (stored by an older version of this CLI). Live keys are no longer stored. Run `vonpay checkout logout` to delete it, and supply the key through the VON_PAY_SECRET_KEY environment variable for the session instead."
110
+ );
77
111
  }
78
112
  function requireApiKey() {
79
113
  const key = getApiKey();
@@ -81,21 +115,28 @@ function requireApiKey() {
81
115
  const message = "No API key found. Run `vonpay checkout login` or set VON_PAY_SECRET_KEY.";
82
116
  emitCliError({ error: message, code: null }, message);
83
117
  }
118
+ if (secretKeyMode(key) === null) {
119
+ emitCliError({ error: NOT_A_SECRET_KEY_MESSAGE, code: null }, NOT_A_SECRET_KEY_MESSAGE);
120
+ }
84
121
  return key;
85
122
  }
86
123
 
87
124
  // src/commands/login.ts
88
- var VALID_PREFIXES = ["vp_sk_test_", "vp_sk_live_"];
89
- function evaluateCliKeyGate(key, opts) {
125
+ var LIVE_KEY_NOT_SAVED_MESSAGE = "Live keys (vp_sk_live_\u2026) are never saved to ~/.vonpay/config.json: the file is plaintext, and a live key moves real money. Supply it for this terminal session only, through the environment, for example:\n read -rs VON_PAY_SECRET_KEY && export VON_PAY_SECRET_KEY\n(paste the key at the silent prompt; it ends with the session). Use `login` for sandbox keys (vp_sk_test_\u2026).";
126
+ function evaluateCliKeyGate(key, _opts = {}) {
90
127
  if (!key) return { action: "prompt" };
91
- const warning = "Warning: passing keys as a command argument exposes them in shell history and process-list (`ps`). Use interactive mode (`vonpay checkout login`) instead.";
92
- if (isLiveKey(key) && !opts.confirmCliExposure) {
93
- return {
94
- action: "refuse",
95
- reason: "Refusing to save a live key supplied on the CLI. Re-run with --confirm-cli-exposure if this is intentional, or omit the key argument to be prompted interactively."
96
- };
97
- }
98
- return { action: "save", warning };
128
+ const refusal = evaluateKeyToSave(key);
129
+ if (refusal) return refusal;
130
+ return {
131
+ action: "save",
132
+ warning: "Warning: passing keys as a command argument exposes them in shell history and process-list (`ps`). Use interactive mode (`vonpay checkout login`) instead."
133
+ };
134
+ }
135
+ function evaluateKeyToSave(key) {
136
+ const mode = secretKeyMode(key);
137
+ if (mode === null) return { action: "refuse", reason: NOT_A_SECRET_KEY_MESSAGE };
138
+ if (mode === "live") return { action: "refuse", reason: LIVE_KEY_NOT_SAVED_MESSAGE };
139
+ return null;
99
140
  }
100
141
  function readLine(prompt) {
101
142
  const rl = createInterface({ input: process.stdin, output: process.stderr });
@@ -107,18 +148,15 @@ function readLine(prompt) {
107
148
  });
108
149
  }
109
150
  function loginCommand() {
110
- return new Command("login").description("Store your API key for CLI access").argument("[key]", "API secret key (vp_sk_*)").option(
111
- "--confirm-cli-exposure",
112
- "Acknowledge that passing a live key as a command argument exposes it to shell history and ps(1)."
151
+ return new Command("login").description("Store a sandbox API key (vp_sk_test_\u2026) for CLI access. Live keys are never stored").argument("[key]", "Sandbox API secret key (vp_sk_test_*)").addOption(
152
+ new Option(
153
+ "--confirm-cli-exposure",
154
+ "No longer has any effect: live keys are never saved"
155
+ ).hideHelp()
113
156
  ).action(async (key, opts) => {
114
157
  let apiKey = key;
115
158
  const verdict = evaluateCliKeyGate(key, opts);
116
159
  if (verdict.action === "refuse") {
117
- console.warn(
118
- chalk.yellow(
119
- "Warning: passing keys as a command argument exposes them in shell history and process-list (`ps`). Use interactive mode (`vonpay checkout login`) instead."
120
- )
121
- );
122
160
  console.error(chalk.red(verdict.reason));
123
161
  process.exit(1);
124
162
  }
@@ -131,26 +169,22 @@ function loginCommand() {
131
169
  "Get your API key from the Von Payments dashboard \u2192 Developers"
132
170
  )
133
171
  );
134
- apiKey = await readLine("API key (vp_sk_*): ");
172
+ apiKey = await readLine("Sandbox API key (vp_sk_test_*): ");
135
173
  }
136
174
  if (!apiKey) {
137
175
  console.error(chalk.red("No key provided."));
138
176
  process.exit(1);
139
177
  }
140
- if (!VALID_PREFIXES.some((p) => apiKey.startsWith(p))) {
141
- console.error(
142
- chalk.red(
143
- `Invalid key format. Must start with ${VALID_PREFIXES.join(" or ")}`
144
- )
145
- );
178
+ const refusal = evaluateKeyToSave(apiKey);
179
+ if (refusal) {
180
+ console.error(chalk.red(refusal.reason));
146
181
  process.exit(1);
147
182
  }
148
183
  const config = loadConfig();
149
184
  config.checkout = { ...config.checkout, apiKey };
150
185
  saveConfig(config);
151
- const mode = apiKey.includes("_test_") ? "test" : "live";
152
186
  console.log(
153
- chalk.green(`\u2713 API key saved (${mode} mode) \u2192 ~/.vonpay/config.json`)
187
+ chalk.green(`\u2713 API key saved (test mode) \u2192 ~/.vonpay/config.json`)
154
188
  );
155
189
  });
156
190
  }
@@ -182,9 +216,10 @@ function logoutCommand() {
182
216
 
183
217
  // src/commands/init.ts
184
218
  import { Command as Command3 } from "commander";
185
- import { appendFileSync, existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
219
+ import { appendFileSync, chmodSync as chmodSync2, existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
186
220
  import { resolve } from "path";
187
221
  import chalk3 from "chalk";
222
+ var LIVE_KEY_NOT_WRITTEN_MESSAGE = "Refusing to write a live key (vp_sk_live_\u2026) to .env: live keys are never written to disk. Run `init` with a sandbox key (vp_sk_test_\u2026), and give your app its live key through your deployment's secret store or the environment when you go live.";
188
223
  function ensureGitignoreBlocksEnv() {
189
224
  if (!existsSync2(".gitignore")) return "no-repo";
190
225
  if (envAlreadyIgnored(".gitignore")) {
@@ -242,20 +277,14 @@ function initCommand() {
242
277
  );
243
278
  process.exit(1);
244
279
  }
245
- const mode = apiKey.includes("_test_") ? "test" : "live";
280
+ const mode = secretKeyMode(apiKey);
281
+ if (mode === null) {
282
+ console.error(chalk3.red(NOT_A_SECRET_KEY_MESSAGE));
283
+ process.exit(1);
284
+ }
246
285
  if (mode === "live") {
247
- console.warn(
248
- chalk3.yellow(
249
- `\u26A0 About to write a LIVE API key to ${absPath}.
250
- Confirm this is the intended directory, then re-run with --force if you're sure.`
251
- )
252
- );
253
- if (!opts.force) {
254
- console.error(
255
- chalk3.red("Refusing to write live key without --force.")
256
- );
257
- process.exit(1);
258
- }
286
+ console.error(chalk3.red(LIVE_KEY_NOT_WRITTEN_MESSAGE));
287
+ process.exit(1);
259
288
  }
260
289
  const content = [
261
290
  "# Von Payments Checkout",
@@ -264,6 +293,7 @@ function initCommand() {
264
293
  ""
265
294
  ].join("\n");
266
295
  writeFileSync2(envFile, content, { encoding: "utf-8", mode: 384 });
296
+ chmodSync2(envFile, 384);
267
297
  console.log(chalk3.green(`\u2713 .env created (${mode} mode) \u2192 ${absPath}`));
268
298
  const gitignoreState = ensureGitignoreBlocksEnv();
269
299
  if (gitignoreState === "added") {
@@ -308,29 +338,39 @@ var CLI_APP_INFO = { name: "vonpay-cli", version: CLI_VERSION };
308
338
  var SILENT_ERROR_REPORTER = () => {
309
339
  };
310
340
 
311
- // src/client-factory.ts
312
- function getBaseUrl() {
313
- const envBase = process.env.VONPAY_BASE_URL;
314
- if (envBase) return envBase;
315
- const config = loadConfig();
316
- return config.checkout?.baseUrl;
341
+ // src/utils/base-url.ts
342
+ import { keyDestinationProblem as sdkKeyDestinationProblem } from "@vonpay/checkout-node";
343
+ var ALLOW_CUSTOM_BASE_URL_ENV = "VONPAY_ALLOW_CUSTOM_BASE_URL";
344
+ function customBaseUrlAllowed(env = process.env) {
345
+ return env[ALLOW_CUSTOM_BASE_URL_ENV] === "1";
346
+ }
347
+ var UNSAFE_URL_CHARS = /[\s\x00-\x1f\x7f\\]/;
348
+ var WHERE = "It comes from VONPAY_BASE_URL or baseUrl in ~/.vonpay/config.json; fix it, or unset it to use https://checkout.vonpay.com.";
349
+ function keyDestinationProblem(apiKey, baseUrl, env = process.env) {
350
+ if (baseUrl === void 0) return null;
351
+ if (UNSAFE_URL_CHARS.test(baseUrl)) {
352
+ return `Refusing to use the base URL: it contains spaces, control characters or backslashes. ${WHERE}`;
353
+ }
354
+ let url;
355
+ try {
356
+ url = new URL(baseUrl);
357
+ } catch {
358
+ return `Refusing to use the base URL: it is not a valid absolute URL (for example https://checkout.vonpay.com). ${WHERE}`;
359
+ }
360
+ if (url.username !== "" || url.password !== "") {
361
+ return `Refusing to use the base URL: it contains a user name or password, which would be sent with every request. ${WHERE}`;
362
+ }
363
+ const problem = sdkKeyDestinationProblem(apiKey, baseUrl, { allowCustom: customBaseUrlAllowed(env) });
364
+ return problem === null ? null : `${problem} ${WHERE}`;
317
365
  }
318
- function makeCheckoutClient() {
319
- const apiKey = requireApiKey();
320
- const baseUrl = getBaseUrl();
321
- return new VonPayCheckout({
322
- apiKey,
323
- ...baseUrl ? { baseUrl } : {},
324
- appInfo: CLI_APP_INFO,
325
- // The CLI prints every error itself (handleError / --json). Without this the SDK's fallback
326
- // reporter adds its own `[vonpay] …` line to stderr on EVERY API error, live key included.
327
- errorReporter: SILENT_ERROR_REPORTER
328
- });
366
+ function allowCustomBaseUrl(env = process.env) {
367
+ return customBaseUrlAllowed(env);
329
368
  }
330
369
 
331
370
  // src/utils/errors.ts
332
371
  import chalk4 from "chalk";
333
372
  import {
373
+ RETRY_WITHHELD_HELP,
334
374
  VonPayError
335
375
  } from "@vonpay/checkout-node";
336
376
  var SECRET_PATTERNS = [
@@ -345,13 +385,24 @@ function scrubSecrets(s) {
345
385
  for (const [re, replacement] of SECRET_PATTERNS) out = out.replace(re, replacement);
346
386
  return out;
347
387
  }
388
+ var CLI_READ_BACK = "From this CLI: `vonpay checkout payment-intents get <id>` (charge, capture, void), `vonpay checkout refunds list --payment-intent <id>` or `--transaction <id>` (refund), `sessions get <id>` (checkout session).";
389
+ var READ_BACK_FIRST = "Check the payment first: read it back (`vonpay checkout payment-intents get <id>` for a charge, capture or void; `vonpay checkout refunds list --payment-intent <id>` (or `--transaction <id>`) for a refund; `sessions get <id>` for a checkout session) and act only on what it shows.";
390
+ var UNKNOWN_OUTCOME_GUIDANCE = `${READ_BACK_FIRST} If you then retry, reuse the SAME --idempotency-key as the first attempt, never a new one: a new key is a new action and can move money twice.`;
348
391
  var str = (v) => v === void 0 || v === null ? null : String(v);
349
392
  var FIELD_PRINTERS = {
350
393
  code: (e) => `Code: ${str(e.code)}`,
351
394
  fix: (e) => `Fix: ${str(e.fix)}`,
352
- nextAction: (e) => e.nextAction ? `Next action: ${str(e.nextAction)}` : null,
395
+ nextAction: (e) => (
396
+ // A withheld retry carries its own (SDK) guidance on the line below.
397
+ e.nextAction === "reconcile" && e.retryWithheld !== true ? `Next action: reconcile \u2014 ${UNKNOWN_OUTCOME_GUIDANCE}` : e.nextAction ? `Next action: ${str(e.nextAction)}` : null
398
+ ),
353
399
  retryable: (e) => typeof e.retryable === "boolean" ? `Retryable: ${e.retryable ? "yes" : "no \u2014 do not re-run this command as-is"}` : null,
354
- retryWithheld: (e) => e.retryWithheld === true ? "Retry withheld: the outcome was unclear and no idempotency key was sent, so the CLI did not retry. Check whether it went through before running it again; with --idempotency-key, re-running the SAME key is safe." : null,
400
+ retryWithheld: (e) => e.retryWithheld === true ? (
401
+ // What to do is the SDK's RETRY_WITHHELD_HELP, printed on the Hint line
402
+ // (the SDK puts it in llmHint; quoted here only when the error carries
403
+ // something else). This line adds the CLI commands for the read-back.
404
+ "Retry withheld: the outcome is unknown and the CLI did not retry it. " + (e.llmHint === RETRY_WITHHELD_HELP.llmHint ? "" : `${RETRY_WITHHELD_HELP.llmHint} `) + CLI_READ_BACK
405
+ ) : null,
355
406
  captureOutcome: (e) => e.captureOutcome ? `Capture outcome: ${str(e.captureOutcome)} \u2014 the provider accepted the capture but it could not be recorded, so money may have moved. Do NOT capture again; read the payment intent back first.` : null,
356
407
  voidOutcome: (e) => e.voidOutcome ? `Void outcome: ${str(e.voidOutcome)} \u2014 the provider accepted the void but it could not be recorded. Do NOT void again or tell the buyer the hold was released until a read-back confirms it.` : null,
357
408
  currentStatus: (e) => e.currentStatus ? `Current status: ${str(e.currentStatus)}` : null,
@@ -365,6 +416,8 @@ var FIELD_PRINTERS = {
365
416
  rawReason: (e) => e.rawReason && e.rawReason !== e.reason ? `Reason as sent: ${str(e.rawReason)}` : null,
366
417
  retryAfterSeconds: (e) => typeof e.retryAfterSeconds === "number" ? `Retry after: ${e.retryAfterSeconds} seconds \u2014 then ask again` : null,
367
418
  session: (e) => e.session ? `Session: ${JSON.stringify(e.session)}` : null,
419
+ // On a withheld retry the SDK sets this to RETRY_WITHHELD_HELP.llmHint — the
420
+ // one wording for every surface — so it is printed as is.
368
421
  llmHint: (e) => e.llmHint ? `Hint: ${str(e.llmHint)}` : null,
369
422
  docs: (e) => e.docs ? `Docs: ${str(e.docs)}` : null,
370
423
  status: (e) => typeof e.status === "number" ? `HTTP status: ${e.status}` : null,
@@ -422,6 +475,66 @@ function handleError(err) {
422
475
  process.exit(1);
423
476
  }
424
477
 
478
+ // src/client-factory.ts
479
+ function getBaseUrl() {
480
+ const envBase = process.env.VONPAY_BASE_URL;
481
+ if (envBase) return envBase;
482
+ const config = loadConfig();
483
+ return config.checkout?.baseUrl;
484
+ }
485
+ function refuseUnsafeLiveDestination(apiKey) {
486
+ const problem = keyDestinationProblem(apiKey, getBaseUrl());
487
+ if (problem) exitWithCliError(problem, problem);
488
+ }
489
+ function apiHostDetail() {
490
+ const baseUrl = getBaseUrl();
491
+ if (!baseUrl) return [];
492
+ let origin = baseUrl;
493
+ try {
494
+ origin = new URL(baseUrl).origin;
495
+ } catch {
496
+ }
497
+ return origin === "https://checkout.vonpay.com" ? [] : [["API host", origin]];
498
+ }
499
+ function makeCheckoutClient() {
500
+ const apiKey = requireApiKey();
501
+ const baseUrl = getBaseUrl();
502
+ refuseUnsafeLiveDestination(apiKey);
503
+ return new VonPayCheckout({
504
+ apiKey,
505
+ ...baseUrl ? { baseUrl } : {},
506
+ // The SDK applies the same host rule at construction; the opt-in is the
507
+ // same environment variable (real process environment only).
508
+ allowCustomBaseUrl: allowCustomBaseUrl(),
509
+ appInfo: CLI_APP_INFO,
510
+ // The CLI prints every error itself (handleError / --json). Without this the SDK's fallback
511
+ // reporter adds its own `[vonpay] …` line to stderr on EVERY API error, live key included.
512
+ errorReporter: SILENT_ERROR_REPORTER
513
+ });
514
+ }
515
+
516
+ // src/utils/amount.ts
517
+ var WHOLE_NUMBER = /^\d+$/;
518
+ function parseMinorUnits(raw, flag) {
519
+ const text = raw.trim();
520
+ const value = Number(text);
521
+ if (!WHOLE_NUMBER.test(text) || !Number.isSafeInteger(value)) {
522
+ return {
523
+ ok: false,
524
+ message: `${flag} must be a whole number in minor units (the currency's smallest unit): for example 1499 = 14.99 USD. Got ${JSON.stringify(raw)}.`
525
+ };
526
+ }
527
+ return { ok: true, value };
528
+ }
529
+ function minorUnitsOrExit(raw, flag) {
530
+ const parsed = parseMinorUnits(raw, flag);
531
+ if (!parsed.ok) exitWithCliError(parsed.message, `Error: ${parsed.message}`);
532
+ return parsed.value;
533
+ }
534
+ function optionalMinorUnitsOrExit(raw, flag) {
535
+ return raw === void 0 ? void 0 : minorUnitsOrExit(raw, flag);
536
+ }
537
+
425
538
  // src/commands/sessions.ts
426
539
  var makeClient = makeCheckoutClient;
427
540
  function sessionLines(session) {
@@ -469,12 +582,13 @@ function sessionsCommand() {
469
582
  const sessions = new Command4("sessions").description(
470
583
  "Create and manage checkout sessions"
471
584
  );
472
- sessions.command("create").description("Create a new checkout session").requiredOption("--amount <cents>", "Amount in minor units (e.g. 1499 = $14.99)", (v) => parseInt(v, 10)).requiredOption("--currency <code>", "Currency code (e.g. USD)").option("--country <code>", "Country code (e.g. US)").option("--description <text>", "Session description").option("--success-url <url>", "Redirect URL on success").option("--cancel-url <url>", "Redirect URL on cancel").option("--idempotency-key <key>", "Idempotency key for safe retries").option("--dry-run", "Validate without creating a session").option("--json", "Output raw JSON").action(async (opts) => {
585
+ sessions.command("create").description("Create a new checkout session").requiredOption("--amount <cents>", "Amount in minor units, a whole number (e.g. 1499 = $14.99)").requiredOption("--currency <code>", "Currency code (e.g. USD)").option("--country <code>", "Country code (e.g. US)").option("--description <text>", "Session description").option("--success-url <url>", "Redirect URL on success").option("--cancel-url <url>", "Redirect URL on cancel").option("--idempotency-key <key>", "Idempotency key for safe retries").option("--dry-run", "Validate without creating a session").option("--json", "Output raw JSON").action(async (opts) => {
586
+ const amount = minorUnitsOrExit(String(opts.amount), "--amount");
473
587
  const client = makeClient();
474
588
  const spinner = startSpinner("Creating session...");
475
589
  try {
476
590
  const params = {
477
- amount: opts.amount,
591
+ amount,
478
592
  currency: opts.currency.toUpperCase(),
479
593
  country: opts.country?.toUpperCase(),
480
594
  description: opts.description,
@@ -537,11 +651,30 @@ function sessionsCommand() {
537
651
  }
538
652
 
539
653
  // src/commands/payment-intents.ts
540
- import { randomUUID } from "crypto";
541
654
  import { Command as Command5 } from "commander";
542
- import chalk7 from "chalk";
655
+ import chalk8 from "chalk";
543
656
  import { formatMinorUnits as formatMinorUnits2 } from "@vonpay/checkout-node";
544
657
 
658
+ // src/utils/idempotency.ts
659
+ import { randomUUID } from "crypto";
660
+ import chalk6 from "chalk";
661
+ function resolveIdempotencyKey(flag) {
662
+ const given = flag?.trim();
663
+ if (given) return { key: given, generated: false };
664
+ return { key: `cli-${randomUUID()}`, generated: true };
665
+ }
666
+ function idempotencyKeyFor(flag, what) {
667
+ const { key, generated } = resolveIdempotencyKey(flag);
668
+ if (generated) {
669
+ console.error(
670
+ chalk6.dim(
671
+ `Idempotency key: ${key} (to retry this same ${what}, re-run with --idempotency-key ${key})`
672
+ )
673
+ );
674
+ }
675
+ return key;
676
+ }
677
+
545
678
  // src/utils/metadata.ts
546
679
  var MAX_LEN = 256;
547
680
  var CONTROL_CHARS = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/;
@@ -597,25 +730,75 @@ function safeDisplay(s, max = 200) {
597
730
  }
598
731
 
599
732
  // src/utils/live-key.ts
600
- import chalk6 from "chalk";
733
+ import { createInterface as createInterface2 } from "readline";
734
+ import chalk7 from "chalk";
735
+ import { Option as Option2 } from "commander";
601
736
  import { isLiveKey as isLiveKey2 } from "@vonpay/checkout-node";
602
- function requireLiveConfirmation(key, opts, operation, consequence = "is irreversible") {
737
+ var CONFIRM_WORD = "confirm";
738
+ var terminal = {
739
+ /**
740
+ * Both stdin and stdout are a terminal. A command run by a script, a CI job or
741
+ * an AI agent's shell tool has at least one of them piped.
742
+ */
743
+ isInteractive() {
744
+ return process.stdin.isTTY === true && process.stdout.isTTY === true;
745
+ },
746
+ /** Ask one line on the terminal. End of input or Ctrl-C answers "" (cancel). */
747
+ ask(prompt) {
748
+ return new Promise((resolve2) => {
749
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
750
+ let answered = false;
751
+ rl.on("SIGINT", () => rl.close());
752
+ rl.on("close", () => {
753
+ if (!answered) resolve2("");
754
+ });
755
+ rl.question(prompt, (answer) => {
756
+ answered = true;
757
+ rl.close();
758
+ resolve2(answer);
759
+ });
760
+ });
761
+ }
762
+ };
763
+ function keyFingerprint(key) {
764
+ const m = key.match(/^(vp_sk_live_)([A-Za-z0-9_-]{0,4})/);
765
+ return m ? `${m[1]}${m[2]}\u2026` : "vp_sk_live_\u2026";
766
+ }
767
+ async function confirmLiveAction(key, summary, opts = {}) {
603
768
  if (!isLiveKey2(key)) return;
769
+ if (!terminal.isInteractive()) {
770
+ const message = `Refusing to ${summary.action} with a live key: no person is at an interactive terminal to confirm it. A live-key action must be confirmed by a person typing at a terminal (stdin and stdout both a terminal); scripts, CI jobs and AI agents cannot confirm it, and --confirm-live is no longer enough. Use a sandbox key (vp_sk_test_\u2026) for automation, or run the command yourself in a terminal. Nothing was sent.`;
771
+ exitWithCliError(message, chalk7.red(message));
772
+ }
604
773
  if (opts.confirmLive) {
605
774
  console.warn(
606
- chalk6.yellow(
607
- `Live key in use \u2014 ${operation} ${consequence}. Proceeding because --confirm-live was passed.`
775
+ chalk7.yellow(
776
+ "Note: --confirm-live no longer confirms a live action by itself. A person confirms it at the terminal."
608
777
  )
609
778
  );
610
- return;
611
779
  }
612
- const message = `Refusing to ${operation} with a live key. Pass --confirm-live to acknowledge this is intentional.`;
613
- exitWithCliError(message, chalk6.red(message));
780
+ const lines = [
781
+ chalk7.bold.red(`You are about to ${summary.action} with a LIVE key (${keyFingerprint(key)}).`),
782
+ ...(summary.details ?? []).map(([label, value]) => ` ${`${label}:`.padEnd(17)}${safeDisplay(value)}`),
783
+ chalk7.yellow(summary.consequence ?? "This moves real money and cannot be undone from here.")
784
+ ];
785
+ for (const line of lines) console.log(line);
786
+ const answer = await terminal.ask(`Type "${CONFIRM_WORD}" to proceed, anything else to cancel: `);
787
+ if (answer.trim() !== CONFIRM_WORD) {
788
+ const message = `Cancelled: you did not type "${CONFIRM_WORD}". Nothing was sent.`;
789
+ exitWithCliError(message, chalk7.red(message));
790
+ }
791
+ }
792
+ function retiredConfirmLiveOption() {
793
+ return new Option2(
794
+ "--confirm-live",
795
+ "No longer confirms anything: a live-key action is confirmed by a person at the terminal"
796
+ ).hideHelp();
614
797
  }
615
798
  function warnLiveSecretFlag(key, flag) {
616
799
  if (!isLiveKey2(key)) return;
617
800
  console.warn(
618
- chalk6.yellow(
801
+ chalk7.yellow(
619
802
  `Warning: passing ${flag} as a command argument with a live key exposes it to shell history and process-list (\`ps\`). Consider piping or env-injecting the value instead.`
620
803
  )
621
804
  );
@@ -660,11 +843,6 @@ function buildCreateParams(opts, resolved) {
660
843
  }
661
844
  return params;
662
845
  }
663
- function resolveIdempotencyKey(flag) {
664
- const given = flag?.trim();
665
- if (given) return { key: given, generated: false };
666
- return { key: `cli-${randomUUID()}`, generated: true };
667
- }
668
846
  function buildMITBlock(opts) {
669
847
  const initiator = opts.mitInitiator?.trim().toLowerCase();
670
848
  const reason = opts.mitReason?.trim().toLowerCase();
@@ -695,7 +873,7 @@ function buildMITBlock(opts) {
695
873
  };
696
874
  }
697
875
  function printPaymentIntent(intent) {
698
- const statusColor = intent.status === "succeeded" ? chalk7.green : intent.status === "authorized" ? chalk7.cyan : intent.status === "requires_action" ? chalk7.yellow : intent.status === "failed" || intent.status === "canceled" ? chalk7.red : chalk7.gray;
876
+ const statusColor = intent.status === "succeeded" ? chalk8.green : intent.status === "authorized" ? chalk8.cyan : intent.status === "requires_action" ? chalk8.yellow : intent.status === "failed" || intent.status === "canceled" ? chalk8.red : chalk8.gray;
699
877
  console.log(` ID: ${intent.id}`);
700
878
  console.log(` Status: ${statusColor(intent.status)}`);
701
879
  console.log(
@@ -704,29 +882,25 @@ function printPaymentIntent(intent) {
704
882
  console.log(` Capture: ${intent.captureMethod}`);
705
883
  if (intent.nextAction) {
706
884
  console.log(
707
- ` Next action: ${chalk7.yellow(safeDisplay(intent.nextAction.type))}`
885
+ ` Next action: ${chalk8.yellow(safeDisplay(intent.nextAction.type))}`
708
886
  );
709
887
  if (intent.nextAction.type === "redirect_to_url") {
710
888
  console.log(
711
- ` Redirect: ${chalk7.yellow(safeDisplay(intent.nextAction.redirectToUrl.url))}`
889
+ ` Redirect: ${chalk8.yellow(safeDisplay(intent.nextAction.redirectToUrl.url))}`
712
890
  );
713
891
  }
714
892
  }
715
893
  if (intent.declineCode) {
716
- console.log(` Decline code: ${chalk7.red(safeDisplay(intent.declineCode))}`);
894
+ console.log(` Decline code: ${chalk8.red(safeDisplay(intent.declineCode))}`);
717
895
  }
718
896
  console.log(` Created: ${intent.createdAt}`);
719
897
  }
720
- var CONFIRM_LIVE_DESCRIPTION = "Required to proceed when the loaded key is vp_sk_live_* (this operation is irreversible)";
898
+ var AMOUNT_HELP = "Amount in minor units, a whole number (e.g. 1499 = $14.99)";
721
899
  function paymentIntentsCommand() {
722
900
  const intents = new Command5("payment-intents").description(
723
- "Create, capture, and void payment intents (discrete lifecycle)"
901
+ "Create, read, capture, and void payment intents (discrete lifecycle)"
724
902
  );
725
- intents.command("create").description("Create a new payment intent").requiredOption(
726
- "--amount <cents>",
727
- "Amount in minor units (e.g. 1499 = $14.99)",
728
- (v) => parseInt(v, 10)
729
- ).requiredOption("--currency <code>", "ISO 4217 currency (e.g. USD)").option(
903
+ intents.command("create").description("Create a new payment intent").requiredOption("--amount <cents>", AMOUNT_HELP).requiredOption("--currency <code>", "ISO 4217 currency (e.g. USD)").option(
730
904
  "--capture-method <mode>",
731
905
  "automatic (default) or manual (parks at authorized)"
732
906
  ).option(
@@ -753,33 +927,43 @@ function paymentIntentsCommand() {
753
927
  ).option("--buyer-email <email>", "Buyer email for this charge").option(
754
928
  "--idempotency-key <key>",
755
929
  "Idempotency key for this charge. Reuse the SAME key to retry the same charge safely. Generated (and printed) when omitted"
756
- ).option("--confirm-live", CONFIRM_LIVE_DESCRIPTION).option("--json", "Output raw JSON").action(async (opts) => {
930
+ ).addOption(retiredConfirmLiveOption()).option("--json", "Output raw JSON").action(async (raw) => {
757
931
  const apiKey = requireApiKey();
758
- requireLiveConfirmation(apiKey, opts, "create a payment intent");
759
- const client = makeCheckoutClient();
760
- const spinner = startSpinner("Creating payment intent...");
932
+ const opts = { ...raw, amount: minorUnitsOrExit(raw.amount, "--amount") };
933
+ const captureMethod = opts.captureMethod?.toLowerCase();
934
+ if (captureMethod !== void 0 && captureMethod !== "automatic" && captureMethod !== "manual") {
935
+ const message = `Invalid --capture-method "${opts.captureMethod}". Expected automatic or manual.`;
936
+ exitWithCliError(message, chalk8.red(message));
937
+ }
938
+ let params;
761
939
  try {
762
- const captureMethod = opts.captureMethod?.toLowerCase();
763
- if (captureMethod !== void 0 && captureMethod !== "automatic" && captureMethod !== "manual") {
764
- spinner.stop();
765
- const message = `Invalid --capture-method "${opts.captureMethod}". Expected automatic or manual.`;
766
- exitWithCliError(message, chalk7.red(message));
767
- }
768
940
  const metadata = parseMetadata(opts.metadata);
769
941
  const mit = buildMITBlock(opts);
770
- const params = buildCreateParams(opts, { captureMethod, metadata, mit });
771
- const { key: idempotencyKey, generated } = resolveIdempotencyKey(
772
- opts.idempotencyKey
773
- );
774
- if (generated) {
775
- spinner.stop();
776
- console.error(
777
- chalk7.dim(
778
- `Idempotency key: ${idempotencyKey} (to retry this same charge, re-run with --idempotency-key ${idempotencyKey})`
779
- )
780
- );
781
- spinner.start();
782
- }
942
+ params = buildCreateParams(opts, { captureMethod, metadata, mit });
943
+ } catch (err) {
944
+ handleError(err);
945
+ }
946
+ refuseUnsafeLiveDestination(apiKey);
947
+ const idempotencyKey = idempotencyKeyFor(opts.idempotencyKey, "charge");
948
+ await confirmLiveAction(
949
+ apiKey,
950
+ {
951
+ action: "create a payment intent (charge a payment)",
952
+ details: [
953
+ ...apiHostDetail(),
954
+ ["Amount", formatMinorUnits2(params.amount, params.currency)],
955
+ ["Capture", params.captureMethod ?? "automatic"],
956
+ ...params.paymentMethod ? [["Saved card", params.paymentMethod.id]] : [],
957
+ ...params.buyerId ? [["Buyer", params.buyerId]] : [],
958
+ ...params.sessionId ? [["Session", params.sessionId]] : [],
959
+ ["Idempotency key", idempotencyKey]
960
+ ]
961
+ },
962
+ opts
963
+ );
964
+ const client = makeCheckoutClient();
965
+ const spinner = startSpinner("Creating payment intent...");
966
+ try {
783
967
  const intent = await client.paymentIntents.create(params, {
784
968
  idempotencyKey
785
969
  });
@@ -787,7 +971,25 @@ function paymentIntentsCommand() {
787
971
  if (opts.json) {
788
972
  console.log(JSON.stringify(intent, null, 2));
789
973
  } else {
790
- console.log(chalk7.green("\u2713 Payment intent created"));
974
+ console.log(chalk8.green("\u2713 Payment intent created"));
975
+ printPaymentIntent(intent);
976
+ }
977
+ } catch (err) {
978
+ spinner.stop();
979
+ handleError(err);
980
+ }
981
+ });
982
+ intents.command("get").description(
983
+ "Read a payment intent back \u2014 the way to check a payment whose outcome was unclear before doing anything else"
984
+ ).argument("<id>", "Payment intent ID (vpi_*)").option("--json", "Output raw JSON").action(async (id, opts) => {
985
+ const client = makeCheckoutClient();
986
+ const spinner = startSpinner("Fetching payment intent...");
987
+ try {
988
+ const intent = await client.paymentIntents.retrieve(id);
989
+ spinner.stop();
990
+ if (opts.json) {
991
+ console.log(JSON.stringify(intent, null, 2));
992
+ } else {
791
993
  printPaymentIntent(intent);
792
994
  }
793
995
  } catch (err) {
@@ -797,26 +999,49 @@ function paymentIntentsCommand() {
797
999
  });
798
1000
  intents.command("capture").description("Capture an authorized payment intent").argument("<id>", "Payment intent ID (vpi_*)").option(
799
1001
  "--amount-to-capture <cents>",
800
- "Partial capture amount in minor units (omit for full)",
801
- (v) => parseInt(v, 10)
802
- ).option("--idempotency-key <key>", "Idempotency key for safe retries").option("--confirm-live", CONFIRM_LIVE_DESCRIPTION).option("--json", "Output raw JSON").action(
1002
+ "Partial capture amount in minor units, a whole number (omit for full)"
1003
+ ).option(
1004
+ "--idempotency-key <key>",
1005
+ "Idempotency key. Reuse the SAME key to retry safely. Generated (and printed) when omitted"
1006
+ ).addOption(retiredConfirmLiveOption()).option("--json", "Output raw JSON").action(
803
1007
  async (id, opts) => {
804
1008
  const apiKey = requireApiKey();
805
- requireLiveConfirmation(apiKey, opts, "capture a payment intent");
1009
+ const amountToCapture = optionalMinorUnitsOrExit(
1010
+ opts.amountToCapture,
1011
+ "--amount-to-capture"
1012
+ );
1013
+ refuseUnsafeLiveDestination(apiKey);
1014
+ const idempotencyKey = idempotencyKeyFor(opts.idempotencyKey, "capture");
1015
+ await confirmLiveAction(
1016
+ apiKey,
1017
+ {
1018
+ action: "capture a payment intent (take the held money)",
1019
+ details: [
1020
+ ...apiHostDetail(),
1021
+ ["Payment intent", id],
1022
+ [
1023
+ "Amount",
1024
+ amountToCapture !== void 0 ? `${amountToCapture} in minor units (partial capture)` : "the full authorized amount"
1025
+ ],
1026
+ ["Idempotency key", idempotencyKey]
1027
+ ]
1028
+ },
1029
+ opts
1030
+ );
806
1031
  const client = makeCheckoutClient();
807
1032
  const spinner = startSpinner("Capturing payment intent...");
808
1033
  try {
809
1034
  const intent = await client.paymentIntents.capture(
810
1035
  id,
811
- opts.amountToCapture !== void 0 ? { amountToCapture: opts.amountToCapture } : void 0,
812
- opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : void 0
1036
+ amountToCapture !== void 0 ? { amountToCapture } : void 0,
1037
+ { idempotencyKey }
813
1038
  );
814
1039
  spinner.stop();
815
1040
  if (opts.json) {
816
1041
  console.log(JSON.stringify(intent, null, 2));
817
1042
  } else {
818
- const captureLabel = opts.amountToCapture !== void 0 ? ` (partial: ${formatMinorUnits2(opts.amountToCapture, intent.currency)})` : "";
819
- console.log(chalk7.green(`\u2713 Payment intent captured${captureLabel}`));
1043
+ const captureLabel = amountToCapture !== void 0 ? ` (partial: ${formatMinorUnits2(amountToCapture, intent.currency)})` : "";
1044
+ console.log(chalk8.green(`\u2713 Payment intent captured${captureLabel}`));
820
1045
  printPaymentIntent(intent);
821
1046
  }
822
1047
  } catch (err) {
@@ -825,22 +1050,36 @@ function paymentIntentsCommand() {
825
1050
  }
826
1051
  }
827
1052
  );
828
- intents.command("void").description("Void an authorized (uncaptured) payment intent").argument("<id>", "Payment intent ID (vpi_*)").option("--idempotency-key <key>", "Idempotency key for safe retries").option("--confirm-live", CONFIRM_LIVE_DESCRIPTION).option("--json", "Output raw JSON").action(
1053
+ intents.command("void").description("Void an authorized (uncaptured) payment intent").argument("<id>", "Payment intent ID (vpi_*)").option(
1054
+ "--idempotency-key <key>",
1055
+ "Idempotency key. Reuse the SAME key to retry safely. Generated (and printed) when omitted"
1056
+ ).addOption(retiredConfirmLiveOption()).option("--json", "Output raw JSON").action(
829
1057
  async (id, opts) => {
830
1058
  const apiKey = requireApiKey();
831
- requireLiveConfirmation(apiKey, opts, "void a payment intent");
1059
+ refuseUnsafeLiveDestination(apiKey);
1060
+ const idempotencyKey = idempotencyKeyFor(opts.idempotencyKey, "void");
1061
+ await confirmLiveAction(
1062
+ apiKey,
1063
+ {
1064
+ action: "void a payment intent (release the hold)",
1065
+ details: [
1066
+ ...apiHostDetail(),
1067
+ ["Payment intent", id],
1068
+ ["Idempotency key", idempotencyKey]
1069
+ ],
1070
+ consequence: "A voided payment cannot be captured afterwards; this cannot be undone from here."
1071
+ },
1072
+ opts
1073
+ );
832
1074
  const client = makeCheckoutClient();
833
1075
  const spinner = startSpinner("Voiding payment intent...");
834
1076
  try {
835
- const intent = await client.paymentIntents.void(
836
- id,
837
- opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : void 0
838
- );
1077
+ const intent = await client.paymentIntents.void(id, { idempotencyKey });
839
1078
  spinner.stop();
840
1079
  if (opts.json) {
841
1080
  console.log(JSON.stringify(intent, null, 2));
842
1081
  } else {
843
- console.log(chalk7.green("\u2713 Payment intent voided"));
1082
+ console.log(chalk8.green("\u2713 Payment intent voided"));
844
1083
  printPaymentIntent(intent);
845
1084
  }
846
1085
  } catch (err) {
@@ -854,7 +1093,7 @@ function paymentIntentsCommand() {
854
1093
 
855
1094
  // src/commands/refunds.ts
856
1095
  import { Command as Command6 } from "commander";
857
- import chalk8 from "chalk";
1096
+ import chalk9 from "chalk";
858
1097
  import { formatMinorUnits as formatMinorUnits3 } from "@vonpay/checkout-node";
859
1098
  function present(v) {
860
1099
  return v !== void 0 && v.trim() !== "";
@@ -884,7 +1123,7 @@ function buildRefundParams(opts, metadata) {
884
1123
  return params;
885
1124
  }
886
1125
  function printRefund(refund) {
887
- const statusColor = refund.status === "succeeded" ? chalk8.green : refund.status === "failed" ? chalk8.red : chalk8.yellow;
1126
+ const statusColor = refund.status === "succeeded" ? chalk9.green : refund.status === "failed" ? chalk9.red : chalk9.yellow;
888
1127
  console.log(` ID: ${refund.id}`);
889
1128
  if (refund.transaction !== null) {
890
1129
  console.log(` Transaction: ${refund.transaction}`);
@@ -913,37 +1152,60 @@ function refundsCommand() {
913
1152
  "Transaction ID to refund (vp_tx_*), instead of --payment-intent (prefer --payment-intent whenever the payment has one). Use it for a payment that has no payment intent, to follow the transaction a refund_target_is_duplicate error names, or as the vp_tx_id read from the session when its payment has no payment intent \u2014 only when vp_tx_count is exactly 1, and only after matching it to the order in your own records. A transaction id is also visible to the shopper's browser \u2014 only refund one you looked up from your own order records"
914
1153
  ).option(
915
1154
  "--amount <cents>",
916
- "Refund amount in minor units (omit for full remaining balance)",
917
- (v) => parseInt(v, 10)
1155
+ "Refund amount in minor units, a whole number (omit for full remaining balance)"
918
1156
  ).option("--currency <code>", "ISO 4217 currency code").option("--reason <text>", "Free-form reason (mirrored back on the record)").option(
919
1157
  "--metadata <pair...>",
920
1158
  "Metadata key=value (repeat flag for multiple pairs)"
921
- ).option("--idempotency-key <key>", "Idempotency key for safe retries").option(
922
- "--confirm-live",
923
- "Required to proceed when the loaded key is vp_sk_live_* (this operation is irreversible)"
924
- ).option("--json", "Output raw JSON").action(async (opts) => {
1159
+ ).option(
1160
+ "--idempotency-key <key>",
1161
+ "Idempotency key. Reuse the SAME key to retry safely. Generated (and printed) when omitted"
1162
+ ).addOption(retiredConfirmLiveOption()).option("--json", "Output raw JSON").action(async (raw) => {
925
1163
  try {
926
- resolveRefundTarget(opts);
1164
+ resolveRefundTarget(raw);
927
1165
  } catch (err) {
928
1166
  const message = err.message;
929
- exitWithCliError(message, chalk8.red(`Error: ${message}`));
1167
+ exitWithCliError(message, chalk9.red(`Error: ${message}`));
930
1168
  }
931
1169
  const apiKey = requireApiKey();
932
- requireLiveConfirmation(apiKey, opts, "create a refund");
1170
+ const opts = {
1171
+ ...raw,
1172
+ amount: optionalMinorUnitsOrExit(raw.amount, "--amount")
1173
+ };
1174
+ let params;
1175
+ try {
1176
+ params = buildRefundParams(opts, parseMetadata(opts.metadata));
1177
+ } catch (err) {
1178
+ handleError(err);
1179
+ }
1180
+ const target = resolveRefundTarget(opts);
1181
+ refuseUnsafeLiveDestination(apiKey);
1182
+ const idempotencyKey = idempotencyKeyFor(opts.idempotencyKey, "refund");
1183
+ await confirmLiveAction(
1184
+ apiKey,
1185
+ {
1186
+ action: "create a refund (send money back to the buyer)",
1187
+ details: [
1188
+ ...apiHostDetail(),
1189
+ "transaction" in target ? ["Transaction", target.transaction] : ["Payment intent", target.paymentIntent],
1190
+ [
1191
+ "Amount",
1192
+ params.amount === void 0 ? "the full remaining balance" : params.currency ? formatMinorUnits3(params.amount, params.currency) : `${params.amount} in minor units`
1193
+ ],
1194
+ ["Idempotency key", idempotencyKey]
1195
+ ],
1196
+ consequence: "This pays money back out of the merchant's balance and cannot be reversed once settled."
1197
+ },
1198
+ opts
1199
+ );
933
1200
  const client = makeCheckoutClient();
934
1201
  const spinner = startSpinner("Creating refund...");
935
1202
  try {
936
- const metadata = parseMetadata(opts.metadata);
937
- const params = buildRefundParams(opts, metadata);
938
- const refund = await client.refunds.create(
939
- params,
940
- opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : void 0
941
- );
1203
+ const refund = await client.refunds.create(params, { idempotencyKey });
942
1204
  spinner.stop();
943
1205
  if (opts.json) {
944
1206
  console.log(JSON.stringify(refund, null, 2));
945
1207
  } else {
946
- console.log(chalk8.green("\u2713 Refund created"));
1208
+ console.log(chalk9.green("\u2713 Refund created"));
947
1209
  printRefund(refund);
948
1210
  }
949
1211
  } catch (err) {
@@ -951,14 +1213,77 @@ function refundsCommand() {
951
1213
  handleError(err);
952
1214
  }
953
1215
  });
1216
+ refunds.command("list").description(
1217
+ "List the refunds of ONE payment and its refund totals \u2014 the way to check a refund whose outcome was unclear"
1218
+ ).option("--payment-intent <id>", "The payment's payment intent ID (vpi_*). Use this OR --transaction").option("--transaction <id>", "The payment's transaction ID (vp_tx_*), instead of --payment-intent").option("--limit <n>", "How many refunds to show (default 25, at most 100)").option("--starting-after <cursor>", "The next-page cursor printed by a previous run").option("--json", "Output raw JSON").action(
1219
+ async (opts) => {
1220
+ let target;
1221
+ try {
1222
+ target = resolveRefundTarget(opts);
1223
+ } catch (err) {
1224
+ const message = err.message.replace("A refund target is required", "A payment is required");
1225
+ exitWithCliError(message, chalk9.red(`Error: ${message}`));
1226
+ }
1227
+ let limit;
1228
+ if (opts.limit !== void 0) {
1229
+ const parsed = parseMinorUnits(opts.limit, "--limit");
1230
+ if (!parsed.ok || parsed.value < 1) {
1231
+ const message = `--limit must be a whole number from 1 to 100; got ${JSON.stringify(opts.limit)}.`;
1232
+ exitWithCliError(message, chalk9.red(`Error: ${message}`));
1233
+ }
1234
+ limit = parsed.value;
1235
+ }
1236
+ const client = makeCheckoutClient();
1237
+ const spinner = startSpinner("Listing refunds...");
1238
+ try {
1239
+ const list = await client.refunds.list(target, {
1240
+ ...limit !== void 0 ? { limit } : {},
1241
+ ...opts.startingAfter ? { startingAfter: opts.startingAfter } : {}
1242
+ });
1243
+ spinner.stop();
1244
+ if (opts.json) {
1245
+ console.log(JSON.stringify(list, null, 2));
1246
+ return;
1247
+ }
1248
+ for (const line of refundListLines(list)) console.log(line);
1249
+ } catch (err) {
1250
+ spinner.stop();
1251
+ handleError(err);
1252
+ }
1253
+ }
1254
+ );
954
1255
  return refunds;
955
1256
  }
1257
+ function refundListLines(list) {
1258
+ const currency = list.data[0]?.currency;
1259
+ const money = (n) => currency ? formatMinorUnits3(n, currency) : `${n} in minor units`;
1260
+ const lines = [
1261
+ ` Refunded so far: ${money(list.amountRefunded)} (succeeded + still in progress)`,
1262
+ ` Still refundable: ${money(list.remainingRefundable)}${list.refundable ? "" : " \u2014 this payment cannot be refunded now"}`
1263
+ ];
1264
+ if (list.data.length === 0) {
1265
+ lines.push(" No refunds on this payment.");
1266
+ }
1267
+ for (const r of list.data) {
1268
+ const status = r.status === "succeeded" ? chalk9.green(r.status) : r.status === "failed" ? chalk9.red(r.status) : chalk9.yellow(r.status);
1269
+ lines.push(
1270
+ ` ${r.id} ${status} ${formatMinorUnits3(r.amount, r.currency)}` + (r.createdAt ? ` ${r.createdAt}` : "") + (r.idempotencyKey ? ` key ${safeDisplay(r.idempotencyKey)}` : "")
1271
+ );
1272
+ }
1273
+ if (list.data.some((r) => r.status === "requested")) {
1274
+ lines.push(chalk9.yellow(" A refund marked requested is still in progress and can still fail: do not refund again."));
1275
+ }
1276
+ if (list.hasMore && list.nextCursor) {
1277
+ lines.push(` More: re-run with --starting-after ${safeDisplay(list.nextCursor)}`);
1278
+ }
1279
+ return lines;
1280
+ }
956
1281
 
957
1282
  // src/commands/tokens.ts
958
1283
  import { Command as Command7 } from "commander";
959
- import chalk9 from "chalk";
1284
+ import chalk10 from "chalk";
960
1285
  function printToken(token) {
961
- const statusColor = token.status === "active" ? chalk9.green : token.status === "expired" ? chalk9.yellow : chalk9.red;
1286
+ const statusColor = token.status === "active" ? chalk10.green : token.status === "expired" ? chalk10.yellow : chalk10.red;
962
1287
  console.log(` ID: ${token.id}`);
963
1288
  console.log(` Status: ${statusColor(token.status)}`);
964
1289
  const card = formatTokenCard(token.card);
@@ -980,7 +1305,7 @@ function tokensCommand() {
980
1305
  const apiKey = requireApiKey();
981
1306
  if (!opts.buyerId && !opts.providerReference) {
982
1307
  console.warn(
983
- chalk9.yellow(
1308
+ chalk10.yellow(
984
1309
  "Warning: no --buyer-id or --provider-reference supplied. Providers with an iframe vault (sandbox and live alike) will fail with a 422 validation_error."
985
1310
  )
986
1311
  );
@@ -1006,7 +1331,7 @@ function tokensCommand() {
1006
1331
  if (opts.json) {
1007
1332
  console.log(JSON.stringify(token, null, 2));
1008
1333
  } else {
1009
- console.log(chalk9.green("\u2713 Token created"));
1334
+ console.log(chalk10.green("\u2713 Token created"));
1010
1335
  printToken(token);
1011
1336
  }
1012
1337
  } catch (err) {
@@ -1025,33 +1350,33 @@ function formatTokenCard(card) {
1025
1350
 
1026
1351
  // src/commands/capabilities.ts
1027
1352
  import { Command as Command8 } from "commander";
1028
- import chalk10 from "chalk";
1353
+ import chalk11 from "chalk";
1029
1354
  function fmtBool(v) {
1030
- return v ? chalk10.green("yes") : chalk10.dim("no");
1355
+ return v ? chalk11.green("yes") : chalk11.dim("no");
1031
1356
  }
1032
1357
  function fmtVoidPolicy(v) {
1033
1358
  switch (v) {
1034
1359
  case "supported":
1035
- return chalk10.green(v);
1360
+ return chalk11.green(v);
1036
1361
  case "rerouted_to_refund":
1037
- return chalk10.yellow(v);
1362
+ return chalk11.yellow(v);
1038
1363
  case "not_supported":
1039
- return chalk10.dim(v);
1364
+ return chalk11.dim(v);
1040
1365
  default:
1041
- return chalk10.dim(String(v));
1366
+ return chalk11.dim(String(v));
1042
1367
  }
1043
1368
  }
1044
1369
  function fmtDisputeReporting(v) {
1045
- return v === "tracked" ? chalk10.green(v) : chalk10.yellow(v);
1370
+ return v === "tracked" ? chalk11.green(v) : chalk11.yellow(v);
1046
1371
  }
1047
1372
  function fmtSubscriptionBilling(v) {
1048
- if (v === true) return chalk10.yellow("yes");
1049
- if (v === false) return chalk10.green("no");
1050
- return chalk10.yellow("unknown");
1373
+ if (v === true) return chalk11.yellow("yes");
1374
+ if (v === false) return chalk11.green("no");
1375
+ return chalk11.yellow("unknown");
1051
1376
  }
1052
1377
  function printCapabilities(caps) {
1053
1378
  const ops = caps.supportedOperations;
1054
- console.log(chalk10.bold("Supported operations"));
1379
+ console.log(chalk11.bold("Supported operations"));
1055
1380
  console.log(` auth/capture separation: ${fmtBool(ops.authCaptureSeparation)}`);
1056
1381
  console.log(` partial capture: ${fmtBool(ops.partialCapture)}`);
1057
1382
  console.log(` partial refund: ${fmtBool(ops.partialRefund)}`);
@@ -1066,17 +1391,17 @@ function printCapabilities(caps) {
1066
1391
  console.log();
1067
1392
  if (ops.disputeReporting !== "tracked") {
1068
1393
  console.log(
1069
- chalk10.yellow(
1394
+ chalk11.yellow(
1070
1395
  " \u26A0 Chargebacks on this connection do NOT reach Von Payments.\n A `succeeded` payment is not evidence it was never disputed.\n Monitor and answer disputes in your processor's own dashboard \u2014\n they carry a deadline we cannot see or report."
1071
1396
  )
1072
1397
  );
1073
1398
  console.log();
1074
1399
  }
1075
- console.log(chalk10.bold("Subscription billing"));
1400
+ console.log(chalk11.bold("Subscription billing"));
1076
1401
  console.log(` Von Pay renews for you: ${fmtSubscriptionBilling(caps.subscriptionBilling?.enabled)}`);
1077
1402
  if (caps.subscriptionBilling?.enabled !== false) {
1078
1403
  console.log(
1079
- chalk10.yellow(
1404
+ chalk11.yellow(
1080
1405
  ` \u26A0 Do not run renewals from your own system while this is not "no":
1081
1406
  Von Pay may be charging these customers' renewals itself, and both
1082
1407
  would charge them. Check again at the moment you renew.`
@@ -1084,12 +1409,12 @@ function printCapabilities(caps) {
1084
1409
  );
1085
1410
  }
1086
1411
  console.log();
1087
- console.log(chalk10.bold("Settlement currencies"));
1412
+ console.log(chalk11.bold("Settlement currencies"));
1088
1413
  console.log(
1089
- ` ${caps.settlementCurrencies.length ? caps.settlementCurrencies.join(", ") : chalk10.dim("(none reported)")}`
1414
+ ` ${caps.settlementCurrencies.length ? caps.settlementCurrencies.join(", ") : chalk11.dim("(none reported)")}`
1090
1415
  );
1091
1416
  console.log();
1092
- console.log(chalk10.bold("Rate limits"));
1417
+ console.log(chalk11.bold("Rate limits"));
1093
1418
  console.log(
1094
1419
  ` payment intents / minute: ${caps.rateLimits.paymentIntentsPerMinute}`
1095
1420
  );
@@ -1115,8 +1440,8 @@ function capabilitiesCommand() {
1115
1440
 
1116
1441
  // src/commands/trigger.ts
1117
1442
  import { Command as Command9 } from "commander";
1118
- import { createHmac, randomUUID as randomUUID2 } from "crypto";
1119
- import chalk11 from "chalk";
1443
+ import { createHmac as createHmac2, randomUUID as randomUUID2 } from "crypto";
1444
+ import chalk12 from "chalk";
1120
1445
  import ora2 from "ora";
1121
1446
 
1122
1447
  // src/utils/forward-to.ts
@@ -1158,6 +1483,12 @@ function validateForwardTo(raw, opts) {
1158
1483
  reason: `${flag} scheme must be http or https; got ${url.protocol}`
1159
1484
  };
1160
1485
  }
1486
+ if (url.username !== "" || url.password !== "") {
1487
+ return {
1488
+ ok: false,
1489
+ reason: `${flag} must not contain a user name or password.`
1490
+ };
1491
+ }
1161
1492
  const host = normalizeHost(url.hostname);
1162
1493
  const isLoopback = LOOPBACK_HOSTS.has(host);
1163
1494
  if (!isLoopback && !opts.allowTunnel) {
@@ -1179,6 +1510,93 @@ function truncate2(s) {
1179
1510
  return s.length <= 120 ? s : `${s.slice(0, 117)}...`;
1180
1511
  }
1181
1512
 
1513
+ // src/utils/listen-forward.ts
1514
+ import { createHmac } from "crypto";
1515
+ async function forwardPayload(payload, opts) {
1516
+ const body = JSON.stringify(payload);
1517
+ const t = Math.floor(Date.now() / 1e3);
1518
+ const v1 = createHmac("sha256", opts.signingSecret).update(`${t}.${body}`).digest("hex");
1519
+ const signatureHeader = `t=${t},v1=${v1}`;
1520
+ const timeoutMs = opts.timeoutMs ?? 5e3;
1521
+ const localController = new AbortController();
1522
+ const timeoutId = setTimeout(() => localController.abort(), timeoutMs);
1523
+ const combinedSignal = anySignal([opts.signal, localController.signal]);
1524
+ const start = Date.now();
1525
+ try {
1526
+ const res = await fetch(opts.url.toString(), {
1527
+ method: "POST",
1528
+ headers: {
1529
+ "Content-Type": "application/json",
1530
+ "x-vonpay-signature": signatureHeader,
1531
+ "User-Agent": "vonpay-cli-listen"
1532
+ },
1533
+ body,
1534
+ signal: combinedSignal,
1535
+ // Never follow a redirect: a 307/308 from the local endpoint would make
1536
+ // fetch re-send this body AND the signature header to wherever it points,
1537
+ // including a non-loopback host --forward-to never allowed.
1538
+ redirect: "manual"
1539
+ });
1540
+ try {
1541
+ await res.arrayBuffer();
1542
+ } catch {
1543
+ }
1544
+ if (isRedirect(res)) {
1545
+ return {
1546
+ attempted: true,
1547
+ status: res.status,
1548
+ latencyMs: Date.now() - start,
1549
+ error: `the endpoint answered with a redirect (HTTP ${res.status}); it was not followed and the event was not sent anywhere else`
1550
+ };
1551
+ }
1552
+ return {
1553
+ attempted: true,
1554
+ status: res.status,
1555
+ latencyMs: Date.now() - start,
1556
+ error: null
1557
+ };
1558
+ } catch (err) {
1559
+ if (opts.signal.aborted) {
1560
+ return { attempted: true, status: null, latencyMs: null, error: "aborted" };
1561
+ }
1562
+ if (localController.signal.aborted) {
1563
+ return { attempted: true, status: null, latencyMs: null, error: `timeout after ${timeoutMs}ms` };
1564
+ }
1565
+ const message = err instanceof Error ? err.message : String(err);
1566
+ return { attempted: true, status: null, latencyMs: null, error: message };
1567
+ } finally {
1568
+ clearTimeout(timeoutId);
1569
+ }
1570
+ }
1571
+ function isRedirect(res) {
1572
+ return res.status >= 300 && res.status < 400 || res.type === "opaqueredirect";
1573
+ }
1574
+ function anySignal(signals) {
1575
+ if (typeof AbortSignal.any === "function") {
1576
+ return AbortSignal.any(signals);
1577
+ }
1578
+ const controller = new AbortController();
1579
+ const tracked = [];
1580
+ const onAbort = () => {
1581
+ for (const sig of tracked) {
1582
+ sig.removeEventListener("abort", onAbort);
1583
+ }
1584
+ controller.abort();
1585
+ };
1586
+ for (const signal of signals) {
1587
+ if (signal.aborted) {
1588
+ controller.abort();
1589
+ for (const sig of tracked) {
1590
+ sig.removeEventListener("abort", onAbort);
1591
+ }
1592
+ return controller.signal;
1593
+ }
1594
+ signal.addEventListener("abort", onAbort);
1595
+ tracked.push(signal);
1596
+ }
1597
+ return controller.signal;
1598
+ }
1599
+
1182
1600
  // src/commands/trigger.ts
1183
1601
  var SUPPORTED_EVENTS = [
1184
1602
  "charge.succeeded",
@@ -1200,7 +1618,7 @@ function warnIfUnsubscribable(event, write = (m) => console.warn(m)) {
1200
1618
  if (!UNSUBSCRIBABLE_EVENTS.includes(event)) return false;
1201
1619
  const suggestion = event === "session.succeeded" ? "charge.succeeded" : "charge.failed";
1202
1620
  write(
1203
- chalk11.yellow(
1621
+ chalk12.yellow(
1204
1622
  `\u26A0 ${event} is emitted internally but is NOT subscribable.
1205
1623
  The subscription API accepts the key, stores nothing and returns success, so a
1206
1624
  real endpoint receives this event NEVER \u2014 a handler tested only against it is
@@ -1276,8 +1694,10 @@ function buildEventData(event, ids, amount, currency) {
1276
1694
  refund_amount: amount,
1277
1695
  amount_refunded_total: 0,
1278
1696
  reason: "customer_request",
1279
- // LOCKED two-value refund vocabulary — `refund_declined` (terminal) or
1280
- // `refund_unresolved` (no provider response, true state unknown).
1697
+ // An OPEN refund vocabulary (the SDK's RefundFailedData.reason_code):
1698
+ // `refund_declined` (the provider refused, retrying unchanged fails the
1699
+ // same way) and `refund_unresolved` (no provider response, true state
1700
+ // unknown) are the values sent so far. This sample uses the first.
1281
1701
  reason_code: "refund_declined",
1282
1702
  is_partial: false,
1283
1703
  original_charge_amount: amount,
@@ -1305,16 +1725,13 @@ function buildPayload(event, opts) {
1305
1725
  };
1306
1726
  }
1307
1727
  function triggerCommand() {
1308
- return new Command9("trigger").description("Send a test webhook event to a local URL").argument("<event>", `Event type (${SUPPORTED_EVENTS.join(", ")})`).requiredOption("--url <url>", "Local webhook URL (e.g. http://localhost:3000/webhooks)").option("--session-id <id>", "Custom session ID").option("--amount <cents>", "Amount in minor units", (v) => parseInt(v, 10)).option("--currency <code>", "Currency code").option(
1728
+ return new Command9("trigger").description("Send a test webhook event to a local URL").argument("<event>", `Event type (${SUPPORTED_EVENTS.join(", ")})`).requiredOption("--url <url>", "Local webhook URL (e.g. http://localhost:3000/webhooks)").option("--session-id <id>", "Custom session ID").option("--amount <cents>", "Amount in minor units, a whole number").option("--currency <code>", "Currency code").option(
1309
1729
  "--url-tunnel",
1310
1730
  "Allow a non-loopback --url (ngrok / Cloudflare Tunnel). Without it, --url must be this machine"
1311
- ).option(
1312
- "--confirm-live",
1313
- "Required when the loaded key is vp_sk_live_*: the event is signed with that LIVE key and sent to --url"
1314
- ).action(async (event, opts) => {
1731
+ ).addOption(retiredConfirmLiveOption()).action(async (event, opts) => {
1315
1732
  if (!SUPPORTED_EVENTS.includes(event)) {
1316
1733
  console.error(
1317
- chalk11.red(
1734
+ chalk12.red(
1318
1735
  `Unknown event: ${event}
1319
1736
  Supported: ${SUPPORTED_EVENTS.join(", ")}`
1320
1737
  )
@@ -1328,21 +1745,31 @@ Supported: ${SUPPORTED_EVENTS.join(", ")}`
1328
1745
  tunnelFlag: "--url-tunnel"
1329
1746
  });
1330
1747
  if (!destination.ok) {
1331
- console.error(chalk11.red(destination.reason));
1748
+ console.error(chalk12.red(destination.reason));
1332
1749
  process.exit(1);
1333
1750
  }
1334
- if (destination.warning) console.warn(chalk11.yellow(destination.warning));
1751
+ if (destination.warning) console.warn(chalk12.yellow(destination.warning));
1752
+ const amount = optionalMinorUnitsOrExit(
1753
+ opts.amount === void 0 ? void 0 : String(opts.amount),
1754
+ "--amount"
1755
+ );
1335
1756
  const apiKey = requireApiKey();
1336
- requireLiveConfirmation(
1757
+ await confirmLiveAction(
1337
1758
  apiKey,
1338
- opts,
1339
- "trigger a test webhook event",
1340
- `signs a synthetic event with your LIVE key and sends it to ${safeDisplay(String(opts.url))}`
1759
+ {
1760
+ action: "trigger a test webhook event",
1761
+ details: [
1762
+ ["Event", event],
1763
+ ["Sent to", String(opts.url)]
1764
+ ],
1765
+ consequence: "The event is signed with your LIVE key: a receiver that verifies with that key will accept it as a genuine event."
1766
+ },
1767
+ opts
1341
1768
  );
1342
- const payload = buildPayload(event, opts);
1769
+ const payload = buildPayload(event, { ...opts, amount });
1343
1770
  const body = JSON.stringify(payload);
1344
1771
  const t = Math.floor(Date.now() / 1e3);
1345
- const v1 = createHmac("sha256", apiKey).update(`${t}.${body}`).digest("hex");
1772
+ const v1 = createHmac2("sha256", apiKey).update(`${t}.${body}`).digest("hex");
1346
1773
  const signatureHeader = `t=${t},v1=${v1}`;
1347
1774
  const spinner = ora2(`Sending ${event} to ${opts.url}...`).start();
1348
1775
  try {
@@ -1354,42 +1781,53 @@ Supported: ${SUPPORTED_EVENTS.join(", ")}`
1354
1781
  "User-Agent": "VonPay-Webhook/1.0 (CLI trigger)"
1355
1782
  },
1356
1783
  body,
1357
- signal: AbortSignal.timeout(1e4)
1784
+ signal: AbortSignal.timeout(1e4),
1785
+ // Never follow a redirect: a 307/308 would re-send this signed body
1786
+ // to wherever it points, including a host --url never allowed.
1787
+ redirect: "manual"
1358
1788
  });
1359
1789
  spinner.stop();
1790
+ if (isRedirect(response)) {
1791
+ console.error(
1792
+ chalk12.red(
1793
+ `\u2717 ${event} not delivered: ${safeDisplay(String(opts.url))} answered with a redirect (HTTP ${response.status}). It was not followed, and the signed event was not sent anywhere else. Point --url at the endpoint itself.`
1794
+ )
1795
+ );
1796
+ process.exit(1);
1797
+ }
1360
1798
  if (response.ok) {
1361
- console.log(chalk11.green(`\u2713 ${event} delivered \u2192 ${response.status}`));
1799
+ console.log(chalk12.green(`\u2713 ${event} delivered \u2192 ${response.status}`));
1362
1800
  } else {
1363
1801
  console.log(
1364
- chalk11.yellow(
1802
+ chalk12.yellow(
1365
1803
  `\u26A0 ${event} delivered but got ${response.status} response`
1366
1804
  )
1367
1805
  );
1368
1806
  }
1369
- console.log(chalk11.dim(` Event: ${payload.id} (${payload.type})`));
1807
+ console.log(chalk12.dim(` Event: ${payload.id} (${payload.type})`));
1370
1808
  console.log(
1371
- chalk11.dim(
1809
+ chalk12.dim(
1372
1810
  ` Amount: ${payload.data.amount} ${payload.data.currency}`
1373
1811
  )
1374
1812
  );
1375
1813
  console.log(
1376
- chalk11.dim(
1814
+ chalk12.dim(
1377
1815
  `
1378
1816
  Verify with: vonpay.webhooks.constructEvent(rawBody, signatureHeader, secret)`
1379
1817
  )
1380
1818
  );
1381
1819
  console.log(
1382
- chalk11.dim(
1820
+ chalk12.dim(
1383
1821
  ` (local dev: 'secret' is your API key here \u2014 production webhooks are signed with your endpoint's whsec_\u2026 secret, NOT your API key)`
1384
1822
  )
1385
1823
  );
1386
1824
  } catch (err) {
1387
1825
  spinner.stop();
1388
1826
  if (err instanceof Error && err.name === "TimeoutError") {
1389
- console.error(chalk11.red(`Timeout: ${opts.url} did not respond within 10s`));
1827
+ console.error(chalk12.red(`Timeout: ${opts.url} did not respond within 10s`));
1390
1828
  } else {
1391
1829
  console.error(
1392
- chalk11.red(
1830
+ chalk12.red(
1393
1831
  `Failed to deliver: ${err instanceof Error ? err.message : err}`
1394
1832
  )
1395
1833
  );
@@ -1401,7 +1839,7 @@ Supported: ${SUPPORTED_EVENTS.join(", ")}`
1401
1839
 
1402
1840
  // src/commands/listen.ts
1403
1841
  import { Command as Command10 } from "commander";
1404
- import { VonPayCheckout as VonPayCheckout2, isLiveKey as isLiveKey3 } from "@vonpay/checkout-node";
1842
+ import { VonPayCheckout as VonPayCheckout2 } from "@vonpay/checkout-node";
1405
1843
 
1406
1844
  // ../sse-parser/dist/index.js
1407
1845
  var SseParser = class {
@@ -1492,6 +1930,9 @@ var SseParser = class {
1492
1930
  // src/utils/sse-consumer.ts
1493
1931
  var MAX_PENDING_CHARS = 1048576;
1494
1932
  var MAX_EVENT_ID_LENGTH = 256;
1933
+ var SERVER_RECONNECT_FLOOR_MS = 250;
1934
+ var SERVER_RECONNECT_WINDOW_MS = 5e3;
1935
+ var SERVER_RECONNECT_MAX_IN_WINDOW = 3;
1495
1936
  function resumeId(lastId) {
1496
1937
  return /^[\x20-\x7e]+$/.test(lastId) ? lastId : void 0;
1497
1938
  }
@@ -1505,6 +1946,8 @@ async function* consumeSse(opts) {
1505
1946
  );
1506
1947
  let backoffMs = initialBackoffMs;
1507
1948
  const onLifecycle = opts.onLifecycle ?? noop;
1949
+ let serverReconnectTimes = [];
1950
+ let holdBackoff = false;
1508
1951
  while (!opts.signal.aborted) {
1509
1952
  const headers = {
1510
1953
  Accept: "text/event-stream",
@@ -1558,7 +2001,8 @@ async function* consumeSse(opts) {
1558
2001
  continue;
1559
2002
  }
1560
2003
  onLifecycle({ kind: "connected" });
1561
- backoffMs = initialBackoffMs;
2004
+ const connectedAt = Date.now();
2005
+ if (!holdBackoff) backoffMs = initialBackoffMs;
1562
2006
  parser.reset();
1563
2007
  const reader = response.body.getReader();
1564
2008
  const decoder = new TextDecoder("utf-8");
@@ -1575,11 +2019,13 @@ async function* consumeSse(opts) {
1575
2019
  }
1576
2020
  for (const ev of pending.splice(0)) {
1577
2021
  if (ev.type === "timeout") {
1578
- yield ev;
1579
2022
  serverInitiatedReconnect = true;
1580
2023
  } else {
1581
- yield ev;
2024
+ serverReconnectTimes = [];
2025
+ holdBackoff = false;
2026
+ backoffMs = initialBackoffMs;
1582
2027
  }
2028
+ yield ev;
1583
2029
  }
1584
2030
  if (feedError !== void 0) throw feedError;
1585
2031
  }
@@ -1595,6 +2041,27 @@ async function* consumeSse(opts) {
1595
2041
  }
1596
2042
  if (opts.signal.aborted) return;
1597
2043
  if (serverInitiatedReconnect) {
2044
+ const now = Date.now();
2045
+ if (now - connectedAt >= SERVER_RECONNECT_WINDOW_MS) {
2046
+ serverReconnectTimes = [];
2047
+ holdBackoff = false;
2048
+ backoffMs = initialBackoffMs;
2049
+ } else {
2050
+ serverReconnectTimes = serverReconnectTimes.filter((t) => now - t < SERVER_RECONNECT_WINDOW_MS);
2051
+ serverReconnectTimes.push(now);
2052
+ }
2053
+ if (holdBackoff || serverReconnectTimes.length > SERVER_RECONNECT_MAX_IN_WINDOW) {
2054
+ holdBackoff = true;
2055
+ onLifecycle({
2056
+ kind: "disconnected",
2057
+ reason: "the server keeps asking to reconnect; backing off",
2058
+ willRetryInMs: backoffMs
2059
+ });
2060
+ await sleep(backoffMs, opts.signal);
2061
+ backoffMs = Math.min(backoffMs * 2, maxBackoffMs);
2062
+ continue;
2063
+ }
2064
+ await sleep(SERVER_RECONNECT_FLOOR_MS, opts.signal);
1598
2065
  continue;
1599
2066
  }
1600
2067
  onLifecycle({
@@ -1645,24 +2112,24 @@ function noop() {
1645
2112
  }
1646
2113
 
1647
2114
  // src/utils/listen-output.ts
1648
- import chalk12 from "chalk";
2115
+ import chalk13 from "chalk";
1649
2116
  function formatAttemptHuman(payload, forward) {
1650
2117
  const ts = formatTimestamp(payload.timestamp);
1651
- const eventType = chalk12.cyan(payload.event_type.padEnd(24));
1652
- const eventId = chalk12.gray(payload.event_id.padEnd(28));
1653
- const attempt = chalk12.gray(`attempt ${payload.attempt}`);
1654
- const arrow = chalk12.gray("\u2192");
2118
+ const eventType = chalk13.cyan(payload.event_type.padEnd(24));
2119
+ const eventId = chalk13.gray(payload.event_id.padEnd(28));
2120
+ const attempt = chalk13.gray(`attempt ${payload.attempt}`);
2121
+ const arrow = chalk13.gray("\u2192");
1655
2122
  const upstream = formatUpstream(payload);
1656
- const latency = chalk12.gray(`${payload.latency_ms}ms`);
1657
- const liveBadge = payload.livemode ? chalk12.red(" [LIVE]") : "";
2123
+ const latency = chalk13.gray(`${payload.latency_ms}ms`);
2124
+ const liveBadge = payload.livemode ? chalk13.red(" [LIVE]") : "";
1658
2125
  let line = `[${ts}] ${eventType} ${eventId} ${attempt} ${arrow} ${upstream} ${latency}${liveBadge}`;
1659
2126
  if (forward && forward.attempted) {
1660
- const fwdArrow = chalk12.gray("\u2192 local");
2127
+ const fwdArrow = chalk13.gray("\u2192 local");
1661
2128
  if (forward.error) {
1662
- line += ` ${fwdArrow} ${chalk12.red(forward.error)}`;
2129
+ line += ` ${fwdArrow} ${chalk13.red(forward.error)}`;
1663
2130
  } else if (forward.status !== null) {
1664
2131
  const fwdStatus = formatStatus(forward.status);
1665
- const fwdLatency = chalk12.gray(`${forward.latencyMs}ms`);
2132
+ const fwdLatency = chalk13.gray(`${forward.latencyMs}ms`);
1666
2133
  line += ` ${fwdArrow} ${fwdStatus} ${fwdLatency}`;
1667
2134
  }
1668
2135
  }
@@ -1691,21 +2158,21 @@ function formatAttemptJson(payload, forward) {
1691
2158
  return JSON.stringify(body);
1692
2159
  }
1693
2160
  function formatLifecycleHuman(message, kind) {
1694
- const tag = kind === "info" ? chalk12.cyan("[vonpay]") : kind === "warn" ? chalk12.yellow("[vonpay]") : chalk12.red("[vonpay]");
2161
+ const tag = kind === "info" ? chalk13.cyan("[vonpay]") : kind === "warn" ? chalk13.yellow("[vonpay]") : chalk13.red("[vonpay]");
1695
2162
  return `${tag} ${message}`;
1696
2163
  }
1697
2164
  function formatLifecycleJson(kind, extra = {}) {
1698
2165
  return JSON.stringify({ type: `cli.${kind}`, ...extra, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
1699
2166
  }
1700
2167
  function formatStatus(status) {
1701
- if (status >= 200 && status < 300) return chalk12.green(String(status));
1702
- if (status >= 400 && status < 500) return chalk12.yellow(String(status));
1703
- if (status >= 500) return chalk12.red(String(status));
1704
- return chalk12.gray(String(status));
2168
+ if (status >= 200 && status < 300) return chalk13.green(String(status));
2169
+ if (status >= 400 && status < 500) return chalk13.yellow(String(status));
2170
+ if (status >= 500) return chalk13.red(String(status));
2171
+ return chalk13.gray(String(status));
1705
2172
  }
1706
2173
  function formatUpstream(payload) {
1707
2174
  if (payload.response_status === null) {
1708
- return chalk12.red("timeout");
2175
+ return chalk13.red("timeout");
1709
2176
  }
1710
2177
  return formatStatus(payload.response_status);
1711
2178
  }
@@ -1718,78 +2185,6 @@ function formatTimestamp(iso) {
1718
2185
  return `${h}:${m}:${s}`;
1719
2186
  }
1720
2187
 
1721
- // src/utils/listen-forward.ts
1722
- import { createHmac as createHmac2 } from "crypto";
1723
- async function forwardPayload(payload, opts) {
1724
- const body = JSON.stringify(payload);
1725
- const t = Math.floor(Date.now() / 1e3);
1726
- const v1 = createHmac2("sha256", opts.signingSecret).update(`${t}.${body}`).digest("hex");
1727
- const signatureHeader = `t=${t},v1=${v1}`;
1728
- const timeoutMs = opts.timeoutMs ?? 5e3;
1729
- const localController = new AbortController();
1730
- const timeoutId = setTimeout(() => localController.abort(), timeoutMs);
1731
- const combinedSignal = anySignal([opts.signal, localController.signal]);
1732
- const start = Date.now();
1733
- try {
1734
- const res = await fetch(opts.url.toString(), {
1735
- method: "POST",
1736
- headers: {
1737
- "Content-Type": "application/json",
1738
- "x-vonpay-signature": signatureHeader,
1739
- "User-Agent": "vonpay-cli-listen"
1740
- },
1741
- body,
1742
- signal: combinedSignal
1743
- });
1744
- try {
1745
- await res.arrayBuffer();
1746
- } catch {
1747
- }
1748
- return {
1749
- attempted: true,
1750
- status: res.status,
1751
- latencyMs: Date.now() - start,
1752
- error: null
1753
- };
1754
- } catch (err) {
1755
- if (opts.signal.aborted) {
1756
- return { attempted: true, status: null, latencyMs: null, error: "aborted" };
1757
- }
1758
- if (localController.signal.aborted) {
1759
- return { attempted: true, status: null, latencyMs: null, error: `timeout after ${timeoutMs}ms` };
1760
- }
1761
- const message = err instanceof Error ? err.message : String(err);
1762
- return { attempted: true, status: null, latencyMs: null, error: message };
1763
- } finally {
1764
- clearTimeout(timeoutId);
1765
- }
1766
- }
1767
- function anySignal(signals) {
1768
- if (typeof AbortSignal.any === "function") {
1769
- return AbortSignal.any(signals);
1770
- }
1771
- const controller = new AbortController();
1772
- const tracked = [];
1773
- const onAbort = () => {
1774
- for (const sig of tracked) {
1775
- sig.removeEventListener("abort", onAbort);
1776
- }
1777
- controller.abort();
1778
- };
1779
- for (const signal of signals) {
1780
- if (signal.aborted) {
1781
- controller.abort();
1782
- for (const sig of tracked) {
1783
- sig.removeEventListener("abort", onAbort);
1784
- }
1785
- return controller.signal;
1786
- }
1787
- signal.addEventListener("abort", onAbort);
1788
- tracked.push(signal);
1789
- }
1790
- return controller.signal;
1791
- }
1792
-
1793
2188
  // src/commands/listen.ts
1794
2189
  function listenCommand() {
1795
2190
  return new Command10("listen").description(
@@ -1804,22 +2199,15 @@ function listenCommand() {
1804
2199
  "--events <type>",
1805
2200
  "Only stream attempts for the given event type. Repeat the flag for multiple types.",
1806
2201
  (value, prev) => prev ? [...prev, value] : [value]
1807
- ).option("--json", "Emit one JSON object per line on stdout instead of the colored transcript").option("--api-key <key>", "Override the API key from env / config").option(
1808
- "--confirm-live",
1809
- "Acknowledge that the listen stream may surface production webhook attempts (required for vp_sk_live_* keys)"
1810
- ).action(async (opts) => {
2202
+ ).option("--json", "Emit one JSON object per line on stdout instead of the colored transcript").option("--api-key <key>", "Override the API key from env / config").addOption(retiredConfirmLiveOption()).action(async (opts) => {
1811
2203
  const apiKey = opts.apiKey ?? requireApiKey();
2204
+ if (opts.apiKey !== void 0) warnLiveSecretFlag(opts.apiKey, "--api-key");
1812
2205
  const refuse = (message) => {
1813
2206
  if (jsonErrorsEnabled()) exitWithCliError(message, message);
1814
2207
  process.stderr.write(formatLifecycleHuman(message, "error") + "\n");
1815
2208
  return process.exit(1);
1816
2209
  };
1817
- if (isLiveKey3(apiKey) && !opts.confirmLive) {
1818
- refuse(
1819
- "Refusing to start listen with a live key (vp_sk_live_*). Re-run with --confirm-live to acknowledge that production webhook attempts will stream to this terminal."
1820
- );
1821
- }
1822
- if (!apiKey.startsWith("vp_sk_test_") && !isLiveKey3(apiKey)) {
2210
+ if (secretKeyMode(apiKey) === null) {
1823
2211
  refuse(
1824
2212
  "Listen requires a secret key (vp_sk_*). Publishable keys are rejected by the server."
1825
2213
  );
@@ -1834,15 +2222,36 @@ function listenCommand() {
1834
2222
  forwardUrl = verdict.url;
1835
2223
  forwardWarning = verdict.warning;
1836
2224
  }
1837
- const baseUrl = (getBaseUrl() ?? "https://checkout.vonpay.com").replace(
2225
+ const configuredBaseUrl = getBaseUrl();
2226
+ const destinationProblem = keyDestinationProblem(apiKey, configuredBaseUrl);
2227
+ if (destinationProblem) refuse(destinationProblem);
2228
+ const baseUrl = (configuredBaseUrl ?? "https://checkout.vonpay.com").replace(
1838
2229
  /\/+$/,
1839
2230
  ""
1840
2231
  );
2232
+ await confirmLiveAction(
2233
+ apiKey,
2234
+ {
2235
+ action: "stream live webhook delivery attempts",
2236
+ details: [
2237
+ ["Stream from", baseUrl],
2238
+ ...forwardUrl ? [["Forward to", forwardUrl.toString()]] : []
2239
+ ],
2240
+ consequence: forwardUrl ? "Live payment events, with buyer and order details, will be shown here, and each one is signed with your LIVE key and sent to the forward URL." : "Live payment events, with buyer and order details, will be shown here."
2241
+ },
2242
+ opts
2243
+ );
1841
2244
  const sseUrl = new URL(`${baseUrl}/v1/webhook_listen`);
1842
2245
  if (opts.events && opts.events.length > 0) {
1843
2246
  sseUrl.searchParams.set("event_types", opts.events.join(","));
1844
2247
  }
1845
- const sdk = forwardUrl ? new VonPayCheckout2({ apiKey, baseUrl, appInfo: CLI_APP_INFO, errorReporter: SILENT_ERROR_REPORTER }) : null;
2248
+ const sdk = forwardUrl ? new VonPayCheckout2({
2249
+ apiKey,
2250
+ baseUrl,
2251
+ allowCustomBaseUrl: allowCustomBaseUrl(),
2252
+ appInfo: CLI_APP_INFO,
2253
+ errorReporter: SILENT_ERROR_REPORTER
2254
+ }) : null;
1846
2255
  const controller = new AbortController();
1847
2256
  let eventCount = 0;
1848
2257
  const startedAt = Date.now();
@@ -1909,7 +2318,7 @@ function listenCommand() {
1909
2318
  onLifecycle({
1910
2319
  kind: "disconnected",
1911
2320
  reason: "server-ceiling (1h cap; auto-reconnecting)",
1912
- willRetryInMs: 0
2321
+ willRetryInMs: SERVER_RECONNECT_FLOOR_MS
1913
2322
  });
1914
2323
  continue;
1915
2324
  }
@@ -2012,7 +2421,7 @@ function lifecycleSeverity(kind) {
2012
2421
 
2013
2422
  // src/commands/health.ts
2014
2423
  import { Command as Command11 } from "commander";
2015
- import chalk13 from "chalk";
2424
+ import chalk14 from "chalk";
2016
2425
  import { VonPayError as VonPayError2 } from "@vonpay/checkout-node";
2017
2426
  function healthCommand() {
2018
2427
  return new Command11("health").description("Check checkout API health").option("--json", "Output raw JSON").action(async (opts) => {
@@ -2024,7 +2433,7 @@ function healthCommand() {
2024
2433
  if (opts.json) {
2025
2434
  console.log(JSON.stringify(health, null, 2));
2026
2435
  } else {
2027
- const statusColor = health.status === "ok" ? chalk13.green : health.status === "degraded" ? chalk13.yellow : chalk13.red;
2436
+ const statusColor = health.status === "ok" ? chalk14.green : health.status === "degraded" ? chalk14.yellow : chalk14.red;
2028
2437
  console.log(` Status: ${statusColor(health.status)}`);
2029
2438
  console.log(` Latency: ${health.latencyMs}ms`);
2030
2439
  if (health.version) {
@@ -2035,11 +2444,11 @@ function healthCommand() {
2035
2444
  spinner.stop();
2036
2445
  if (jsonErrorsEnabled()) handleError(err);
2037
2446
  if (err instanceof VonPayError2) {
2038
- console.error(chalk13.red(`API error: ${scrubSecrets(err.message)}`));
2039
- console.error(chalk13.dim(` Code: ${err.code}`));
2447
+ console.error(chalk14.red(`API error: ${scrubSecrets(err.message)}`));
2448
+ console.error(chalk14.dim(` Code: ${err.code}`));
2040
2449
  } else {
2041
2450
  console.error(
2042
- chalk13.red(
2451
+ chalk14.red(
2043
2452
  `Cannot reach API: ${scrubSecrets(err instanceof Error ? err.message : String(err))}`
2044
2453
  )
2045
2454
  );
@@ -2051,7 +2460,7 @@ function healthCommand() {
2051
2460
 
2052
2461
  // src/commands/doctor.ts
2053
2462
  import { Command as Command12 } from "commander";
2054
- import chalk14 from "chalk";
2463
+ import chalk15 from "chalk";
2055
2464
  import ora3 from "ora";
2056
2465
  import { homedir as homedir2 } from "os";
2057
2466
  import { existsSync as existsSync3 } from "fs";
@@ -2083,14 +2492,11 @@ function maskPrefix(key) {
2083
2492
  }
2084
2493
  function keyMode(key) {
2085
2494
  if (!key) return void 0;
2086
- if (!/^vp_[ps]k_/.test(key)) return void 0;
2087
- if (key.includes("_test_")) return "test";
2088
- if (key.includes("_live_")) return "live";
2089
- return void 0;
2495
+ return secretKeyMode(key) ?? "not_a_secret_key";
2090
2496
  }
2091
2497
  async function gather() {
2092
2498
  const config = loadConfig();
2093
- const apiKey = process.env.VON_PAY_SECRET_KEY ?? config.checkout?.apiKey;
2499
+ const apiKey = process.env.VON_PAY_SECRET_KEY || config.checkout?.apiKey;
2094
2500
  const baseUrl = process.env.VONPAY_BASE_URL ?? "https://checkout.vonpay.com";
2095
2501
  const envVarsPresent = RELEVANT_ENV_VARS.filter(
2096
2502
  (n) => process.env[n] && process.env[n] !== ""
@@ -2122,8 +2528,14 @@ async function gather() {
2122
2528
  "No API key found. Set VON_PAY_SECRET_KEY in env, or run `vonpay checkout login` to save one. Without a key the health probe still runs but sessions.validate is skipped."
2123
2529
  );
2124
2530
  }
2531
+ const shapeProblem = keyDestinationProblem("", process.env.VONPAY_BASE_URL);
2532
+ if (shapeProblem) {
2533
+ report.health.baseUrl = "(not shown: the base URL was refused)";
2534
+ report.health.error = shapeProblem;
2535
+ report.hints.push(`${shapeProblem} No probe was run.`);
2536
+ }
2125
2537
  const start = Date.now();
2126
- try {
2538
+ if (!shapeProblem) try {
2127
2539
  const controller = new AbortController();
2128
2540
  const timer = setTimeout(() => controller.abort(), 1e4);
2129
2541
  const res = await fetch(`${baseUrl}/api/health`, {
@@ -2155,11 +2567,27 @@ async function gather() {
2155
2567
  "Health probe could not reach the API. Check network / DNS / firewall \u2014 vonpay-checkout requires outbound HTTPS to checkout.vonpay.com."
2156
2568
  );
2157
2569
  }
2158
- if (apiKey && report.health.reachable) {
2570
+ let mayProbe = Boolean(apiKey) && !shapeProblem;
2571
+ if (apiKey && report.apiKeyMode === "not_a_secret_key") {
2572
+ mayProbe = false;
2573
+ report.hints.push(`${NOT_A_SECRET_KEY_MESSAGE} The sessions.validate probe was not run.`);
2574
+ }
2575
+ const destinationProblem = apiKey && mayProbe ? keyDestinationProblem(apiKey, process.env.VONPAY_BASE_URL) : null;
2576
+ if (destinationProblem) {
2577
+ mayProbe = false;
2578
+ report.hints.push(`${destinationProblem} The sessions.validate probe was not run.`);
2579
+ }
2580
+ if (config.checkout?.apiKey && report.configFile.keyType === "live") {
2581
+ report.hints.push(
2582
+ "A LIVE key is saved in plaintext in ~/.vonpay/config.json. Live keys are no longer stored: run `vonpay checkout logout` to delete it, and supply it through VON_PAY_SECRET_KEY for the session instead."
2583
+ );
2584
+ }
2585
+ if (apiKey && mayProbe && report.health.reachable) {
2159
2586
  try {
2160
2587
  const probeClient = new VonPayCheckout3({
2161
2588
  apiKey,
2162
2589
  baseUrl,
2590
+ allowCustomBaseUrl: allowCustomBaseUrl(),
2163
2591
  timeout: 1e4,
2164
2592
  maxRetries: 0,
2165
2593
  errorReporter: () => {
@@ -2216,47 +2644,47 @@ async function gather() {
2216
2644
  }
2217
2645
  function renderHuman(r) {
2218
2646
  const lines = [];
2219
- lines.push(chalk14.bold.blue("vonpay doctor \u2014 diagnostic report"));
2220
- lines.push(chalk14.dim(`generated ${r.generatedAt}`));
2647
+ lines.push(chalk15.bold.blue("vonpay doctor \u2014 diagnostic report"));
2648
+ lines.push(chalk15.dim(`generated ${r.generatedAt}`));
2221
2649
  lines.push("");
2222
- lines.push(chalk14.bold("Runtime"));
2650
+ lines.push(chalk15.bold("Runtime"));
2223
2651
  lines.push(` CLI: ${r.cli.version}`);
2224
2652
  lines.push(` Node: ${r.runtime.node}`);
2225
2653
  lines.push(` Platform: ${r.runtime.platform} (${r.runtime.arch})`);
2226
2654
  lines.push(` CWD: ${r.runtime.cwd}`);
2227
2655
  lines.push("");
2228
- lines.push(chalk14.bold("API key"));
2656
+ lines.push(chalk15.bold("API key"));
2229
2657
  if (r.apiKeyPrefix) {
2230
2658
  lines.push(` Prefix: ${r.apiKeyPrefix}`);
2231
2659
  lines.push(` Mode: ${r.apiKeyMode}`);
2232
2660
  } else {
2233
- lines.push(chalk14.dim(" (no key found in env or ~/.vonpay/config.json)"));
2661
+ lines.push(chalk15.dim(" (no key found in env or ~/.vonpay/config.json)"));
2234
2662
  }
2235
2663
  lines.push("");
2236
- lines.push(chalk14.bold("Env vars present (names only)"));
2664
+ lines.push(chalk15.bold("Env vars present (names only)"));
2237
2665
  if (r.envVarsPresent.length === 0) {
2238
- lines.push(chalk14.dim(" (none of the expected vonpay env vars are set)"));
2666
+ lines.push(chalk15.dim(" (none of the expected vonpay env vars are set)"));
2239
2667
  } else {
2240
2668
  for (const n of r.envVarsPresent) lines.push(` \u2713 ${n}`);
2241
2669
  }
2242
2670
  lines.push("");
2243
- lines.push(chalk14.bold("Health probe"));
2671
+ lines.push(chalk15.bold("Health probe"));
2244
2672
  if (r.health.reachable) {
2245
- const statusColor = r.health.status === "ok" ? chalk14.green : r.health.status === "degraded" ? chalk14.yellow : chalk14.red;
2246
- lines.push(` Reachable: ${chalk14.green("yes")}`);
2673
+ const statusColor = r.health.status === "ok" ? chalk15.green : r.health.status === "degraded" ? chalk15.yellow : chalk15.red;
2674
+ lines.push(` Reachable: ${chalk15.green("yes")}`);
2247
2675
  lines.push(` Status: ${statusColor(r.health.status ?? "?")}`);
2248
2676
  lines.push(` Latency: ${r.health.latencyMs}ms`);
2249
2677
  } else {
2250
- lines.push(` Reachable: ${chalk14.red("no")}`);
2678
+ lines.push(` Reachable: ${chalk15.red("no")}`);
2251
2679
  lines.push(` Error: ${r.health.error}`);
2252
2680
  }
2253
2681
  lines.push("");
2254
2682
  if (r.validateProbe) {
2255
- lines.push(chalk14.bold("sessions.validate probe ($14.99 USD dry-run)"));
2683
+ lines.push(chalk15.bold("sessions.validate probe ($14.99 USD dry-run)"));
2256
2684
  if (r.validateProbe.ok) {
2257
- lines.push(` Result: ${chalk14.green("valid")}`);
2685
+ lines.push(` Result: ${chalk15.green("valid")}`);
2258
2686
  } else {
2259
- lines.push(` Result: ${chalk14.red("invalid")}`);
2687
+ lines.push(` Result: ${chalk15.red("invalid")}`);
2260
2688
  if (r.validateProbe.error) {
2261
2689
  lines.push(` Code: ${r.validateProbe.error.code}`);
2262
2690
  lines.push(` Action: ${r.validateProbe.error.nextAction}`);
@@ -2269,7 +2697,7 @@ function renderHuman(r) {
2269
2697
  lines.push("");
2270
2698
  }
2271
2699
  if (r.hints.length > 0) {
2272
- lines.push(chalk14.bold("Hints"));
2700
+ lines.push(chalk15.bold("Hints"));
2273
2701
  for (const h of r.hints) lines.push(` \u2022 ${h}`);
2274
2702
  lines.push("");
2275
2703
  }