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.
- impreza_cli/__init__.py +14 -0
- impreza_cli/commands/__init__.py +7 -0
- impreza_cli/commands/_helpers.py +128 -0
- impreza_cli/commands/account.py +576 -0
- impreza_cli/commands/catalog.py +270 -0
- impreza_cli/commands/context.py +232 -0
- impreza_cli/commands/doctor.py +427 -0
- impreza_cli/commands/domain.py +858 -0
- impreza_cli/commands/invoice.py +198 -0
- impreza_cli/commands/key.py +104 -0
- impreza_cli/commands/orders.py +478 -0
- impreza_cli/commands/services.py +100 -0
- impreza_cli/commands/vps.py +812 -0
- impreza_cli/commands/vps_cloud.py +865 -0
- impreza_cli/commands/vps_proxmox.py +727 -0
- impreza_cli/commands/webhooks.py +483 -0
- impreza_cli/config.py +405 -0
- impreza_cli/main.py +100 -0
- impreza_cli/output.py +207 -0
- impreza_cli/sdk.py +97 -0
- impreza_cli/state.py +94 -0
- impreza_cli-0.3.0.dist-info/METADATA +296 -0
- impreza_cli-0.3.0.dist-info/RECORD +26 -0
- impreza_cli-0.3.0.dist-info/WHEEL +5 -0
- impreza_cli-0.3.0.dist-info/entry_points.txt +2 -0
- impreza_cli-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -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}.")
|