@vonpay/checkout-cli 0.14.0 → 0.15.0

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 +722 -325
  3. package/package.json +3 -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
@@ -1305,16 +1723,13 @@ function buildPayload(event, opts) {
1305
1723
  };
1306
1724
  }
1307
1725
  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(
1726
+ 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
1727
  "--url-tunnel",
1310
1728
  "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) => {
1729
+ ).addOption(retiredConfirmLiveOption()).action(async (event, opts) => {
1315
1730
  if (!SUPPORTED_EVENTS.includes(event)) {
1316
1731
  console.error(
1317
- chalk11.red(
1732
+ chalk12.red(
1318
1733
  `Unknown event: ${event}
1319
1734
  Supported: ${SUPPORTED_EVENTS.join(", ")}`
1320
1735
  )
@@ -1328,21 +1743,31 @@ Supported: ${SUPPORTED_EVENTS.join(", ")}`
1328
1743
  tunnelFlag: "--url-tunnel"
1329
1744
  });
1330
1745
  if (!destination.ok) {
1331
- console.error(chalk11.red(destination.reason));
1746
+ console.error(chalk12.red(destination.reason));
1332
1747
  process.exit(1);
1333
1748
  }
1334
- if (destination.warning) console.warn(chalk11.yellow(destination.warning));
1749
+ if (destination.warning) console.warn(chalk12.yellow(destination.warning));
1750
+ const amount = optionalMinorUnitsOrExit(
1751
+ opts.amount === void 0 ? void 0 : String(opts.amount),
1752
+ "--amount"
1753
+ );
1335
1754
  const apiKey = requireApiKey();
1336
- requireLiveConfirmation(
1755
+ await confirmLiveAction(
1337
1756
  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))}`
1757
+ {
1758
+ action: "trigger a test webhook event",
1759
+ details: [
1760
+ ["Event", event],
1761
+ ["Sent to", String(opts.url)]
1762
+ ],
1763
+ consequence: "The event is signed with your LIVE key: a receiver that verifies with that key will accept it as a genuine event."
1764
+ },
1765
+ opts
1341
1766
  );
1342
- const payload = buildPayload(event, opts);
1767
+ const payload = buildPayload(event, { ...opts, amount });
1343
1768
  const body = JSON.stringify(payload);
1344
1769
  const t = Math.floor(Date.now() / 1e3);
1345
- const v1 = createHmac("sha256", apiKey).update(`${t}.${body}`).digest("hex");
1770
+ const v1 = createHmac2("sha256", apiKey).update(`${t}.${body}`).digest("hex");
1346
1771
  const signatureHeader = `t=${t},v1=${v1}`;
1347
1772
  const spinner = ora2(`Sending ${event} to ${opts.url}...`).start();
1348
1773
  try {
@@ -1354,42 +1779,53 @@ Supported: ${SUPPORTED_EVENTS.join(", ")}`
1354
1779
  "User-Agent": "VonPay-Webhook/1.0 (CLI trigger)"
1355
1780
  },
1356
1781
  body,
1357
- signal: AbortSignal.timeout(1e4)
1782
+ signal: AbortSignal.timeout(1e4),
1783
+ // Never follow a redirect: a 307/308 would re-send this signed body
1784
+ // to wherever it points, including a host --url never allowed.
1785
+ redirect: "manual"
1358
1786
  });
1359
1787
  spinner.stop();
1788
+ if (isRedirect(response)) {
1789
+ console.error(
1790
+ chalk12.red(
1791
+ `\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.`
1792
+ )
1793
+ );
1794
+ process.exit(1);
1795
+ }
1360
1796
  if (response.ok) {
1361
- console.log(chalk11.green(`\u2713 ${event} delivered \u2192 ${response.status}`));
1797
+ console.log(chalk12.green(`\u2713 ${event} delivered \u2192 ${response.status}`));
1362
1798
  } else {
1363
1799
  console.log(
1364
- chalk11.yellow(
1800
+ chalk12.yellow(
1365
1801
  `\u26A0 ${event} delivered but got ${response.status} response`
1366
1802
  )
1367
1803
  );
1368
1804
  }
1369
- console.log(chalk11.dim(` Event: ${payload.id} (${payload.type})`));
1805
+ console.log(chalk12.dim(` Event: ${payload.id} (${payload.type})`));
1370
1806
  console.log(
1371
- chalk11.dim(
1807
+ chalk12.dim(
1372
1808
  ` Amount: ${payload.data.amount} ${payload.data.currency}`
1373
1809
  )
1374
1810
  );
1375
1811
  console.log(
1376
- chalk11.dim(
1812
+ chalk12.dim(
1377
1813
  `
1378
1814
  Verify with: vonpay.webhooks.constructEvent(rawBody, signatureHeader, secret)`
1379
1815
  )
1380
1816
  );
1381
1817
  console.log(
1382
- chalk11.dim(
1818
+ chalk12.dim(
1383
1819
  ` (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
1820
  )
1385
1821
  );
1386
1822
  } catch (err) {
1387
1823
  spinner.stop();
1388
1824
  if (err instanceof Error && err.name === "TimeoutError") {
1389
- console.error(chalk11.red(`Timeout: ${opts.url} did not respond within 10s`));
1825
+ console.error(chalk12.red(`Timeout: ${opts.url} did not respond within 10s`));
1390
1826
  } else {
1391
1827
  console.error(
1392
- chalk11.red(
1828
+ chalk12.red(
1393
1829
  `Failed to deliver: ${err instanceof Error ? err.message : err}`
1394
1830
  )
1395
1831
  );
@@ -1401,7 +1837,7 @@ Supported: ${SUPPORTED_EVENTS.join(", ")}`
1401
1837
 
1402
1838
  // src/commands/listen.ts
1403
1839
  import { Command as Command10 } from "commander";
1404
- import { VonPayCheckout as VonPayCheckout2, isLiveKey as isLiveKey3 } from "@vonpay/checkout-node";
1840
+ import { VonPayCheckout as VonPayCheckout2 } from "@vonpay/checkout-node";
1405
1841
 
1406
1842
  // ../sse-parser/dist/index.js
1407
1843
  var SseParser = class {
@@ -1645,24 +2081,24 @@ function noop() {
1645
2081
  }
1646
2082
 
1647
2083
  // src/utils/listen-output.ts
1648
- import chalk12 from "chalk";
2084
+ import chalk13 from "chalk";
1649
2085
  function formatAttemptHuman(payload, forward) {
1650
2086
  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");
2087
+ const eventType = chalk13.cyan(payload.event_type.padEnd(24));
2088
+ const eventId = chalk13.gray(payload.event_id.padEnd(28));
2089
+ const attempt = chalk13.gray(`attempt ${payload.attempt}`);
2090
+ const arrow = chalk13.gray("\u2192");
1655
2091
  const upstream = formatUpstream(payload);
1656
- const latency = chalk12.gray(`${payload.latency_ms}ms`);
1657
- const liveBadge = payload.livemode ? chalk12.red(" [LIVE]") : "";
2092
+ const latency = chalk13.gray(`${payload.latency_ms}ms`);
2093
+ const liveBadge = payload.livemode ? chalk13.red(" [LIVE]") : "";
1658
2094
  let line = `[${ts}] ${eventType} ${eventId} ${attempt} ${arrow} ${upstream} ${latency}${liveBadge}`;
1659
2095
  if (forward && forward.attempted) {
1660
- const fwdArrow = chalk12.gray("\u2192 local");
2096
+ const fwdArrow = chalk13.gray("\u2192 local");
1661
2097
  if (forward.error) {
1662
- line += ` ${fwdArrow} ${chalk12.red(forward.error)}`;
2098
+ line += ` ${fwdArrow} ${chalk13.red(forward.error)}`;
1663
2099
  } else if (forward.status !== null) {
1664
2100
  const fwdStatus = formatStatus(forward.status);
1665
- const fwdLatency = chalk12.gray(`${forward.latencyMs}ms`);
2101
+ const fwdLatency = chalk13.gray(`${forward.latencyMs}ms`);
1666
2102
  line += ` ${fwdArrow} ${fwdStatus} ${fwdLatency}`;
1667
2103
  }
1668
2104
  }
@@ -1691,21 +2127,21 @@ function formatAttemptJson(payload, forward) {
1691
2127
  return JSON.stringify(body);
1692
2128
  }
1693
2129
  function formatLifecycleHuman(message, kind) {
1694
- const tag = kind === "info" ? chalk12.cyan("[vonpay]") : kind === "warn" ? chalk12.yellow("[vonpay]") : chalk12.red("[vonpay]");
2130
+ const tag = kind === "info" ? chalk13.cyan("[vonpay]") : kind === "warn" ? chalk13.yellow("[vonpay]") : chalk13.red("[vonpay]");
1695
2131
  return `${tag} ${message}`;
1696
2132
  }
1697
2133
  function formatLifecycleJson(kind, extra = {}) {
1698
2134
  return JSON.stringify({ type: `cli.${kind}`, ...extra, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
1699
2135
  }
1700
2136
  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));
2137
+ if (status >= 200 && status < 300) return chalk13.green(String(status));
2138
+ if (status >= 400 && status < 500) return chalk13.yellow(String(status));
2139
+ if (status >= 500) return chalk13.red(String(status));
2140
+ return chalk13.gray(String(status));
1705
2141
  }
1706
2142
  function formatUpstream(payload) {
1707
2143
  if (payload.response_status === null) {
1708
- return chalk12.red("timeout");
2144
+ return chalk13.red("timeout");
1709
2145
  }
1710
2146
  return formatStatus(payload.response_status);
1711
2147
  }
@@ -1718,78 +2154,6 @@ function formatTimestamp(iso) {
1718
2154
  return `${h}:${m}:${s}`;
1719
2155
  }
1720
2156
 
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
2157
  // src/commands/listen.ts
1794
2158
  function listenCommand() {
1795
2159
  return new Command10("listen").description(
@@ -1804,22 +2168,15 @@ function listenCommand() {
1804
2168
  "--events <type>",
1805
2169
  "Only stream attempts for the given event type. Repeat the flag for multiple types.",
1806
2170
  (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) => {
2171
+ ).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
2172
  const apiKey = opts.apiKey ?? requireApiKey();
2173
+ if (opts.apiKey !== void 0) warnLiveSecretFlag(opts.apiKey, "--api-key");
1812
2174
  const refuse = (message) => {
1813
2175
  if (jsonErrorsEnabled()) exitWithCliError(message, message);
1814
2176
  process.stderr.write(formatLifecycleHuman(message, "error") + "\n");
1815
2177
  return process.exit(1);
1816
2178
  };
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)) {
2179
+ if (secretKeyMode(apiKey) === null) {
1823
2180
  refuse(
1824
2181
  "Listen requires a secret key (vp_sk_*). Publishable keys are rejected by the server."
1825
2182
  );
@@ -1834,15 +2191,36 @@ function listenCommand() {
1834
2191
  forwardUrl = verdict.url;
1835
2192
  forwardWarning = verdict.warning;
1836
2193
  }
1837
- const baseUrl = (getBaseUrl() ?? "https://checkout.vonpay.com").replace(
2194
+ const configuredBaseUrl = getBaseUrl();
2195
+ const destinationProblem = keyDestinationProblem(apiKey, configuredBaseUrl);
2196
+ if (destinationProblem) refuse(destinationProblem);
2197
+ const baseUrl = (configuredBaseUrl ?? "https://checkout.vonpay.com").replace(
1838
2198
  /\/+$/,
1839
2199
  ""
1840
2200
  );
2201
+ await confirmLiveAction(
2202
+ apiKey,
2203
+ {
2204
+ action: "stream live webhook delivery attempts",
2205
+ details: [
2206
+ ["Stream from", baseUrl],
2207
+ ...forwardUrl ? [["Forward to", forwardUrl.toString()]] : []
2208
+ ],
2209
+ 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."
2210
+ },
2211
+ opts
2212
+ );
1841
2213
  const sseUrl = new URL(`${baseUrl}/v1/webhook_listen`);
1842
2214
  if (opts.events && opts.events.length > 0) {
1843
2215
  sseUrl.searchParams.set("event_types", opts.events.join(","));
1844
2216
  }
1845
- const sdk = forwardUrl ? new VonPayCheckout2({ apiKey, baseUrl, appInfo: CLI_APP_INFO, errorReporter: SILENT_ERROR_REPORTER }) : null;
2217
+ const sdk = forwardUrl ? new VonPayCheckout2({
2218
+ apiKey,
2219
+ baseUrl,
2220
+ allowCustomBaseUrl: allowCustomBaseUrl(),
2221
+ appInfo: CLI_APP_INFO,
2222
+ errorReporter: SILENT_ERROR_REPORTER
2223
+ }) : null;
1846
2224
  const controller = new AbortController();
1847
2225
  let eventCount = 0;
1848
2226
  const startedAt = Date.now();
@@ -2012,7 +2390,7 @@ function lifecycleSeverity(kind) {
2012
2390
 
2013
2391
  // src/commands/health.ts
2014
2392
  import { Command as Command11 } from "commander";
2015
- import chalk13 from "chalk";
2393
+ import chalk14 from "chalk";
2016
2394
  import { VonPayError as VonPayError2 } from "@vonpay/checkout-node";
2017
2395
  function healthCommand() {
2018
2396
  return new Command11("health").description("Check checkout API health").option("--json", "Output raw JSON").action(async (opts) => {
@@ -2024,7 +2402,7 @@ function healthCommand() {
2024
2402
  if (opts.json) {
2025
2403
  console.log(JSON.stringify(health, null, 2));
2026
2404
  } else {
2027
- const statusColor = health.status === "ok" ? chalk13.green : health.status === "degraded" ? chalk13.yellow : chalk13.red;
2405
+ const statusColor = health.status === "ok" ? chalk14.green : health.status === "degraded" ? chalk14.yellow : chalk14.red;
2028
2406
  console.log(` Status: ${statusColor(health.status)}`);
2029
2407
  console.log(` Latency: ${health.latencyMs}ms`);
2030
2408
  if (health.version) {
@@ -2035,11 +2413,11 @@ function healthCommand() {
2035
2413
  spinner.stop();
2036
2414
  if (jsonErrorsEnabled()) handleError(err);
2037
2415
  if (err instanceof VonPayError2) {
2038
- console.error(chalk13.red(`API error: ${scrubSecrets(err.message)}`));
2039
- console.error(chalk13.dim(` Code: ${err.code}`));
2416
+ console.error(chalk14.red(`API error: ${scrubSecrets(err.message)}`));
2417
+ console.error(chalk14.dim(` Code: ${err.code}`));
2040
2418
  } else {
2041
2419
  console.error(
2042
- chalk13.red(
2420
+ chalk14.red(
2043
2421
  `Cannot reach API: ${scrubSecrets(err instanceof Error ? err.message : String(err))}`
2044
2422
  )
2045
2423
  );
@@ -2051,7 +2429,7 @@ function healthCommand() {
2051
2429
 
2052
2430
  // src/commands/doctor.ts
2053
2431
  import { Command as Command12 } from "commander";
2054
- import chalk14 from "chalk";
2432
+ import chalk15 from "chalk";
2055
2433
  import ora3 from "ora";
2056
2434
  import { homedir as homedir2 } from "os";
2057
2435
  import { existsSync as existsSync3 } from "fs";
@@ -2083,14 +2461,11 @@ function maskPrefix(key) {
2083
2461
  }
2084
2462
  function keyMode(key) {
2085
2463
  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;
2464
+ return secretKeyMode(key) ?? "not_a_secret_key";
2090
2465
  }
2091
2466
  async function gather() {
2092
2467
  const config = loadConfig();
2093
- const apiKey = process.env.VON_PAY_SECRET_KEY ?? config.checkout?.apiKey;
2468
+ const apiKey = process.env.VON_PAY_SECRET_KEY || config.checkout?.apiKey;
2094
2469
  const baseUrl = process.env.VONPAY_BASE_URL ?? "https://checkout.vonpay.com";
2095
2470
  const envVarsPresent = RELEVANT_ENV_VARS.filter(
2096
2471
  (n) => process.env[n] && process.env[n] !== ""
@@ -2122,8 +2497,14 @@ async function gather() {
2122
2497
  "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
2498
  );
2124
2499
  }
2500
+ const shapeProblem = keyDestinationProblem("", process.env.VONPAY_BASE_URL);
2501
+ if (shapeProblem) {
2502
+ report.health.baseUrl = "(not shown: the base URL was refused)";
2503
+ report.health.error = shapeProblem;
2504
+ report.hints.push(`${shapeProblem} No probe was run.`);
2505
+ }
2125
2506
  const start = Date.now();
2126
- try {
2507
+ if (!shapeProblem) try {
2127
2508
  const controller = new AbortController();
2128
2509
  const timer = setTimeout(() => controller.abort(), 1e4);
2129
2510
  const res = await fetch(`${baseUrl}/api/health`, {
@@ -2155,11 +2536,27 @@ async function gather() {
2155
2536
  "Health probe could not reach the API. Check network / DNS / firewall \u2014 vonpay-checkout requires outbound HTTPS to checkout.vonpay.com."
2156
2537
  );
2157
2538
  }
2158
- if (apiKey && report.health.reachable) {
2539
+ let mayProbe = Boolean(apiKey) && !shapeProblem;
2540
+ if (apiKey && report.apiKeyMode === "not_a_secret_key") {
2541
+ mayProbe = false;
2542
+ report.hints.push(`${NOT_A_SECRET_KEY_MESSAGE} The sessions.validate probe was not run.`);
2543
+ }
2544
+ const destinationProblem = apiKey && mayProbe ? keyDestinationProblem(apiKey, process.env.VONPAY_BASE_URL) : null;
2545
+ if (destinationProblem) {
2546
+ mayProbe = false;
2547
+ report.hints.push(`${destinationProblem} The sessions.validate probe was not run.`);
2548
+ }
2549
+ if (config.checkout?.apiKey && report.configFile.keyType === "live") {
2550
+ report.hints.push(
2551
+ "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."
2552
+ );
2553
+ }
2554
+ if (apiKey && mayProbe && report.health.reachable) {
2159
2555
  try {
2160
2556
  const probeClient = new VonPayCheckout3({
2161
2557
  apiKey,
2162
2558
  baseUrl,
2559
+ allowCustomBaseUrl: allowCustomBaseUrl(),
2163
2560
  timeout: 1e4,
2164
2561
  maxRetries: 0,
2165
2562
  errorReporter: () => {
@@ -2216,47 +2613,47 @@ async function gather() {
2216
2613
  }
2217
2614
  function renderHuman(r) {
2218
2615
  const lines = [];
2219
- lines.push(chalk14.bold.blue("vonpay doctor \u2014 diagnostic report"));
2220
- lines.push(chalk14.dim(`generated ${r.generatedAt}`));
2616
+ lines.push(chalk15.bold.blue("vonpay doctor \u2014 diagnostic report"));
2617
+ lines.push(chalk15.dim(`generated ${r.generatedAt}`));
2221
2618
  lines.push("");
2222
- lines.push(chalk14.bold("Runtime"));
2619
+ lines.push(chalk15.bold("Runtime"));
2223
2620
  lines.push(` CLI: ${r.cli.version}`);
2224
2621
  lines.push(` Node: ${r.runtime.node}`);
2225
2622
  lines.push(` Platform: ${r.runtime.platform} (${r.runtime.arch})`);
2226
2623
  lines.push(` CWD: ${r.runtime.cwd}`);
2227
2624
  lines.push("");
2228
- lines.push(chalk14.bold("API key"));
2625
+ lines.push(chalk15.bold("API key"));
2229
2626
  if (r.apiKeyPrefix) {
2230
2627
  lines.push(` Prefix: ${r.apiKeyPrefix}`);
2231
2628
  lines.push(` Mode: ${r.apiKeyMode}`);
2232
2629
  } else {
2233
- lines.push(chalk14.dim(" (no key found in env or ~/.vonpay/config.json)"));
2630
+ lines.push(chalk15.dim(" (no key found in env or ~/.vonpay/config.json)"));
2234
2631
  }
2235
2632
  lines.push("");
2236
- lines.push(chalk14.bold("Env vars present (names only)"));
2633
+ lines.push(chalk15.bold("Env vars present (names only)"));
2237
2634
  if (r.envVarsPresent.length === 0) {
2238
- lines.push(chalk14.dim(" (none of the expected vonpay env vars are set)"));
2635
+ lines.push(chalk15.dim(" (none of the expected vonpay env vars are set)"));
2239
2636
  } else {
2240
2637
  for (const n of r.envVarsPresent) lines.push(` \u2713 ${n}`);
2241
2638
  }
2242
2639
  lines.push("");
2243
- lines.push(chalk14.bold("Health probe"));
2640
+ lines.push(chalk15.bold("Health probe"));
2244
2641
  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")}`);
2642
+ const statusColor = r.health.status === "ok" ? chalk15.green : r.health.status === "degraded" ? chalk15.yellow : chalk15.red;
2643
+ lines.push(` Reachable: ${chalk15.green("yes")}`);
2247
2644
  lines.push(` Status: ${statusColor(r.health.status ?? "?")}`);
2248
2645
  lines.push(` Latency: ${r.health.latencyMs}ms`);
2249
2646
  } else {
2250
- lines.push(` Reachable: ${chalk14.red("no")}`);
2647
+ lines.push(` Reachable: ${chalk15.red("no")}`);
2251
2648
  lines.push(` Error: ${r.health.error}`);
2252
2649
  }
2253
2650
  lines.push("");
2254
2651
  if (r.validateProbe) {
2255
- lines.push(chalk14.bold("sessions.validate probe ($14.99 USD dry-run)"));
2652
+ lines.push(chalk15.bold("sessions.validate probe ($14.99 USD dry-run)"));
2256
2653
  if (r.validateProbe.ok) {
2257
- lines.push(` Result: ${chalk14.green("valid")}`);
2654
+ lines.push(` Result: ${chalk15.green("valid")}`);
2258
2655
  } else {
2259
- lines.push(` Result: ${chalk14.red("invalid")}`);
2656
+ lines.push(` Result: ${chalk15.red("invalid")}`);
2260
2657
  if (r.validateProbe.error) {
2261
2658
  lines.push(` Code: ${r.validateProbe.error.code}`);
2262
2659
  lines.push(` Action: ${r.validateProbe.error.nextAction}`);
@@ -2269,7 +2666,7 @@ function renderHuman(r) {
2269
2666
  lines.push("");
2270
2667
  }
2271
2668
  if (r.hints.length > 0) {
2272
- lines.push(chalk14.bold("Hints"));
2669
+ lines.push(chalk15.bold("Hints"));
2273
2670
  for (const h of r.hints) lines.push(` \u2022 ${h}`);
2274
2671
  lines.push("");
2275
2672
  }