extendvcc-cli 0.1.0__py3-none-any.whl
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.
- extendvcc/__init__.py +46 -0
- extendvcc/_exit_codes.py +24 -0
- extendvcc/_jsonl.py +35 -0
- extendvcc/_paths.py +28 -0
- extendvcc/auth.py +900 -0
- extendvcc/cards.py +761 -0
- extendvcc/cli.py +883 -0
- extendvcc/client.py +491 -0
- extendvcc/imap_otp.py +170 -0
- extendvcc/ledger.py +535 -0
- extendvcc/models.py +74 -0
- extendvcc/py.typed +0 -0
- extendvcc_cli-0.1.0.dist-info/METADATA +179 -0
- extendvcc_cli-0.1.0.dist-info/RECORD +17 -0
- extendvcc_cli-0.1.0.dist-info/WHEEL +4 -0
- extendvcc_cli-0.1.0.dist-info/entry_points.txt +2 -0
- extendvcc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
extendvcc/cli.py
ADDED
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
"""extendvcc CLI — full lifecycle management of Extend virtual cards."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import csv
|
|
7
|
+
import getpass
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
from dataclasses import asdict
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from ._exit_codes import (
|
|
17
|
+
EXIT_API_ERROR,
|
|
18
|
+
EXIT_AUTH_REQUIRED,
|
|
19
|
+
EXIT_DISABLED,
|
|
20
|
+
EXIT_ERROR,
|
|
21
|
+
EXIT_OK,
|
|
22
|
+
EXIT_USAGE,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
DISCLAIMER = (
|
|
26
|
+
"Unofficial and unaffiliated: extendvcc is an independent client for "
|
|
27
|
+
"Extend's private API (api.paywithextend.com). It is not affiliated with, endorsed "
|
|
28
|
+
"by, or supported by Extend, Inc. Automating their private API may get your account "
|
|
29
|
+
"suspended. Use at your own risk."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CLIInputError(ValueError):
|
|
34
|
+
"""Raised for CLI-layer input/usage errors (maps to EXIT_USAGE).
|
|
35
|
+
|
|
36
|
+
Distinct from library ``ValueError`` (e.g. ``usage()`` missing org_id) so that
|
|
37
|
+
only CLI-owned validation maps to exit 2; library errors fall through to 1.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _info(msg: str = "") -> None:
|
|
42
|
+
"""Print a human-oriented message to stderr.
|
|
43
|
+
|
|
44
|
+
Under ``--json``, stdout must carry only structured JSON, so every progress
|
|
45
|
+
line, confirmation, summary, and warning routes through here to stderr.
|
|
46
|
+
"""
|
|
47
|
+
print(msg, file=sys.stderr)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _json_out(data: Any) -> str:
|
|
51
|
+
"""Serialize data for --json output."""
|
|
52
|
+
return json.dumps(data, indent=2, sort_keys=True, default=str)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _confirm(prompt: str, *, yes: bool = False) -> bool:
|
|
56
|
+
"""Ask for confirmation unless --yes was passed."""
|
|
57
|
+
if yes:
|
|
58
|
+
return True
|
|
59
|
+
# Prompt to stderr so stdout stays clean (shell pipes, --json capture).
|
|
60
|
+
print(prompt, end="", file=sys.stderr, flush=True)
|
|
61
|
+
answer = input()
|
|
62
|
+
return answer.strip().lower() in ("y", "yes")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _mask_card_number(number: str) -> str:
|
|
66
|
+
"""Show first 4 and last 4 digits, mask the rest."""
|
|
67
|
+
if len(number) <= 8:
|
|
68
|
+
return number
|
|
69
|
+
return number[:4] + "*" * (len(number) - 8) + number[-4:]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _card_to_dict(card: Any) -> dict[str, Any]:
|
|
73
|
+
"""Convert a dataclass card to a JSON-safe dict."""
|
|
74
|
+
return json.loads(json.dumps(asdict(card), default=str))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# Command handlers
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _prompt(prompt: str) -> str:
|
|
83
|
+
"""Read a line from stdin with the prompt written to stderr (keeps stdout clean)."""
|
|
84
|
+
print(prompt, end="", file=sys.stderr, flush=True)
|
|
85
|
+
return input()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _cmd_login(args: argparse.Namespace) -> int:
|
|
89
|
+
from . import auth
|
|
90
|
+
from .imap_otp import make_otp_callback
|
|
91
|
+
|
|
92
|
+
# Surface the unofficial/at-your-own-risk notice at the start of real usage.
|
|
93
|
+
_info(DISCLAIMER)
|
|
94
|
+
_info()
|
|
95
|
+
|
|
96
|
+
email = args.email or os.environ.get("EXTENDVCC_EMAIL") or _prompt("Email: ")
|
|
97
|
+
# getpass writes its prompt to stderr, so stdout stays clean.
|
|
98
|
+
password = os.environ.get("EXTENDVCC_PASSWORD") or getpass.getpass("Password: ")
|
|
99
|
+
|
|
100
|
+
# Pass credentials directly — never write the plaintext password into the
|
|
101
|
+
# process environment, where it would leak into every child process.
|
|
102
|
+
otp_callback = make_otp_callback()
|
|
103
|
+
result = auth.setup(email=email, password=password, otp_callback=otp_callback)
|
|
104
|
+
|
|
105
|
+
if getattr(args, "json", False):
|
|
106
|
+
print(_json_out(result))
|
|
107
|
+
else:
|
|
108
|
+
_info(f"Logged in as {result.get('email', '?')}")
|
|
109
|
+
if result.get("org_id"):
|
|
110
|
+
_info(f"Organization: {result['org_id']}")
|
|
111
|
+
_info(f"Session saved to {result.get('session_path', '?')}")
|
|
112
|
+
return EXIT_OK
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _cmd_accounts(args: argparse.Namespace) -> int:
|
|
116
|
+
from .cards import list_credit_cards
|
|
117
|
+
|
|
118
|
+
cards = list_credit_cards()
|
|
119
|
+
if getattr(args, "json", False):
|
|
120
|
+
print(_json_out([_card_to_dict(c) for c in cards]))
|
|
121
|
+
else:
|
|
122
|
+
if not cards:
|
|
123
|
+
_info("No enrolled credit cards.")
|
|
124
|
+
return EXIT_OK
|
|
125
|
+
print(f"{'ID':<40} {'Last 4':<8} {'Status':<16} {'Name'}")
|
|
126
|
+
print("-" * 90)
|
|
127
|
+
for c in cards:
|
|
128
|
+
print(f"{c.id:<40} {c.last4:<8} {c.status.value:<16} {c.display_name}")
|
|
129
|
+
return EXIT_OK
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _cmd_enroll(args: argparse.Namespace) -> int:
|
|
133
|
+
from .cards import enroll_credit_card
|
|
134
|
+
|
|
135
|
+
card_number = getpass.getpass("Card number (PAN): ")
|
|
136
|
+
cvc = getpass.getpass("CVC: ")
|
|
137
|
+
|
|
138
|
+
_info(f"\nEnrolling card ending in ...{card_number[-4:]}")
|
|
139
|
+
_info(f" Name: {args.display_name}")
|
|
140
|
+
_info(f" Cardholder: {args.cardholder_name}")
|
|
141
|
+
_info(f" Issuer ID: {args.issuer_id}")
|
|
142
|
+
if not _confirm("Proceed? [y/N] ", yes=getattr(args, "yes", False)):
|
|
143
|
+
_info("Cancelled.")
|
|
144
|
+
return EXIT_ERROR
|
|
145
|
+
|
|
146
|
+
address = {
|
|
147
|
+
"address1": args.address1,
|
|
148
|
+
"address2": getattr(args, "address2", "") or "",
|
|
149
|
+
"city": args.city,
|
|
150
|
+
"province": args.province,
|
|
151
|
+
"postal": args.postal,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
result = enroll_credit_card(
|
|
155
|
+
display_name=args.display_name,
|
|
156
|
+
card_number=card_number,
|
|
157
|
+
expires=args.expires,
|
|
158
|
+
cvc=cvc,
|
|
159
|
+
cardholder_name=args.cardholder_name,
|
|
160
|
+
issuer_id=args.issuer_id,
|
|
161
|
+
address=address,
|
|
162
|
+
company_name=getattr(args, "company_name", None),
|
|
163
|
+
country=getattr(args, "country", "US") or "US",
|
|
164
|
+
)
|
|
165
|
+
if getattr(args, "json", False):
|
|
166
|
+
print(_json_out(_card_to_dict(result)))
|
|
167
|
+
else:
|
|
168
|
+
print(f"Enrolled: {result.id} (last4={result.last4}, status={result.status.value})")
|
|
169
|
+
_info("Check your email for issuer verification, then run: extendvcc activate <id>")
|
|
170
|
+
return EXIT_OK
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _cmd_activate(args: argparse.Namespace) -> int:
|
|
174
|
+
from .cards import activate_credit_card
|
|
175
|
+
from .models import CardStatus
|
|
176
|
+
|
|
177
|
+
card = activate_credit_card(args.id)
|
|
178
|
+
if getattr(args, "json", False):
|
|
179
|
+
print(_json_out(_card_to_dict(card)))
|
|
180
|
+
else:
|
|
181
|
+
print(f"Card: {card.id}")
|
|
182
|
+
print(f"Status: {card.status.value}")
|
|
183
|
+
if card.status == CardStatus.PENDING:
|
|
184
|
+
_info("Still PENDING — verify via the issuer email, then re-run: extendvcc activate <id>")
|
|
185
|
+
return EXIT_OK
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _cmd_issuers(args: argparse.Namespace) -> int:
|
|
189
|
+
from .cards import list_issuers
|
|
190
|
+
|
|
191
|
+
issuers = list_issuers()
|
|
192
|
+
if getattr(args, "json", False):
|
|
193
|
+
print(_json_out([_card_to_dict(i) for i in issuers]))
|
|
194
|
+
else:
|
|
195
|
+
if not issuers:
|
|
196
|
+
_info("No issuers found.")
|
|
197
|
+
return EXIT_OK
|
|
198
|
+
print(f"{'ID':<40} {'Code':<12} {'Name'}")
|
|
199
|
+
print("-" * 70)
|
|
200
|
+
for i in issuers:
|
|
201
|
+
print(f"{i.id:<40} {i.code:<12} {i.name}")
|
|
202
|
+
return EXIT_OK
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _cmd_cards(args: argparse.Namespace) -> int:
|
|
206
|
+
from .cards import list_cards
|
|
207
|
+
from .models import CardStatus
|
|
208
|
+
|
|
209
|
+
status = None
|
|
210
|
+
if args.status:
|
|
211
|
+
status = CardStatus(args.status.upper())
|
|
212
|
+
|
|
213
|
+
cards = list_cards(status=status)
|
|
214
|
+
if getattr(args, "json", False):
|
|
215
|
+
print(_json_out([_card_to_dict(c) for c in cards]))
|
|
216
|
+
else:
|
|
217
|
+
if not cards:
|
|
218
|
+
_info("No virtual cards found.")
|
|
219
|
+
return EXIT_OK
|
|
220
|
+
print(f"{'ID':<40} {'Last 4':<8} {'Status':<14} {'Balance':<12} {'Name'}")
|
|
221
|
+
print("-" * 120)
|
|
222
|
+
for c in cards:
|
|
223
|
+
balance = f"${c.balance_cents / 100:.2f}"
|
|
224
|
+
print(f"{c.id:<40} {c.last4:<8} {c.status.value:<14} {balance:<12} {c.name}")
|
|
225
|
+
return EXIT_OK
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _cmd_card(args: argparse.Namespace) -> int:
|
|
229
|
+
from .cards import get_card
|
|
230
|
+
|
|
231
|
+
card = get_card(args.id)
|
|
232
|
+
if getattr(args, "json", False):
|
|
233
|
+
print(_json_out(_card_to_dict(card)))
|
|
234
|
+
else:
|
|
235
|
+
print(f"ID: {card.id}")
|
|
236
|
+
print(f"Name: {card.name}")
|
|
237
|
+
print(f"Last 4: {card.last4}")
|
|
238
|
+
print(f"Status: {card.status.value}")
|
|
239
|
+
print(f"Balance: ${card.balance_cents / 100:.2f}")
|
|
240
|
+
print(f"Credit Card: {card.credit_card_id}")
|
|
241
|
+
print(f"Valid From: {card.valid_from or 'N/A'}")
|
|
242
|
+
print(f"Valid To: {card.valid_to or 'N/A'}")
|
|
243
|
+
print(f"Created: {card.created_at or 'N/A'}")
|
|
244
|
+
if card.notes:
|
|
245
|
+
print(f"Notes: {card.notes}")
|
|
246
|
+
return EXIT_OK
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _cmd_usage(args: argparse.Namespace) -> int:
|
|
250
|
+
from .cards import usage
|
|
251
|
+
|
|
252
|
+
result = usage()
|
|
253
|
+
if getattr(args, "json", False):
|
|
254
|
+
print(_json_out(result))
|
|
255
|
+
else:
|
|
256
|
+
print(f"Active cards: {result['used']} / {result['limit']}")
|
|
257
|
+
print(f"Remaining: {result['remaining']}")
|
|
258
|
+
return EXIT_OK
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _create_recurrence(args: argparse.Namespace) -> Any:
|
|
262
|
+
from .models import Recurrence
|
|
263
|
+
|
|
264
|
+
if not args.period:
|
|
265
|
+
return None
|
|
266
|
+
return Recurrence(
|
|
267
|
+
period=args.period.upper(),
|
|
268
|
+
interval=getattr(args, "interval", 1) or 1,
|
|
269
|
+
terminator=(getattr(args, "terminator", None) or "NONE").upper(),
|
|
270
|
+
by_month_day=getattr(args, "by_month_day", None),
|
|
271
|
+
by_week_day=getattr(args, "by_week_day", None),
|
|
272
|
+
until=getattr(args, "until", None),
|
|
273
|
+
count=getattr(args, "count", None),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _create_summary(args: argparse.Namespace, recurrence: Any) -> None:
|
|
278
|
+
kind = "recurring" if recurrence else "one-time"
|
|
279
|
+
balance_dollars = args.balance_cents / 100
|
|
280
|
+
_info(f"Creating {kind} virtual card:")
|
|
281
|
+
_info(f" Name: {args.name}")
|
|
282
|
+
_info(f" Balance: ${balance_dollars:.2f}")
|
|
283
|
+
_info(f" Credit Card ID: {args.credit_card_id}")
|
|
284
|
+
if recurrence:
|
|
285
|
+
_info(f" Period: {recurrence.period} (every {recurrence.interval})")
|
|
286
|
+
_info(f" Terminator: {recurrence.terminator}")
|
|
287
|
+
else:
|
|
288
|
+
_info(f" Valid To: {args.valid_to}")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _local_recipient(recipient_flag: str | None) -> tuple[str, bool]:
|
|
292
|
+
"""Resolve a recipient WITHOUT any network call for dry-run preview.
|
|
293
|
+
|
|
294
|
+
Returns ``(email, exact)``. Prefers an explicit ``--recipient`` flag, else the
|
|
295
|
+
email from the locally-saved session file, else a placeholder. ``exact`` is
|
|
296
|
+
True only when we have a concrete value (flag or session); a placeholder means
|
|
297
|
+
the preview is approximate.
|
|
298
|
+
"""
|
|
299
|
+
if recipient_flag:
|
|
300
|
+
return recipient_flag, True
|
|
301
|
+
from . import auth
|
|
302
|
+
|
|
303
|
+
session = auth.load_session()
|
|
304
|
+
if session and session.get("email"):
|
|
305
|
+
return str(session["email"]), True
|
|
306
|
+
return "<session-email>", False
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _cmd_create(args: argparse.Namespace) -> int:
|
|
310
|
+
from .cards import build_create_card_operation, create_card
|
|
311
|
+
|
|
312
|
+
if args.valid_to and args.period:
|
|
313
|
+
raise CLIInputError("--valid-to and --period are mutually exclusive.")
|
|
314
|
+
if not args.valid_to and not args.period:
|
|
315
|
+
raise CLIInputError("provide either --valid-to (one-time) or --period (recurring).")
|
|
316
|
+
|
|
317
|
+
recurrence = _create_recurrence(args)
|
|
318
|
+
|
|
319
|
+
if getattr(args, "dry_run", False):
|
|
320
|
+
return _create_dry_run(args, recurrence, build_create_card_operation)
|
|
321
|
+
|
|
322
|
+
_create_summary(args, recurrence)
|
|
323
|
+
if not _confirm("Proceed? [y/N] ", yes=getattr(args, "yes", False)):
|
|
324
|
+
_info("Cancelled.")
|
|
325
|
+
return EXIT_ERROR
|
|
326
|
+
|
|
327
|
+
card = create_card(
|
|
328
|
+
credit_card_id=args.credit_card_id,
|
|
329
|
+
name=args.name,
|
|
330
|
+
balance_cents=args.balance_cents,
|
|
331
|
+
valid_to=args.valid_to if not recurrence else None,
|
|
332
|
+
recurrence=recurrence,
|
|
333
|
+
recipient=getattr(args, "recipient", None),
|
|
334
|
+
)
|
|
335
|
+
if getattr(args, "json", False):
|
|
336
|
+
print(_json_out(_card_to_dict(card)))
|
|
337
|
+
else:
|
|
338
|
+
print(f"Created: {card.id} (last4={card.last4}, status={card.status.value})")
|
|
339
|
+
return EXIT_OK
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _create_dry_run(args: argparse.Namespace, recurrence: Any, builder: Any) -> int:
|
|
343
|
+
"""Preview a create without any network call: resolve recipient locally,
|
|
344
|
+
print the plan to stderr and the would-be request body (JSON) to stdout."""
|
|
345
|
+
import uuid
|
|
346
|
+
|
|
347
|
+
recipient, exact = _local_recipient(getattr(args, "recipient", None))
|
|
348
|
+
operation = builder(
|
|
349
|
+
args.credit_card_id,
|
|
350
|
+
args.name,
|
|
351
|
+
args.balance_cents,
|
|
352
|
+
args.valid_to if not recurrence else None,
|
|
353
|
+
recurrence=recurrence,
|
|
354
|
+
recipient_resolver=lambda: recipient,
|
|
355
|
+
token_factory=lambda: uuid.uuid4().hex[:8],
|
|
356
|
+
)
|
|
357
|
+
if not exact:
|
|
358
|
+
operation["preview_accuracy"] = "approximate"
|
|
359
|
+
|
|
360
|
+
_create_summary(args, recurrence)
|
|
361
|
+
_info(f" Recipient: {recipient}")
|
|
362
|
+
_info(f"[dry-run] No API call made (preview: {operation['preview_accuracy']}).")
|
|
363
|
+
print(_json_out(operation["body"]))
|
|
364
|
+
return EXIT_OK
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _read_bulk_rows(csv_path: Path) -> list[dict[str, Any]]:
|
|
368
|
+
with open(csv_path, newline="", encoding="utf-8") as f:
|
|
369
|
+
reader = csv.DictReader(f)
|
|
370
|
+
rows: list[dict[str, Any]] = []
|
|
371
|
+
for row in reader:
|
|
372
|
+
parsed: dict[str, Any] = {
|
|
373
|
+
"name": row["name"],
|
|
374
|
+
"balance_cents": int(row["balance_cents"]),
|
|
375
|
+
"valid_to": row["valid_to"],
|
|
376
|
+
}
|
|
377
|
+
if "recipient" in row and row["recipient"].strip():
|
|
378
|
+
parsed["recipient"] = row["recipient"].strip()
|
|
379
|
+
rows.append(parsed)
|
|
380
|
+
return rows
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _cmd_bulk(args: argparse.Namespace) -> int:
|
|
384
|
+
from .cards import build_create_card_operation, create_cards_bulk
|
|
385
|
+
|
|
386
|
+
csv_path = Path(args.file)
|
|
387
|
+
if not csv_path.exists():
|
|
388
|
+
raise CLIInputError(f"file not found: {csv_path}")
|
|
389
|
+
|
|
390
|
+
rows = _read_bulk_rows(csv_path)
|
|
391
|
+
if not rows:
|
|
392
|
+
raise CLIInputError("CSV file is empty.")
|
|
393
|
+
|
|
394
|
+
total_cents = sum(r["balance_cents"] for r in rows)
|
|
395
|
+
_info(f"Bulk create: {len(rows)} cards, total ${total_cents / 100:.2f}")
|
|
396
|
+
_info(f" Credit Card ID: {args.credit_card_id}")
|
|
397
|
+
|
|
398
|
+
if getattr(args, "dry_run", False):
|
|
399
|
+
return _bulk_dry_run(args, rows, build_create_card_operation)
|
|
400
|
+
|
|
401
|
+
_info(f" Delay: {args.delay}s (jitter {args.jitter}s, min {args.min_delay}s)")
|
|
402
|
+
if not _confirm("Proceed? [y/N] ", yes=getattr(args, "yes", False)):
|
|
403
|
+
_info("Cancelled.")
|
|
404
|
+
return EXIT_ERROR
|
|
405
|
+
|
|
406
|
+
cards = create_cards_bulk(
|
|
407
|
+
credit_card_id=args.credit_card_id,
|
|
408
|
+
rows=rows,
|
|
409
|
+
delay_seconds=args.delay,
|
|
410
|
+
jitter_seconds=args.jitter,
|
|
411
|
+
min_delay_seconds=args.min_delay,
|
|
412
|
+
)
|
|
413
|
+
if getattr(args, "json", False):
|
|
414
|
+
print(_json_out([_card_to_dict(c) for c in cards]))
|
|
415
|
+
else:
|
|
416
|
+
for c in cards:
|
|
417
|
+
print(f" Created: {c.id} (last4={c.last4}, {c.name})")
|
|
418
|
+
print(f"\n{len(cards)} cards created.")
|
|
419
|
+
return EXIT_OK
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _bulk_dry_run(args: argparse.Namespace, rows: list[dict[str, Any]], builder: Any) -> int:
|
|
423
|
+
"""Preview a bulk create with no network calls and no pacing sleeps.
|
|
424
|
+
|
|
425
|
+
Each row's recipient is resolved locally (flag/session/placeholder); the plan
|
|
426
|
+
goes to stderr and the list of would-be request bodies (JSON) to stdout."""
|
|
427
|
+
import uuid
|
|
428
|
+
|
|
429
|
+
bodies: list[dict[str, Any]] = []
|
|
430
|
+
approximate = False
|
|
431
|
+
for row in rows:
|
|
432
|
+
recipient, exact = _local_recipient(row.get("recipient"))
|
|
433
|
+
approximate = approximate or not exact
|
|
434
|
+
operation = builder(
|
|
435
|
+
args.credit_card_id,
|
|
436
|
+
row["name"],
|
|
437
|
+
row["balance_cents"],
|
|
438
|
+
row["valid_to"],
|
|
439
|
+
recurrence=None,
|
|
440
|
+
recipient_resolver=lambda r=recipient: r,
|
|
441
|
+
token_factory=lambda: uuid.uuid4().hex[:8],
|
|
442
|
+
)
|
|
443
|
+
bodies.append(operation["body"])
|
|
444
|
+
|
|
445
|
+
accuracy = "approximate" if approximate else "exact"
|
|
446
|
+
_info(f"[dry-run] {len(bodies)} cards, no API calls (preview: {accuracy}).")
|
|
447
|
+
print(_json_out(bodies))
|
|
448
|
+
return EXIT_OK
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _cmd_reveal(args: argparse.Namespace) -> int:
|
|
452
|
+
from .cards import reveal_card
|
|
453
|
+
|
|
454
|
+
_info("WARNING: Card credentials are highly sensitive.")
|
|
455
|
+
_info("Do not share, log, or transmit them insecurely.")
|
|
456
|
+
|
|
457
|
+
creds = reveal_card(args.id)
|
|
458
|
+
json_path = getattr(args, "json_path", None)
|
|
459
|
+
|
|
460
|
+
# Security boundary: full PAN/CVC reaches stdout via NO path. stdout is
|
|
461
|
+
# captured by shell history, CI logs, and agent transcripts, so raw
|
|
462
|
+
# credentials only ever go to a 0600 file (--json-path). The global --json
|
|
463
|
+
# flag emits a MASKED structure; human mode masks too.
|
|
464
|
+
if json_path:
|
|
465
|
+
path = Path(json_path)
|
|
466
|
+
fd, tmp_name = tempfile.mkstemp(prefix=f"{path.name}.", dir=str(path.parent))
|
|
467
|
+
try:
|
|
468
|
+
os.chmod(tmp_name, 0o600)
|
|
469
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
470
|
+
fh.write(json.dumps(creds, indent=2, sort_keys=True))
|
|
471
|
+
fh.write("\n")
|
|
472
|
+
fh.flush()
|
|
473
|
+
os.fsync(fh.fileno())
|
|
474
|
+
os.replace(tmp_name, str(path))
|
|
475
|
+
os.chmod(str(path), 0o600)
|
|
476
|
+
except Exception:
|
|
477
|
+
try:
|
|
478
|
+
os.unlink(tmp_name)
|
|
479
|
+
except FileNotFoundError:
|
|
480
|
+
pass
|
|
481
|
+
raise
|
|
482
|
+
_info(f"Credentials written to {path} (mode 0600)")
|
|
483
|
+
elif getattr(args, "json", False):
|
|
484
|
+
masked = {
|
|
485
|
+
"last4": creds.get("last4"),
|
|
486
|
+
"expires": creds.get("expires"),
|
|
487
|
+
"number": _mask_card_number(creds.get("number", "")),
|
|
488
|
+
"cvc": "****",
|
|
489
|
+
}
|
|
490
|
+
print(_json_out(masked))
|
|
491
|
+
else:
|
|
492
|
+
number = creds.get("number", "")
|
|
493
|
+
print(f"Card Number: {_mask_card_number(number)}")
|
|
494
|
+
print(f"Last 4: {creds.get('last4', 'N/A')}")
|
|
495
|
+
print(f"Expires: {creds.get('expires', 'N/A')}")
|
|
496
|
+
print("CVC: ****")
|
|
497
|
+
return EXIT_OK
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _cmd_update(args: argparse.Namespace) -> int:
|
|
501
|
+
from .cards import update_card
|
|
502
|
+
|
|
503
|
+
kwargs: dict[str, Any] = {}
|
|
504
|
+
if args.balance_cents is not None:
|
|
505
|
+
kwargs["balance_cents"] = args.balance_cents
|
|
506
|
+
if args.name is not None:
|
|
507
|
+
kwargs["name"] = args.name
|
|
508
|
+
if args.valid_to is not None:
|
|
509
|
+
kwargs["valid_to"] = args.valid_to
|
|
510
|
+
|
|
511
|
+
if not kwargs:
|
|
512
|
+
raise CLIInputError("no fields to update. Use --balance-cents, --name, or --valid-to.")
|
|
513
|
+
|
|
514
|
+
if getattr(args, "dry_run", False):
|
|
515
|
+
return _update_dry_run(args, kwargs)
|
|
516
|
+
|
|
517
|
+
card = update_card(args.id, **kwargs)
|
|
518
|
+
if getattr(args, "json", False):
|
|
519
|
+
print(_json_out(_card_to_dict(card)))
|
|
520
|
+
else:
|
|
521
|
+
print(f"Updated: {card.id} (last4={card.last4}, status={card.status.value})")
|
|
522
|
+
return EXIT_OK
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _update_dry_run(args: argparse.Namespace, kwargs: dict[str, Any]) -> int:
|
|
526
|
+
"""Preview an update. The read-only GET is allowed (non-destructive) so the
|
|
527
|
+
merged PUT body is accurate; no mutation is performed."""
|
|
528
|
+
from .cards import _default_client, _update_overrides, build_update_card_operation
|
|
529
|
+
|
|
530
|
+
client = _default_client()
|
|
531
|
+
overrides = _update_overrides(
|
|
532
|
+
balance_cents=kwargs.get("balance_cents"),
|
|
533
|
+
name=kwargs.get("name"),
|
|
534
|
+
valid_to=kwargs.get("valid_to"),
|
|
535
|
+
recurs=None,
|
|
536
|
+
)
|
|
537
|
+
operation = build_update_card_operation(
|
|
538
|
+
args.id,
|
|
539
|
+
overrides,
|
|
540
|
+
fetcher=lambda: client.get(f"/virtualcards/{args.id}"),
|
|
541
|
+
)
|
|
542
|
+
_info(f"[dry-run] update {args.id} — overrides: {overrides}. No mutation made.")
|
|
543
|
+
print(_json_out(operation["body"]))
|
|
544
|
+
return EXIT_OK
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _bodyless_descriptor(card_id: str, *, action: str, reversible: bool) -> dict[str, Any]:
|
|
548
|
+
"""Build a dry-run descriptor for a bodyless PUT (cancel/close)."""
|
|
549
|
+
return {
|
|
550
|
+
"method": "PUT",
|
|
551
|
+
"path": f"/virtualcards/{card_id}/{action}",
|
|
552
|
+
"card_id": card_id,
|
|
553
|
+
"reversible": reversible,
|
|
554
|
+
"body": None,
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _cmd_cancel(args: argparse.Namespace) -> int:
|
|
559
|
+
from .cards import cancel_card
|
|
560
|
+
|
|
561
|
+
if getattr(args, "dry_run", False):
|
|
562
|
+
_info(f"[dry-run] would cancel {args.id} (reversible). No API call made.")
|
|
563
|
+
print(_json_out(_bodyless_descriptor(args.id, action="cancel", reversible=True)))
|
|
564
|
+
return EXIT_OK
|
|
565
|
+
|
|
566
|
+
card = cancel_card(args.id)
|
|
567
|
+
if getattr(args, "json", False):
|
|
568
|
+
print(_json_out(_card_to_dict(card)))
|
|
569
|
+
else:
|
|
570
|
+
print(f"Cancelled: {card.id} (last4={card.last4}, status={card.status.value})")
|
|
571
|
+
return EXIT_OK
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _cmd_close(args: argparse.Namespace) -> int:
|
|
575
|
+
from .cards import close_card
|
|
576
|
+
|
|
577
|
+
if getattr(args, "dry_run", False):
|
|
578
|
+
_info(f"[dry-run] would close {args.id} (PERMANENT, not reversible). No API call made.")
|
|
579
|
+
print(_json_out(_bodyless_descriptor(args.id, action="close", reversible=False)))
|
|
580
|
+
return EXIT_OK
|
|
581
|
+
|
|
582
|
+
_info(f"WARNING: Closing card {args.id} is permanent and cannot be undone.")
|
|
583
|
+
if not _confirm("Proceed? [y/N] ", yes=getattr(args, "yes", False)):
|
|
584
|
+
_info("Cancelled.")
|
|
585
|
+
return EXIT_ERROR
|
|
586
|
+
|
|
587
|
+
card = close_card(args.id)
|
|
588
|
+
if getattr(args, "json", False):
|
|
589
|
+
print(_json_out(_card_to_dict(card)))
|
|
590
|
+
else:
|
|
591
|
+
print(f"Closed: {card.id} (last4={card.last4}, status={card.status.value})")
|
|
592
|
+
return EXIT_OK
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _cmd_reconcile(args: argparse.Namespace) -> int:
|
|
596
|
+
from .cards import reconcile
|
|
597
|
+
|
|
598
|
+
result = reconcile()
|
|
599
|
+
if getattr(args, "json", False):
|
|
600
|
+
print(_json_out(result))
|
|
601
|
+
else:
|
|
602
|
+
adopted = result.get("adopted", [])
|
|
603
|
+
failed = result.get("failed", [])
|
|
604
|
+
if adopted:
|
|
605
|
+
print(f"Adopted {len(adopted)} card(s):")
|
|
606
|
+
for card_id in adopted:
|
|
607
|
+
print(f" {card_id}")
|
|
608
|
+
if failed:
|
|
609
|
+
print(f"Failed {len(failed)} pending row(s):")
|
|
610
|
+
for key in failed:
|
|
611
|
+
print(f" {key}")
|
|
612
|
+
if not adopted and not failed:
|
|
613
|
+
_info("No pending rows to reconcile.")
|
|
614
|
+
return EXIT_OK
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _cmd_status(args: argparse.Namespace) -> int:
|
|
618
|
+
from .client import disabled_status
|
|
619
|
+
|
|
620
|
+
status = disabled_status()
|
|
621
|
+
if getattr(args, "json", False):
|
|
622
|
+
if status is None:
|
|
623
|
+
print(_json_out({"disabled": False}))
|
|
624
|
+
else:
|
|
625
|
+
print(_json_out(status))
|
|
626
|
+
else:
|
|
627
|
+
if status is None:
|
|
628
|
+
print("Status: ENABLED")
|
|
629
|
+
else:
|
|
630
|
+
print("Status: DISABLED")
|
|
631
|
+
print(f"Reason: {status.get('reason', 'unknown')}")
|
|
632
|
+
if "timestamp" in status:
|
|
633
|
+
print(f"Since: {status['timestamp']}")
|
|
634
|
+
if "path" in status:
|
|
635
|
+
print(f"File: {status['path']}")
|
|
636
|
+
_info("\nTo re-enable: extendvcc clear-disabled --manual")
|
|
637
|
+
return EXIT_OK
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _cmd_clear_disabled(args: argparse.Namespace) -> int:
|
|
641
|
+
from .client import clear_disabled
|
|
642
|
+
|
|
643
|
+
manual = getattr(args, "manual", False)
|
|
644
|
+
if not manual:
|
|
645
|
+
raise CLIInputError(
|
|
646
|
+
"--manual flag is required to confirm re-enabling. Usage: extendvcc clear-disabled --manual"
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
removed = clear_disabled(manual=True)
|
|
650
|
+
if getattr(args, "json", False):
|
|
651
|
+
print(_json_out({"cleared": removed}))
|
|
652
|
+
else:
|
|
653
|
+
if removed:
|
|
654
|
+
_info("Kill switch cleared. Client re-enabled.")
|
|
655
|
+
else:
|
|
656
|
+
_info("No disabled-state file found. Client was already enabled.")
|
|
657
|
+
return EXIT_OK
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
# ---------------------------------------------------------------------------
|
|
661
|
+
# Parser construction
|
|
662
|
+
# ---------------------------------------------------------------------------
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
class _ArgumentParser(argparse.ArgumentParser):
|
|
666
|
+
"""ArgumentParser that exits with the stable EXIT_USAGE code on bad input.
|
|
667
|
+
|
|
668
|
+
argparse already exits 2, but we route it through the constant so the usage
|
|
669
|
+
contract is explicit and stays correct if the value ever changes.
|
|
670
|
+
"""
|
|
671
|
+
|
|
672
|
+
def error(self, message: str) -> Any: # noqa: D102
|
|
673
|
+
self.print_usage(sys.stderr)
|
|
674
|
+
self.exit(EXIT_USAGE, f"{self.prog}: error: {message}\n")
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
678
|
+
parser = _ArgumentParser(
|
|
679
|
+
prog="extendvcc",
|
|
680
|
+
description="Unofficial CLI for the Extend virtual card API.",
|
|
681
|
+
epilog=DISCLAIMER,
|
|
682
|
+
)
|
|
683
|
+
parser.add_argument("--state-dir", default=None, help="Override state directory")
|
|
684
|
+
parser.add_argument("--ledger", default=None, help="Override ledger file path")
|
|
685
|
+
parser.add_argument("--json", action="store_true", default=False, help="Machine-readable JSON output")
|
|
686
|
+
|
|
687
|
+
sub = parser.add_subparsers(dest="command")
|
|
688
|
+
|
|
689
|
+
# login
|
|
690
|
+
p = sub.add_parser("login", help="Authenticate with Extend (SRP + email OTP)")
|
|
691
|
+
p.add_argument("--email", default=None, help="Email (or EXTENDVCC_EMAIL env var)")
|
|
692
|
+
|
|
693
|
+
# accounts
|
|
694
|
+
sub.add_parser("accounts", help="List enrolled parent credit cards")
|
|
695
|
+
|
|
696
|
+
# enroll
|
|
697
|
+
p = sub.add_parser("enroll", help="Enroll a new parent credit card")
|
|
698
|
+
p.add_argument("--display-name", required=True, help="Display name for the card")
|
|
699
|
+
p.add_argument("--cardholder-name", required=True, help="Name on the card")
|
|
700
|
+
p.add_argument("--issuer-id", required=True, help="Issuer ID (see 'issuers' command)")
|
|
701
|
+
p.add_argument("--expires", required=True, help="Card expiration date (YYYY-MM-DD)")
|
|
702
|
+
p.add_argument("--address1", required=True, help="Billing address line 1")
|
|
703
|
+
p.add_argument("--address2", default="", help="Billing address line 2")
|
|
704
|
+
p.add_argument("--city", required=True, help="Billing city")
|
|
705
|
+
p.add_argument("--province", required=True, help="Billing state/province")
|
|
706
|
+
p.add_argument("--postal", required=True, help="Billing postal/ZIP code")
|
|
707
|
+
p.add_argument("--company-name", default=None, help="Company name (defaults to cardholder name)")
|
|
708
|
+
p.add_argument("--country", default="US", help="Country code (default: US)")
|
|
709
|
+
p.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt")
|
|
710
|
+
|
|
711
|
+
# activate
|
|
712
|
+
p = sub.add_parser("activate", help="Activate a verified parent credit card (PENDING -> ACTIVE)")
|
|
713
|
+
p.add_argument("id", help="Credit card ID")
|
|
714
|
+
|
|
715
|
+
# issuers
|
|
716
|
+
sub.add_parser("issuers", help="List card issuers")
|
|
717
|
+
|
|
718
|
+
# cards
|
|
719
|
+
p = sub.add_parser("cards", help="List virtual cards")
|
|
720
|
+
p.add_argument("--status", default=None, help="Filter by status (ACTIVE, CANCELLED, PENDING, etc.)")
|
|
721
|
+
|
|
722
|
+
# card
|
|
723
|
+
p = sub.add_parser("card", help="Get a single virtual card")
|
|
724
|
+
p.add_argument("id", help="Virtual card ID")
|
|
725
|
+
|
|
726
|
+
# usage
|
|
727
|
+
sub.add_parser("usage", help="Show active virtual card usage vs. limit")
|
|
728
|
+
|
|
729
|
+
# create
|
|
730
|
+
p = sub.add_parser("create", help="Create a virtual card (one-time or recurring)")
|
|
731
|
+
p.add_argument("--credit-card-id", required=True, help="Parent credit card ID")
|
|
732
|
+
p.add_argument("--name", required=True, help="Card display name")
|
|
733
|
+
p.add_argument("--balance-cents", required=True, type=int, help="Spending limit in cents")
|
|
734
|
+
p.add_argument("--recipient", default=None, help="Recipient email (defaults to account email)")
|
|
735
|
+
p.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt")
|
|
736
|
+
# One-time
|
|
737
|
+
p.add_argument("--valid-to", default=None, help="Expiration date YYYY-MM-DD (one-time card)")
|
|
738
|
+
# Recurring (mutually exclusive with --valid-to conceptually)
|
|
739
|
+
p.add_argument("--period", default=None, help="Recurrence period: DAILY, WEEKLY, or MONTHLY")
|
|
740
|
+
p.add_argument("--interval", type=int, default=1, help="Reset every N periods (default: 1)")
|
|
741
|
+
p.add_argument("--by-month-day", type=int, default=None, help="Day of month (1-31, MONTHLY only)")
|
|
742
|
+
p.add_argument("--by-week-day", type=int, default=None, help="Day of week (0-6, WEEKLY only)")
|
|
743
|
+
p.add_argument("--terminator", default=None, help="NONE, DATE, or COUNT")
|
|
744
|
+
p.add_argument("--until", default=None, help="End date YYYY-MM-DD (DATE terminator)")
|
|
745
|
+
p.add_argument("--count", type=int, default=None, help="Number of resets (COUNT terminator)")
|
|
746
|
+
p.add_argument("--dry-run", action="store_true", help="Preview the request body (no API call)")
|
|
747
|
+
|
|
748
|
+
# bulk
|
|
749
|
+
p = sub.add_parser("bulk", help="Bulk-create virtual cards from a CSV file")
|
|
750
|
+
p.add_argument("file", help="CSV file (columns: name, balance_cents, valid_to, [recipient])")
|
|
751
|
+
p.add_argument("--credit-card-id", required=True, help="Parent credit card ID")
|
|
752
|
+
p.add_argument("--delay", type=float, default=2.0, help="Mean delay between cards in seconds (default: 2.0)")
|
|
753
|
+
p.add_argument("--jitter", type=float, default=0.75, help="Delay jitter std-dev in seconds (default: 0.75)")
|
|
754
|
+
p.add_argument("--min-delay", type=float, default=0.5, help="Minimum delay between cards in seconds (default: 0.5)")
|
|
755
|
+
p.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt")
|
|
756
|
+
p.add_argument("--dry-run", action="store_true", help="Preview the request bodies (no API calls)")
|
|
757
|
+
|
|
758
|
+
# reveal
|
|
759
|
+
p = sub.add_parser("reveal", help="Reveal live card credentials (PAN, CVC, expiry)")
|
|
760
|
+
p.add_argument("id", help="Virtual card ID")
|
|
761
|
+
p.add_argument(
|
|
762
|
+
"--json-path",
|
|
763
|
+
default=None,
|
|
764
|
+
metavar="PATH",
|
|
765
|
+
help="Save full credentials to file (0600 perms) instead of printing masked",
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
# update
|
|
769
|
+
p = sub.add_parser("update", help="Update a virtual card (read-modify-write)")
|
|
770
|
+
p.add_argument("id", help="Virtual card ID")
|
|
771
|
+
p.add_argument("--balance-cents", type=int, default=None, help="New spending limit in cents")
|
|
772
|
+
p.add_argument("--name", default=None, help="New display name")
|
|
773
|
+
p.add_argument("--valid-to", default=None, help="New expiration date YYYY-MM-DD")
|
|
774
|
+
p.add_argument("--dry-run", action="store_true", help="Preview the merged PUT body (read-only GET, no mutation)")
|
|
775
|
+
|
|
776
|
+
# cancel
|
|
777
|
+
p = sub.add_parser("cancel", help="Cancel a virtual card (reversible)")
|
|
778
|
+
p.add_argument("id", help="Virtual card ID")
|
|
779
|
+
p.add_argument("--dry-run", action="store_true", help="Preview the operation descriptor (no API call)")
|
|
780
|
+
|
|
781
|
+
# close
|
|
782
|
+
p = sub.add_parser("close", help="Close a virtual card permanently")
|
|
783
|
+
p.add_argument("id", help="Virtual card ID")
|
|
784
|
+
p.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt")
|
|
785
|
+
p.add_argument("--dry-run", action="store_true", help="Preview the operation descriptor (no API call)")
|
|
786
|
+
|
|
787
|
+
# reconcile
|
|
788
|
+
sub.add_parser("reconcile", help="Resolve pending ledger rows against remote cards")
|
|
789
|
+
|
|
790
|
+
# status
|
|
791
|
+
sub.add_parser("status", help="Show kill-switch state")
|
|
792
|
+
|
|
793
|
+
# clear-disabled
|
|
794
|
+
p = sub.add_parser("clear-disabled", help="Re-enable client after kill-switch trip")
|
|
795
|
+
p.add_argument("--manual", action="store_true", help="Confirm manual override (required)")
|
|
796
|
+
|
|
797
|
+
return parser
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
# ---------------------------------------------------------------------------
|
|
801
|
+
# Entry point
|
|
802
|
+
# ---------------------------------------------------------------------------
|
|
803
|
+
|
|
804
|
+
_COMMANDS: dict[str, Any] = {
|
|
805
|
+
"login": _cmd_login,
|
|
806
|
+
"accounts": _cmd_accounts,
|
|
807
|
+
"enroll": _cmd_enroll,
|
|
808
|
+
"activate": _cmd_activate,
|
|
809
|
+
"issuers": _cmd_issuers,
|
|
810
|
+
"cards": _cmd_cards,
|
|
811
|
+
"card": _cmd_card,
|
|
812
|
+
"usage": _cmd_usage,
|
|
813
|
+
"create": _cmd_create,
|
|
814
|
+
"bulk": _cmd_bulk,
|
|
815
|
+
"reveal": _cmd_reveal,
|
|
816
|
+
"update": _cmd_update,
|
|
817
|
+
"cancel": _cmd_cancel,
|
|
818
|
+
"close": _cmd_close,
|
|
819
|
+
"reconcile": _cmd_reconcile,
|
|
820
|
+
"status": _cmd_status,
|
|
821
|
+
"clear-disabled": _cmd_clear_disabled,
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def main(argv: list[str] | None = None) -> int:
|
|
826
|
+
"""CLI entry point. Returns exit code."""
|
|
827
|
+
parser = _build_parser()
|
|
828
|
+
args = parser.parse_args(argv)
|
|
829
|
+
|
|
830
|
+
# No subcommand: help to stderr (keeps stdout JSON-only under --json), exit 2.
|
|
831
|
+
if not args.command:
|
|
832
|
+
parser.print_help(sys.stderr)
|
|
833
|
+
return EXIT_USAGE
|
|
834
|
+
|
|
835
|
+
# Configure paths before dispatching
|
|
836
|
+
from . import _paths
|
|
837
|
+
|
|
838
|
+
path_kwargs: dict[str, Any] = {}
|
|
839
|
+
if args.state_dir:
|
|
840
|
+
path_kwargs["state_dir"] = args.state_dir
|
|
841
|
+
if args.ledger:
|
|
842
|
+
path_kwargs["ledger_path"] = args.ledger
|
|
843
|
+
if path_kwargs:
|
|
844
|
+
_paths.configure(**path_kwargs)
|
|
845
|
+
|
|
846
|
+
handler = _COMMANDS.get(args.command)
|
|
847
|
+
if handler is None:
|
|
848
|
+
parser.print_help(sys.stderr)
|
|
849
|
+
return EXIT_USAGE
|
|
850
|
+
|
|
851
|
+
from .auth import PayWithExtendAuthError
|
|
852
|
+
from .client import PayWithExtendAPIError, PayWithExtendDisabled, PayWithExtendError
|
|
853
|
+
|
|
854
|
+
# Catch order is most-specific-first. Note auth errors are RuntimeError, NOT
|
|
855
|
+
# PayWithExtendError, so they are caught on their own branch.
|
|
856
|
+
try:
|
|
857
|
+
return handler(args)
|
|
858
|
+
except PayWithExtendDisabled as exc: # covers AccountRiskDetected
|
|
859
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
860
|
+
print("Hint: run 'extendvcc clear-disabled --manual' to re-enable.", file=sys.stderr)
|
|
861
|
+
return EXIT_DISABLED
|
|
862
|
+
except PayWithExtendAuthError as exc: # covers SessionNotFound, OTPRequired, UnexpectedChallenge
|
|
863
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
864
|
+
return EXIT_AUTH_REQUIRED
|
|
865
|
+
except PayWithExtendAPIError as exc:
|
|
866
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
867
|
+
return EXIT_API_ERROR
|
|
868
|
+
except PayWithExtendError as exc:
|
|
869
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
870
|
+
return EXIT_ERROR
|
|
871
|
+
except KeyboardInterrupt:
|
|
872
|
+
print("\nInterrupted.", file=sys.stderr)
|
|
873
|
+
return 130
|
|
874
|
+
except CLIInputError as exc: # CLI-owned validation -> usage error
|
|
875
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
876
|
+
return EXIT_USAGE
|
|
877
|
+
except ValueError as exc: # library-internal ValueError -> generic error, NOT usage
|
|
878
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
879
|
+
return EXIT_ERROR
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
if __name__ == "__main__":
|
|
883
|
+
raise SystemExit(main())
|