impreza-cli 0.3.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.
@@ -0,0 +1,858 @@
1
+ """``impreza domain`` subcommand surface.
2
+
3
+ Read commands shipped in 2.4 (`show / check / pricing` + `dns list`).
4
+ Write commands shipped in 3.1: domain registration / transfer /
5
+ nameservers / lock / id-protection / RAA / GDPR / transfer-approval
6
+ plus DNS CRUD on the `dns` sub-app.
7
+
8
+ ``impreza domain list`` is still deferred — the server has no
9
+ listing endpoint and the SDK has no ``c.domains.list()`` method.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ import typer
17
+ from impreza.exceptions import ApiError, InsufficientCredit, ResourceNotFound
18
+
19
+ from ..output import OutputFormat, error, info, print_dict, print_table, success
20
+ from ..sdk import make_client_or_exit
21
+ from ..state import confirm_or_exit, from_typer_context, resolve_output
22
+ from ._helpers import exit_on_api_error as _exit_on_api_error
23
+
24
+ app = typer.Typer(
25
+ name="domain",
26
+ help="Read domain registrations, check availability, and inspect DNS.",
27
+ no_args_is_help=True,
28
+ )
29
+
30
+ # Sub-app for DNS commands. `dns list` shipped in 2.4; CRUD and
31
+ # `dns activate` land in 3.1.
32
+ dns_app = typer.Typer(
33
+ name="dns",
34
+ help="Inspect and manage DNS records on registered domains.",
35
+ no_args_is_help=True,
36
+ )
37
+ app.add_typer(dns_app, name="dns")
38
+
39
+
40
+ def _exit_on_insufficient_credit(exc: InsufficientCredit) -> None:
41
+ """402 Insufficient Credit gets a special message that points
42
+ at the topup command, since "add money to your balance" is the
43
+ standard remediation. Once Phase 3 ships ``impreza account
44
+ topup`` (3.6), users can chain the suggestion directly."""
45
+ parts = [exc.message]
46
+ if exc.code:
47
+ parts.append(f"(code={exc.code})")
48
+ error(" ".join(parts))
49
+ error(
50
+ " -> Top up your balance with: "
51
+ "impreza account topup --amount <X> --method btc|xmr|trx|usdt"
52
+ )
53
+ raise typer.Exit(code=1)
54
+
55
+
56
+ def _try_lookup_register_price(
57
+ client: Any, domain: str, years: int
58
+ ) -> tuple[float, str] | None:
59
+ """Best-effort price lookup so the confirmation prompt can show
60
+ "$X.XX from your balance" instead of "<unknown cost>".
61
+
62
+ Returns ``None`` (silent fall-through) if the catalog call fails
63
+ or the TLD isn't priced — registration still works, we just
64
+ don't show the cost up front.
65
+ """
66
+ try:
67
+ # Extract the TLD (".com", ".net", ".com.br") for the filter.
68
+ tld = "." + domain.split(".", 1)[1] if "." in domain else None
69
+ if not tld:
70
+ return None
71
+ tlds = client.catalog.tlds(filter=tld)
72
+ if not tlds:
73
+ return None
74
+ prices = tlds[0].register_prices
75
+ per_year = prices.get(str(years)) or prices.get("1")
76
+ if per_year is None:
77
+ return None
78
+ # If only year-1 is priced and caller wants more years,
79
+ # multiply naively. Real price for N years may differ;
80
+ # surface this caveat in the prompt by including "≈".
81
+ if str(years) not in prices and years > 1:
82
+ per_year = float(per_year) * years
83
+ return float(per_year), tlds[0].currency
84
+ except Exception: # noqa: BLE001 - best-effort, never block the order on this
85
+ return None
86
+
87
+
88
+ def _year_1_price(prices: dict[str, float]) -> float | None:
89
+ raw = prices.get("1")
90
+ return float(raw) if raw is not None else None
91
+
92
+
93
+ # ── domain show ──────────────────────────────────────────────────────
94
+
95
+
96
+ @app.command("show")
97
+ def show(
98
+ typer_ctx: typer.Context,
99
+ domain: str = typer.Argument(..., help="Domain name to inspect (e.g. example.com)."),
100
+ output: OutputFormat | None = typer.Option(
101
+ None,
102
+ "--output",
103
+ "-o",
104
+ help="Output format. Overrides the global --output flag.",
105
+ case_sensitive=False,
106
+ ),
107
+ ) -> None:
108
+ """Show full registration details for a domain.
109
+
110
+ Wraps ``GET /domains/{domain}``. Renders status / expiry /
111
+ nameservers / lock state / ID protection / auto-renew. Most
112
+ fields are nullable upstream — table mode shows ``-`` for
113
+ unknown values rather than blanking the row.
114
+ """
115
+ state = from_typer_context(typer_ctx)
116
+ fmt = resolve_output(state, output)
117
+
118
+ with make_client_or_exit(state) as client:
119
+ try:
120
+ d = client.domains.get(domain)
121
+ except ResourceNotFound:
122
+ error(f"Domain {domain!r} is not registered to this account.")
123
+ raise typer.Exit(code=1) from None
124
+ except ApiError as exc:
125
+ _exit_on_api_error(exc)
126
+
127
+ data: dict[str, Any] = {
128
+ "domain": d.domain,
129
+ "status": d.status,
130
+ "registration_date": d.registration_date,
131
+ "expires_at": d.expires_at,
132
+ "next_due_date": d.next_due_date,
133
+ "nameservers": (
134
+ ", ".join(d.nameservers)
135
+ if fmt is OutputFormat.TABLE and d.nameservers
136
+ else d.nameservers
137
+ ),
138
+ "lock_status": d.lock_status,
139
+ "id_protection": d.id_protection,
140
+ "auto_renew": d.auto_renew,
141
+ "privacy": d.privacy,
142
+ "epp_code": d.epp_code,
143
+ }
144
+ print_dict(f"Domain {domain}", data, fmt=fmt)
145
+
146
+
147
+ # ── domain check ─────────────────────────────────────────────────────
148
+
149
+
150
+ @app.command("check")
151
+ def check(
152
+ typer_ctx: typer.Context,
153
+ domains: list[str] = typer.Argument(
154
+ ...,
155
+ help="One or more domain names to check (max 10 per call).",
156
+ ),
157
+ output: OutputFormat | None = typer.Option(
158
+ None,
159
+ "--output",
160
+ "-o",
161
+ help="Output format. Overrides the global --output flag.",
162
+ case_sensitive=False,
163
+ ),
164
+ ) -> None:
165
+ """Check availability for one or more domains in a single call.
166
+
167
+ Wraps ``GET /domains/check?domains=...``. The server caps the
168
+ batch at 10 domains per call — passing more raises an SDK
169
+ validation error before the round-trip. Table output sorts the
170
+ results by domain name; JSON / YAML preserve the input order.
171
+ """
172
+ state = from_typer_context(typer_ctx)
173
+ fmt = resolve_output(state, output)
174
+
175
+ with make_client_or_exit(state) as client:
176
+ try:
177
+ availability = client.domains.check(list(domains))
178
+ except ApiError as exc:
179
+ _exit_on_api_error(exc)
180
+
181
+ if fmt is OutputFormat.TABLE:
182
+ rows = [
183
+ {"domain": name, "available": availability.get(name, False)}
184
+ for name in sorted(availability.keys())
185
+ ]
186
+ print_table(
187
+ f"Availability ({len(rows)})",
188
+ rows,
189
+ columns=["domain", "available"],
190
+ fmt=fmt,
191
+ )
192
+ else:
193
+ # Stable input-order list for JSON; consumers shouldn't have
194
+ # to re-sort to align with the request.
195
+ rows = [
196
+ {"domain": name, "available": availability.get(name, False)}
197
+ for name in domains
198
+ ]
199
+ print_table("Availability", rows, fmt=fmt)
200
+
201
+
202
+ # ── domain pricing ───────────────────────────────────────────────────
203
+
204
+
205
+ @app.command("pricing")
206
+ def pricing(
207
+ typer_ctx: typer.Context,
208
+ filter_: str | None = typer.Option(
209
+ None,
210
+ "--filter",
211
+ "-f",
212
+ help=(
213
+ "Comma-separated list of TLDs (e.g. '.com,.net,.io'). "
214
+ "Without a filter, the full TLD catalog is returned."
215
+ ),
216
+ ),
217
+ output: OutputFormat | None = typer.Option(
218
+ None,
219
+ "--output",
220
+ "-o",
221
+ help="Output format. Overrides the global --output flag.",
222
+ case_sensitive=False,
223
+ ),
224
+ ) -> None:
225
+ """Show TLD register / renew pricing.
226
+
227
+ Functionally equivalent to ``impreza catalog tlds`` — same SDK
228
+ call, same render, mounted in the ``domain`` namespace for
229
+ muscle memory (people thinking about domain registrations
230
+ naturally type `impreza domain pricing`). The catalog version
231
+ stays the canonical entry point.
232
+ """
233
+ state = from_typer_context(typer_ctx)
234
+ fmt = resolve_output(state, output)
235
+
236
+ with make_client_or_exit(state) as client:
237
+ try:
238
+ tlds = client.catalog.tlds(filter=filter_)
239
+ except ApiError as exc:
240
+ _exit_on_api_error(exc)
241
+
242
+ if not tlds:
243
+ msg = (
244
+ f"No TLDs match the filter: {filter_!r}."
245
+ if filter_
246
+ else "No TLDs in the catalog yet."
247
+ )
248
+ typer.echo(msg)
249
+ return
250
+
251
+ if fmt is OutputFormat.TABLE:
252
+ rows: list[dict[str, Any]] = []
253
+ for t in tlds:
254
+ reg_1y = _year_1_price(t.register_prices)
255
+ ren_1y = _year_1_price(t.renew_prices)
256
+ rows.append(
257
+ {
258
+ "tld": t.tld,
259
+ "currency": t.currency,
260
+ "register_1y": (
261
+ f"{reg_1y:.2f}" if reg_1y is not None else "-"
262
+ ),
263
+ "renew_1y": (
264
+ f"{ren_1y:.2f}" if ren_1y is not None else "-"
265
+ ),
266
+ "cheapest": (
267
+ f"{t.cheapest:.2f}" if t.cheapest is not None else "-"
268
+ ),
269
+ "min_years": t.min_years,
270
+ }
271
+ )
272
+ print_table(
273
+ f"Domain pricing ({len(rows)})",
274
+ rows,
275
+ columns=[
276
+ "tld",
277
+ "currency",
278
+ "register_1y",
279
+ "renew_1y",
280
+ "cheapest",
281
+ "min_years",
282
+ ],
283
+ fmt=fmt,
284
+ )
285
+ else:
286
+ rows = [t.model_dump(by_alias=True) for t in tlds]
287
+ print_table("Domain pricing", rows, fmt=fmt)
288
+
289
+
290
+ # ── domain dns list ──────────────────────────────────────────────────
291
+
292
+
293
+ @dns_app.command("list")
294
+ def dns_list(
295
+ typer_ctx: typer.Context,
296
+ domain: str = typer.Argument(..., help="Domain whose DNS records to list."),
297
+ output: OutputFormat | None = typer.Option(
298
+ None,
299
+ "--output",
300
+ "-o",
301
+ help="Output format. Overrides the global --output flag.",
302
+ case_sensitive=False,
303
+ ),
304
+ ) -> None:
305
+ """List all DNS records for a domain.
306
+
307
+ Wraps ``GET /domains/{domain}/dns``. The domain must have DNS
308
+ management activated (``c.domains.activate_dns(domain)``). Empty
309
+ record lists are valid — a freshly-activated domain renders as
310
+ a friendly "no records" message rather than an empty table.
311
+
312
+ Write verbs (``add`` / ``update`` / ``delete``) land in Phase 3
313
+ alongside the rest of the mutating CLI surface.
314
+ """
315
+ state = from_typer_context(typer_ctx)
316
+ fmt = resolve_output(state, output)
317
+
318
+ with make_client_or_exit(state) as client:
319
+ try:
320
+ records = client.domains.dns.list(domain)
321
+ except ResourceNotFound:
322
+ error(
323
+ f"Domain {domain!r} not found, or DNS management is not "
324
+ "active on it. Activate first with: impreza domain dns "
325
+ "activate <domain>."
326
+ )
327
+ raise typer.Exit(code=1) from None
328
+ except ApiError as exc:
329
+ _exit_on_api_error(exc)
330
+
331
+ if not records:
332
+ typer.echo(f"No DNS records on {domain!r} yet.")
333
+ return
334
+
335
+ rows = [
336
+ {
337
+ "type": r.type,
338
+ "host": r.host,
339
+ "value": r.value,
340
+ "ttl": r.ttl,
341
+ "priority": r.priority,
342
+ }
343
+ for r in records
344
+ ]
345
+ print_table(
346
+ f"DNS records — {domain} ({len(rows)})",
347
+ rows,
348
+ columns=["type", "host", "value", "ttl", "priority"],
349
+ fmt=fmt,
350
+ )
351
+
352
+
353
+ # ═══════════════════════════════════════════════════════════════════
354
+ # WRITES (Phase 3.1)
355
+ # ═══════════════════════════════════════════════════════════════════
356
+
357
+
358
+ # ── domain register ─────────────────────────────────────────────────
359
+
360
+
361
+ @app.command("register")
362
+ def register(
363
+ typer_ctx: typer.Context,
364
+ domain: str = typer.Argument(..., help="Domain to register (e.g. example.com)."),
365
+ years: int = typer.Option(1, "--years", help="Registration period (1-10)."),
366
+ nameservers: list[str] | None = typer.Option(
367
+ None,
368
+ "--ns",
369
+ "--nameserver",
370
+ help=(
371
+ "Repeat to set nameservers at registration time. Defaults "
372
+ "to Impreza nameservers when not supplied."
373
+ ),
374
+ ),
375
+ yes: bool = typer.Option(
376
+ False, "--yes", "-y", help="Skip the cost-confirmation prompt."
377
+ ),
378
+ ) -> None:
379
+ """Register a new domain. Pays from account balance.
380
+
381
+ Wraps ``c.domains.register()``. Looks up the registration price
382
+ via the catalog before prompting so users see the charge upfront.
383
+ On success, prints the order id + invoice id.
384
+ """
385
+ state = from_typer_context(typer_ctx)
386
+
387
+ with make_client_or_exit(state) as client:
388
+ # Best-effort price lookup. Falls through silently if the
389
+ # TLD isn't priced — the registration still works.
390
+ price = _try_lookup_register_price(client, domain, years)
391
+
392
+ # Build the confirmation message with whatever pricing info
393
+ # we have. Always include the amount when available.
394
+ if price is not None:
395
+ amount, currency = price
396
+ try:
397
+ me = client.account.get()
398
+ balance_msg = f" (balance: {me.balance:.2f} {me.currency})"
399
+ except ApiError:
400
+ balance_msg = ""
401
+ msg = (
402
+ f"Register {domain!r} for {years} year(s) "
403
+ f"— {amount:.2f} {currency} from your balance{balance_msg}."
404
+ )
405
+ else:
406
+ msg = (
407
+ f"Register {domain!r} for {years} year(s). "
408
+ "Cost will be charged from your account balance."
409
+ )
410
+ confirm_or_exit(msg, yes=yes)
411
+
412
+ try:
413
+ result = client.domains.register(
414
+ domain=domain, years=years, nameservers=nameservers
415
+ )
416
+ except InsufficientCredit as exc:
417
+ _exit_on_insufficient_credit(exc)
418
+ except ApiError as exc:
419
+ _exit_on_api_error(exc)
420
+
421
+ success(
422
+ f"Registered {result.domain!r} — "
423
+ f"order #{result.order_id}, invoice #{result.invoice_id}, "
424
+ f"charged {result.amount:.2f} {result.currency}."
425
+ )
426
+
427
+
428
+ # ── domain transfer ─────────────────────────────────────────────────
429
+
430
+
431
+ @app.command("transfer")
432
+ def transfer(
433
+ typer_ctx: typer.Context,
434
+ domain: str = typer.Argument(..., help="Domain to transfer in."),
435
+ epp: str = typer.Option(
436
+ ..., "--epp", help="Authorisation / EPP code from the current registrar."
437
+ ),
438
+ years: int = typer.Option(1, "--years", help="Renewal period to add (default 1)."),
439
+ yes: bool = typer.Option(
440
+ False, "--yes", "-y", help="Skip the cost-confirmation prompt."
441
+ ),
442
+ ) -> None:
443
+ """Transfer a domain in. Pays from account balance.
444
+
445
+ Wraps ``c.domains.transfer()``. The EPP code is required and
446
+ must come from the losing registrar. Most TLDs have a 5-7 day
447
+ transfer window during which the gaining registrar (Impreza)
448
+ contacts the losing one — see ``impreza domain show <d>`` for
449
+ progress after transfer is initiated.
450
+ """
451
+ state = from_typer_context(typer_ctx)
452
+
453
+ with make_client_or_exit(state) as client:
454
+ price = _try_lookup_register_price(client, domain, years)
455
+ if price is not None:
456
+ amount, currency = price
457
+ msg = (
458
+ f"Transfer {domain!r} (renewal: {years} year) "
459
+ f"— ~{amount:.2f} {currency} from your balance."
460
+ )
461
+ else:
462
+ msg = (
463
+ f"Transfer {domain!r} (renewal: {years} year). "
464
+ "Cost will be charged from your account balance."
465
+ )
466
+ confirm_or_exit(msg, yes=yes)
467
+
468
+ try:
469
+ result = client.domains.transfer(
470
+ domain=domain, epp_code=epp, years=years
471
+ )
472
+ except InsufficientCredit as exc:
473
+ _exit_on_insufficient_credit(exc)
474
+ except ApiError as exc:
475
+ _exit_on_api_error(exc)
476
+
477
+ success(
478
+ f"Transfer initiated for {result.domain!r} — "
479
+ f"order #{result.order_id}, invoice #{result.invoice_id}, "
480
+ f"charged {result.amount:.2f} {result.currency}. "
481
+ "Check progress with: impreza domain show "
482
+ f"{result.domain}"
483
+ )
484
+
485
+
486
+ # ── domain set-nameservers ──────────────────────────────────────────
487
+
488
+
489
+ @app.command("set-nameservers")
490
+ def set_nameservers(
491
+ typer_ctx: typer.Context,
492
+ domain: str = typer.Argument(..., help="Domain to update."),
493
+ nameservers: list[str] = typer.Argument(
494
+ ...,
495
+ help="Nameserver hostnames (minimum 2). Pass each as a positional arg.",
496
+ ),
497
+ ) -> None:
498
+ """Replace the domain's nameservers (minimum 2).
499
+
500
+ Wraps ``c.domains.set_nameservers()``. Propagation across the
501
+ DNS hierarchy can take up to 48h after this returns; the
502
+ registry is updated immediately.
503
+ """
504
+ state = from_typer_context(typer_ctx)
505
+ if len(nameservers) < 2:
506
+ error("at least 2 nameservers are required")
507
+ raise typer.Exit(code=1)
508
+
509
+ with make_client_or_exit(state) as client:
510
+ try:
511
+ client.domains.set_nameservers(domain, list(nameservers))
512
+ except ApiError as exc:
513
+ _exit_on_api_error(exc)
514
+
515
+ success(
516
+ f"Nameservers for {domain!r} set to: {', '.join(nameservers)}."
517
+ )
518
+
519
+
520
+ # ── domain lock / unlock ────────────────────────────────────────────
521
+
522
+
523
+ @app.command("lock")
524
+ def lock(
525
+ typer_ctx: typer.Context,
526
+ domain: str = typer.Argument(..., help="Domain to lock."),
527
+ ) -> None:
528
+ """Enable transfer lock. Prevents the domain from being
529
+ transferred to another registrar without first unlocking.
530
+
531
+ Wraps ``c.domains.lock()``.
532
+ """
533
+ state = from_typer_context(typer_ctx)
534
+ with make_client_or_exit(state) as client:
535
+ try:
536
+ client.domains.lock(domain)
537
+ except ApiError as exc:
538
+ _exit_on_api_error(exc)
539
+ success(f"Transfer lock enabled on {domain!r}.")
540
+
541
+
542
+ @app.command("unlock")
543
+ def unlock(
544
+ typer_ctx: typer.Context,
545
+ domain: str = typer.Argument(..., help="Domain to unlock."),
546
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the warning prompt."),
547
+ ) -> None:
548
+ """Disable transfer lock and return the EPP / authorisation
549
+ code needed to initiate a transfer at another registrar.
550
+
551
+ Wraps ``c.domains.unlock()``. Unlocking is a security-sensitive
552
+ action — anyone with the EPP code can pull your domain — so we
553
+ confirm by default. Re-lock with ``impreza domain lock`` after
554
+ you're done.
555
+ """
556
+ state = from_typer_context(typer_ctx)
557
+ confirm_or_exit(
558
+ f"Unlocking {domain!r} returns the EPP code, which authorises "
559
+ "transfers away from Impreza. Anyone with the code can move "
560
+ "the domain.",
561
+ yes=yes,
562
+ )
563
+ with make_client_or_exit(state) as client:
564
+ try:
565
+ epp = client.domains.unlock(domain)
566
+ except ApiError as exc:
567
+ _exit_on_api_error(exc)
568
+ success(f"Transfer lock disabled on {domain!r}.")
569
+ info(f" EPP / auth code: {epp}")
570
+
571
+
572
+ # ── domain id-protection ────────────────────────────────────────────
573
+
574
+
575
+ @app.command("id-protection")
576
+ def id_protection(
577
+ typer_ctx: typer.Context,
578
+ domain: str = typer.Argument(..., help="Domain to protect."),
579
+ yes: bool = typer.Option(
580
+ False, "--yes", "-y", help="Skip the cost-confirmation prompt."
581
+ ),
582
+ ) -> None:
583
+ """Purchase WHOIS Privacy / ID protection. Pays from account
584
+ balance.
585
+
586
+ Wraps ``c.domains.purchase_id_protection()``. Hides the
587
+ registrant contact details from public WHOIS lookups. Some TLDs
588
+ (e.g. ``.us``, certain ccTLDs) don't support privacy at the
589
+ registry level — the API returns 400 with a descriptive
590
+ message in that case.
591
+ """
592
+ state = from_typer_context(typer_ctx)
593
+ confirm_or_exit(
594
+ f"Purchase ID protection for {domain!r}. "
595
+ "Cost will be charged from your account balance.",
596
+ yes=yes,
597
+ )
598
+ with make_client_or_exit(state) as client:
599
+ try:
600
+ result = client.domains.purchase_id_protection(domain)
601
+ except InsufficientCredit as exc:
602
+ _exit_on_insufficient_credit(exc)
603
+ except ApiError as exc:
604
+ _exit_on_api_error(exc)
605
+
606
+ # The SDK returns the raw `data` dict — fields vary by upstream
607
+ # response; surface the whole thing as a small key/value table
608
+ # so users see whatever the registrar reported.
609
+ if isinstance(result, dict) and result:
610
+ rows = {str(k): v for k, v in result.items()}
611
+ print_dict(f"ID protection — {domain}", rows, fmt=OutputFormat.TABLE)
612
+ else:
613
+ success(f"ID protection purchased for {domain!r}.")
614
+
615
+
616
+ # ── domain raa-verify / gdpr-auth / transfer-approval ───────────────
617
+
618
+
619
+ @app.command("raa-verify")
620
+ def raa_verify(
621
+ typer_ctx: typer.Context,
622
+ domain: str = typer.Argument(..., help="Domain awaiting RAA verification."),
623
+ ) -> None:
624
+ """Resend the ICANN RAA email-verification message.
625
+
626
+ Wraps ``c.domains.resend_raa_verification()``. Required after
627
+ registration to confirm the registrant email address; without
628
+ it, ICANN suspends the domain after 15 days.
629
+ """
630
+ state = from_typer_context(typer_ctx)
631
+ with make_client_or_exit(state) as client:
632
+ try:
633
+ client.domains.resend_raa_verification(domain)
634
+ except ApiError as exc:
635
+ _exit_on_api_error(exc)
636
+ success(f"RAA verification email resent for {domain!r}.")
637
+
638
+
639
+ @app.command("gdpr-auth")
640
+ def gdpr_auth(
641
+ typer_ctx: typer.Context,
642
+ domain: str = typer.Argument(..., help="Domain awaiting GDPR authorisation."),
643
+ ) -> None:
644
+ """Resend the GDPR data-processing authorisation email.
645
+
646
+ Wraps ``c.domains.resend_gdpr_auth()``. Required for EU-resident
647
+ registrants on certain TLDs.
648
+ """
649
+ state = from_typer_context(typer_ctx)
650
+ with make_client_or_exit(state) as client:
651
+ try:
652
+ client.domains.resend_gdpr_auth(domain)
653
+ except ApiError as exc:
654
+ _exit_on_api_error(exc)
655
+ success(f"GDPR authorisation email resent for {domain!r}.")
656
+
657
+
658
+ @app.command("transfer-approval")
659
+ def transfer_approval(
660
+ typer_ctx: typer.Context,
661
+ domain: str = typer.Argument(..., help="Domain awaiting transfer approval."),
662
+ ) -> None:
663
+ """Resend the inbound-transfer approval email.
664
+
665
+ Wraps ``c.domains.resend_transfer_approval()``. Sent to the
666
+ registrant's WHOIS email by the gaining registrar; users
667
+ sometimes miss it, this command resends.
668
+ """
669
+ state = from_typer_context(typer_ctx)
670
+ with make_client_or_exit(state) as client:
671
+ try:
672
+ client.domains.resend_transfer_approval(domain)
673
+ except ApiError as exc:
674
+ _exit_on_api_error(exc)
675
+ success(f"Transfer approval email resent for {domain!r}.")
676
+
677
+
678
+ # ═══════════════════════════════════════════════════════════════════
679
+ # DNS CRUD (Phase 3.1)
680
+ # ═══════════════════════════════════════════════════════════════════
681
+
682
+
683
+ _DNS_TYPES = ["A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV"]
684
+
685
+
686
+ # ── dns activate ────────────────────────────────────────────────────
687
+
688
+
689
+ @dns_app.command("activate")
690
+ def dns_activate(
691
+ typer_ctx: typer.Context,
692
+ domain: str = typer.Argument(..., help="Domain to activate DNS management on."),
693
+ ) -> None:
694
+ """Activate DNS management on a registered domain.
695
+
696
+ Wraps ``c.domains.activate_dns()``. Required exactly once before
697
+ ``dns add`` / ``update`` / ``delete`` will succeed. After this,
698
+ the registry's NS records point at Impreza's nameservers and
699
+ record CRUD becomes available via the API.
700
+ """
701
+ state = from_typer_context(typer_ctx)
702
+ with make_client_or_exit(state) as client:
703
+ try:
704
+ client.domains.activate_dns(domain)
705
+ except ApiError as exc:
706
+ _exit_on_api_error(exc)
707
+ success(f"DNS management activated on {domain!r}.")
708
+
709
+
710
+ # ── dns add ─────────────────────────────────────────────────────────
711
+
712
+
713
+ @dns_app.command("add")
714
+ def dns_add(
715
+ typer_ctx: typer.Context,
716
+ domain: str = typer.Argument(..., help="Domain to add the record to."),
717
+ type_: str = typer.Option(
718
+ ...,
719
+ "--type",
720
+ help=f"Record type. One of: {', '.join(_DNS_TYPES)}.",
721
+ ),
722
+ name: str = typer.Option(
723
+ ...,
724
+ "--name",
725
+ help='Record host. Use "@" for the apex.',
726
+ ),
727
+ value: str = typer.Option(..., "--value", help="Record value."),
728
+ ttl: int | None = typer.Option(
729
+ None, "--ttl", help="TTL in seconds. Server default if omitted."
730
+ ),
731
+ priority: int | None = typer.Option(
732
+ None,
733
+ "--priority",
734
+ help="Required for MX; ignored for other types.",
735
+ ),
736
+ ) -> None:
737
+ """Add a DNS record.
738
+
739
+ Wraps ``c.domains.dns.add()``. The SDK rejects invalid record
740
+ types client-side, so a typo in ``--type`` errors before any
741
+ network round-trip.
742
+ """
743
+ state = from_typer_context(typer_ctx)
744
+ with make_client_or_exit(state) as client:
745
+ try:
746
+ client.domains.dns.add(
747
+ domain,
748
+ type=type_,
749
+ host=name,
750
+ value=value,
751
+ ttl=ttl,
752
+ priority=priority,
753
+ )
754
+ except ValueError as exc:
755
+ error(str(exc))
756
+ raise typer.Exit(code=1) from None
757
+ except ApiError as exc:
758
+ _exit_on_api_error(exc)
759
+ success(
760
+ f"Added {type_} record on {domain!r}: {name} -> {value}"
761
+ + (f" (TTL {ttl}s)" if ttl else "")
762
+ + (f" priority={priority}" if priority is not None else "")
763
+ )
764
+
765
+
766
+ # ── dns update ──────────────────────────────────────────────────────
767
+
768
+
769
+ @dns_app.command("update")
770
+ def dns_update(
771
+ typer_ctx: typer.Context,
772
+ domain: str = typer.Argument(..., help="Domain whose record to update."),
773
+ type_: str = typer.Option(..., "--type", help="Record type."),
774
+ name: str = typer.Option(..., "--name", help='Record host (use "@" for apex).'),
775
+ old_value: str = typer.Option(
776
+ ...,
777
+ "--old-value",
778
+ help="Current value. Used as the match key — must equal what's currently in DNS.",
779
+ ),
780
+ new_value: str = typer.Option(..., "--new-value", help="Replacement value."),
781
+ ttl: int | None = typer.Option(None, "--ttl", help="New TTL in seconds (optional)."),
782
+ priority: int | None = typer.Option(
783
+ None, "--priority", help="New MX priority (optional, MX only)."
784
+ ),
785
+ ) -> None:
786
+ """Update a DNS record by matching ``type + name + old-value``.
787
+
788
+ Wraps ``c.domains.dns.update()``. The match is exact —
789
+ case-sensitive on host, exact-byte on value. If the record
790
+ doesn't match, the API returns 404 (caught and surfaced).
791
+ """
792
+ state = from_typer_context(typer_ctx)
793
+ with make_client_or_exit(state) as client:
794
+ try:
795
+ client.domains.dns.update(
796
+ domain,
797
+ type=type_,
798
+ host=name,
799
+ old_value=old_value,
800
+ new_value=new_value,
801
+ ttl=ttl,
802
+ priority=priority,
803
+ )
804
+ except ValueError as exc:
805
+ error(str(exc))
806
+ raise typer.Exit(code=1) from None
807
+ except ResourceNotFound:
808
+ error(
809
+ f"No matching {type_} record on {domain!r} with "
810
+ f"name={name!r} value={old_value!r}."
811
+ )
812
+ raise typer.Exit(code=1) from None
813
+ except ApiError as exc:
814
+ _exit_on_api_error(exc)
815
+ success(
816
+ f"Updated {type_} record on {domain!r}: "
817
+ f"{name} -> {old_value!r} replaced with {new_value!r}."
818
+ )
819
+
820
+
821
+ # ── dns delete ──────────────────────────────────────────────────────
822
+
823
+
824
+ @dns_app.command("delete")
825
+ def dns_delete(
826
+ typer_ctx: typer.Context,
827
+ domain: str = typer.Argument(..., help="Domain whose record to delete."),
828
+ type_: str = typer.Option(..., "--type", help="Record type."),
829
+ name: str = typer.Option(..., "--name", help='Record host (use "@" for apex).'),
830
+ value: str = typer.Option(..., "--value", help="Record value (exact match required)."),
831
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
832
+ ) -> None:
833
+ """Delete a DNS record by exact match of ``type + name + value``.
834
+
835
+ Wraps ``c.domains.dns.delete()``. Confirms by default since the
836
+ deletion is immediate and can break dependent services (e.g.
837
+ deleting an MX record affects mail delivery).
838
+ """
839
+ state = from_typer_context(typer_ctx)
840
+ confirm_or_exit(
841
+ f"Delete {type_} record on {domain!r}: {name} -> {value!r}.",
842
+ yes=yes,
843
+ )
844
+ with make_client_or_exit(state) as client:
845
+ try:
846
+ client.domains.dns.delete(domain, type=type_, host=name, value=value)
847
+ except ValueError as exc:
848
+ error(str(exc))
849
+ raise typer.Exit(code=1) from None
850
+ except ResourceNotFound:
851
+ error(
852
+ f"No matching {type_} record on {domain!r} with "
853
+ f"name={name!r} value={value!r}."
854
+ )
855
+ raise typer.Exit(code=1) from None
856
+ except ApiError as exc:
857
+ _exit_on_api_error(exc)
858
+ success(f"Deleted {type_} record on {domain!r}: {name} -> {value!r}.")