certifyclouds 1.2.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,74 @@
1
+ """CSV formatter for scan results."""
2
+ from __future__ import annotations
3
+
4
+ import csv
5
+ import io
6
+ from datetime import datetime, timezone
7
+ from typing import Any
8
+
9
+
10
+ def format_csv(
11
+ results: dict[str, Any],
12
+ findings: list[dict[str, Any]],
13
+ output_file: str | None = None,
14
+ ) -> str | None:
15
+ """Format scan results as flat CSV (one row per asset).
16
+
17
+ Args:
18
+ results: Scan results dict
19
+ findings: Security findings list (not included in CSV — use JSON for that)
20
+ output_file: If set, write to file
21
+ """
22
+ output = io.StringIO() if not output_file else open(output_file, "w", newline="", encoding="utf-8")
23
+
24
+ writer = csv.writer(output)
25
+ writer.writerow([
26
+ "subscription", "vault", "type", "name", "enabled", "expires",
27
+ "days_until_expiry", "content_type", "version", "tags",
28
+ ])
29
+
30
+ now = datetime.now(timezone.utc)
31
+
32
+ for sub in results.get("subscriptions", []):
33
+ sub_name = sub.get("name", sub.get("id", ""))
34
+ for vault in sub.get("vaults", []):
35
+ vault_name = vault.get("name", "")
36
+ for item_type, items in [
37
+ ("secret", vault.get("secrets", [])),
38
+ ("certificate", vault.get("certificates", [])),
39
+ ("key", vault.get("keys", [])),
40
+ ]:
41
+ for item in items:
42
+ days_left = ""
43
+ expires_on = item.get("expiresOn")
44
+ if expires_on:
45
+ try:
46
+ expiry = datetime.fromisoformat(expires_on.replace("Z", "+00:00"))
47
+ if expiry.tzinfo is None:
48
+ expiry = expiry.replace(tzinfo=timezone.utc)
49
+ days_left = str((expiry - now).days)
50
+ except (ValueError, TypeError):
51
+ pass
52
+
53
+ tags = item.get("tags", {})
54
+ tags_str = "; ".join(f"{k}={v}" for k, v in tags.items()) if tags else ""
55
+
56
+ writer.writerow([
57
+ sub_name,
58
+ vault_name,
59
+ item_type,
60
+ item.get("name", ""),
61
+ item.get("enabled", ""),
62
+ expires_on or "",
63
+ days_left,
64
+ item.get("contentType", ""),
65
+ item.get("version", ""),
66
+ tags_str,
67
+ ])
68
+
69
+ if output_file:
70
+ output.close()
71
+ else:
72
+ print(output.getvalue(), end="")
73
+
74
+ return None
@@ -0,0 +1,187 @@
1
+ """HTML report formatter for scan results."""
2
+ from __future__ import annotations
3
+
4
+ import html
5
+ from datetime import datetime, timezone
6
+ from typing import Any
7
+
8
+ from .. import __version__
9
+ from ..filters import compute_expiry_summary
10
+
11
+ SEVERITY_COLORS = {
12
+ "CRITICAL": "#ef4444",
13
+ "HIGH": "#f97316",
14
+ "MEDIUM": "#eab308",
15
+ "LOW": "#3b82f6",
16
+ "INFO": "#6b7280",
17
+ }
18
+
19
+ STATUS_COLORS = {
20
+ "expired": "#ef4444",
21
+ "7_days": "#f97316",
22
+ "30_days": "#eab308",
23
+ "90_days": "#a3e635",
24
+ "ok": "#22c55e",
25
+ "no_expiry": "#6b7280",
26
+ }
27
+
28
+
29
+ def format_html(
30
+ results: dict[str, Any],
31
+ findings: list[dict[str, Any]],
32
+ output_file: str | None = None,
33
+ ) -> str | None:
34
+ """Format scan results as a standalone HTML report."""
35
+ scan_time = results.get("scannedAt", datetime.now(timezone.utc).isoformat())
36
+ total = results.get("totalSecrets", 0) + results.get("totalCertificates", 0) + results.get("totalKeys", 0)
37
+ expiry = compute_expiry_summary(results)
38
+ # Build HTML
39
+ h = []
40
+ h.append("<!DOCTYPE html>")
41
+ h.append('<html lang="en"><head><meta charset="utf-8">')
42
+ h.append('<meta name="viewport" content="width=device-width, initial-scale=1.0">')
43
+ h.append(f"<title>CertifyClouds Scanner Report — {scan_time[:10]}</title>")
44
+ h.append(f"<style>{_css()}</style>")
45
+ h.append("</head><body>")
46
+
47
+ # Header
48
+ h.append('<header><h1>CertifyClouds Scanner Report</h1>')
49
+ h.append(f'<p>Generated: {scan_time} | Scanner v{__version__}</p></header>')
50
+
51
+ # Summary cards
52
+ h.append('<section class="cards">')
53
+ for label, value in [
54
+ ("Subscriptions", len(results.get("subscriptions", []))),
55
+ ("Vaults", results.get("totalVaults", 0)),
56
+ ("Secrets", results.get("totalSecrets", 0)),
57
+ ("Certificates", results.get("totalCertificates", 0)),
58
+ ("Keys", results.get("totalKeys", 0)),
59
+ ]:
60
+ h.append(f'<div class="card"><div class="card-value">{value}</div><div class="card-label">{label}</div></div>')
61
+ h.append("</section>")
62
+
63
+ # Expiry breakdown
64
+ h.append("<section><h2>Expiry Status</h2>")
65
+ h.append('<div class="expiry-bars">')
66
+ for bucket, label in [("expired", "Expired"), ("7_days", "< 7 days"), ("30_days", "< 30 days"), ("90_days", "< 90 days"), ("ok", "OK"), ("no_expiry", "No expiry")]:
67
+ count = sum(expiry[bucket].values())
68
+ color = STATUS_COLORS[bucket]
69
+ width = min(max(count / max(total, 1) * 100, 2), 100) if count > 0 else 0
70
+ h.append(f'<div class="bar-row"><span class="bar-label">{label}</span>')
71
+ h.append(f'<div class="bar" style="width:{width}%;background:{color};">{count}</div></div>')
72
+ h.append("</div></section>")
73
+
74
+ # Security findings
75
+ if findings:
76
+ h.append(f"<section><h2>Security Findings ({len(findings)})</h2>")
77
+ h.append('<table><tr><th>Severity</th><th>Finding</th><th>Resource</th><th>Recommendation</th></tr>')
78
+ for f in findings:
79
+ color = SEVERITY_COLORS.get(f["severity"], "#6b7280")
80
+ h.append(f'<tr><td><span class="badge" style="background:{color}">{html.escape(f["severity"])}</span></td>')
81
+ h.append(f'<td>{html.escape(f["title"])}</td>')
82
+ h.append(f'<td>{html.escape(f["resource"])}</td>')
83
+ h.append(f'<td>{html.escape(f.get("recommendation", ""))}</td></tr>')
84
+ h.append("</table></section>")
85
+
86
+ # Per-vault details
87
+ h.append("<section><h2>Vault Details</h2>")
88
+ for sub in results.get("subscriptions", []):
89
+ sub_name = html.escape(sub.get("name", sub.get("id", "")))
90
+ h.append(f"<h3>{sub_name}</h3>")
91
+ for vault in sub.get("vaults", []):
92
+ vault_name = html.escape(vault.get("name", ""))
93
+ location = html.escape(vault.get("location", ""))
94
+ error = vault.get("error")
95
+ h.append(f'<h4>{vault_name} <span class="dim">({location})</span></h4>')
96
+ if error:
97
+ h.append(f'<p class="error">{html.escape(error)}</p>')
98
+ continue
99
+
100
+ all_items = []
101
+ for item in vault.get("secrets", []):
102
+ all_items.append(("secret", item))
103
+ for item in vault.get("certificates", []):
104
+ all_items.append(("certificate", item))
105
+ for item in vault.get("keys", []):
106
+ all_items.append(("key", item))
107
+
108
+ if not all_items:
109
+ h.append("<p>No items found.</p>")
110
+ continue
111
+
112
+ h.append('<table><tr><th>Name</th><th>Type</th><th>Enabled</th><th>Expires</th><th>Status</th></tr>')
113
+ now = datetime.now(timezone.utc)
114
+ for item_type, item in all_items:
115
+ name = html.escape(item.get("name", ""))
116
+ enabled = "Yes" if item.get("enabled") else "No"
117
+ expires = item.get("expiresOn", "")
118
+ status = ""
119
+ if expires:
120
+ try:
121
+ exp_dt = datetime.fromisoformat(expires.replace("Z", "+00:00"))
122
+ if exp_dt.tzinfo is None:
123
+ exp_dt = exp_dt.replace(tzinfo=timezone.utc)
124
+ days = (exp_dt - now).days
125
+ if days < 0:
126
+ status = f'<span style="color:#ef4444">Expired ({abs(days)}d ago)</span>'
127
+ elif days <= 30:
128
+ status = f'<span style="color:#eab308">{days}d left</span>'
129
+ else:
130
+ status = f"{days}d left"
131
+ expires = expires[:10]
132
+ except (ValueError, TypeError):
133
+ pass
134
+ else:
135
+ status = '<span style="color:#6b7280">No expiry</span>'
136
+ h.append(f"<tr><td>{name}</td><td>{item_type}</td><td>{enabled}</td><td>{expires}</td><td>{status}</td></tr>")
137
+ h.append("</table>")
138
+ h.append("</section>")
139
+
140
+ # Footer
141
+ h.append('<footer><p>Generated by <a href="https://certifyclouds.com/scanner">CertifyClouds Scanner</a>')
142
+ h.append(' — Automate rotation and compliance with <a href="https://certifyclouds.com/pro">CertifyClouds Pro</a></p></footer>')
143
+ h.append("</body></html>")
144
+
145
+ html_str = "\n".join(h)
146
+
147
+ if output_file:
148
+ with open(output_file, "w", encoding="utf-8") as f:
149
+ f.write(html_str)
150
+ return None
151
+
152
+ print(html_str)
153
+ return None
154
+
155
+
156
+ def _css() -> str:
157
+ """Return embedded CSS for the HTML report."""
158
+ return """
159
+ *{margin:0;padding:0;box-sizing:border-box}
160
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f0f1a;color:#e2e8f0;padding:2rem;line-height:1.6}
161
+ header{text-align:center;padding:2rem 0;border-bottom:1px solid rgba(139,92,246,0.3);margin-bottom:2rem}
162
+ header h1{font-size:1.8rem;background:linear-gradient(135deg,#8b5cf6,#06b6d4);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
163
+ header p{color:#94a3b8;font-size:0.85rem;margin-top:0.5rem}
164
+ section{margin-bottom:2rem}
165
+ h2{font-size:1.3rem;color:#e2e8f0;margin-bottom:1rem;padding-bottom:0.5rem;border-bottom:1px solid rgba(139,92,246,0.2)}
166
+ h3{font-size:1.1rem;color:#8b5cf6;margin:1.5rem 0 0.5rem}
167
+ h4{font-size:1rem;color:#cbd5e1;margin:1rem 0 0.5rem}
168
+ .cards{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:2rem}
169
+ .card{background:#1a1a2e;border:1px solid rgba(139,92,246,0.2);border-radius:8px;padding:1.2rem;text-align:center;flex:1;min-width:120px}
170
+ .card-value{font-size:2rem;font-weight:700;color:#8b5cf6}
171
+ .card-label{font-size:0.8rem;color:#94a3b8;margin-top:0.3rem}
172
+ table{width:100%;border-collapse:collapse;margin:0.5rem 0 1rem}
173
+ th{text-align:left;padding:0.5rem 0.75rem;background:#1a1a2e;color:#94a3b8;font-size:0.8rem;text-transform:uppercase;letter-spacing:0.05em}
174
+ td{padding:0.5rem 0.75rem;border-bottom:1px solid rgba(255,255,255,0.05);font-size:0.85rem}
175
+ tr:hover{background:rgba(139,92,246,0.05)}
176
+ .badge{display:inline-block;padding:0.15rem 0.6rem;border-radius:10px;color:#fff;font-size:0.75rem;font-weight:600}
177
+ .dim{color:#64748b}
178
+ .error{color:#ef4444;font-size:0.85rem}
179
+ .expiry-bars{display:flex;flex-direction:column;gap:0.4rem}
180
+ .bar-row{display:flex;align-items:center;gap:0.75rem}
181
+ .bar-label{width:80px;text-align:right;font-size:0.8rem;color:#94a3b8}
182
+ .bar{height:24px;border-radius:4px;color:#fff;font-size:0.75rem;display:flex;align-items:center;padding-left:0.5rem;min-width:fit-content}
183
+ footer{text-align:center;padding:2rem 0;border-top:1px solid rgba(139,92,246,0.2);margin-top:2rem;color:#64748b;font-size:0.8rem}
184
+ footer a{color:#8b5cf6;text-decoration:none}
185
+ @media print{body{background:#fff;color:#1a1a2e}header h1{-webkit-text-fill-color:#8b5cf6}.card{border-color:#e2e8f0}th{background:#f1f5f9}}
186
+ @media(max-width:768px){.cards{flex-direction:column}body{padding:1rem}table{font-size:0.75rem}}
187
+ """
@@ -0,0 +1,36 @@
1
+ """JSON formatter for scan results."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from typing import Any
6
+
7
+
8
+ def format_json(
9
+ results: dict[str, Any],
10
+ findings: list[dict[str, Any]],
11
+ output_file: str | None = None,
12
+ ) -> str | None:
13
+ """Format scan results as JSON.
14
+
15
+ Args:
16
+ results: Scan results dict
17
+ findings: Security findings list
18
+ output_file: If set, write to file
19
+
20
+ Returns:
21
+ JSON string if no output_file, None otherwise
22
+ """
23
+ output = {
24
+ **results,
25
+ "securityFindings": findings,
26
+ }
27
+
28
+ json_str = json.dumps(output, indent=2, default=str)
29
+
30
+ if output_file:
31
+ with open(output_file, "w", encoding="utf-8") as f:
32
+ f.write(json_str)
33
+ return None
34
+
35
+ print(json_str)
36
+ return None
@@ -0,0 +1,203 @@
1
+ """Rich terminal table formatter for scan results."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+ from rich.text import Text
9
+ from rich.tree import Tree
10
+
11
+ from ..filters import compute_expiry_summary
12
+ from ..security import count_by_severity
13
+
14
+ SEVERITY_COLORS = {
15
+ "CRITICAL": "bold red",
16
+ "HIGH": "red",
17
+ "MEDIUM": "yellow",
18
+ "LOW": "blue",
19
+ "INFO": "dim",
20
+ }
21
+
22
+
23
+ def format_table(
24
+ results: dict[str, Any],
25
+ findings: list[dict[str, Any]],
26
+ output_file: str | None = None,
27
+ ) -> str | None:
28
+ """Format scan results as Rich terminal output.
29
+
30
+ Args:
31
+ results: Scan results dict
32
+ findings: Security findings list
33
+ output_file: If set, write to file instead of stdout (falls back to plain text)
34
+
35
+ Returns:
36
+ None (prints to console) or string if output_file is set
37
+ """
38
+ console = Console(file=open(output_file, "w") if output_file else None, force_terminal=output_file is None)
39
+
40
+ # Check for scan error
41
+ if results.get("error"):
42
+ console.print(f"[red]Error: {results['error']}[/red]")
43
+ if output_file:
44
+ console.file.close()
45
+ return None
46
+
47
+ # Subscription tree
48
+ for sub in results.get("subscriptions", []):
49
+ sub_name = sub.get("name", sub.get("id", "unknown"))
50
+ tree = Tree(f"[bold]{sub_name}[/bold] ({sub.get('id', '')})")
51
+
52
+ for vault in sub.get("vaults", []):
53
+ vault_name = vault.get("name", "unknown")
54
+ location = vault.get("location", "")
55
+ error = vault.get("error")
56
+
57
+ if error:
58
+ tree.add(f"[dim]{vault_name}[/dim] ({location}) — [red]{error}[/red]")
59
+ else:
60
+ s_count = len(vault.get("secrets", []))
61
+ c_count = len(vault.get("certificates", []))
62
+ k_count = len(vault.get("keys", []))
63
+ tree.add(f"{vault_name} ({location}) — {s_count} secrets, {c_count} certificates, {k_count} keys")
64
+
65
+ console.print(tree)
66
+ console.print()
67
+
68
+ # Summary
69
+ total_assets = results.get("totalSecrets", 0) + results.get("totalCertificates", 0) + results.get("totalKeys", 0)
70
+ console.rule("[bold]SUMMARY[/bold]")
71
+ console.print(
72
+ f" Subscriptions: {len(results.get('subscriptions', []))} "
73
+ f"Vaults: {results.get('totalVaults', 0)} "
74
+ f"Total assets: {total_assets}"
75
+ )
76
+ console.print(
77
+ f" Secrets: {results.get('totalSecrets', 0)} "
78
+ f"Certificates: {results.get('totalCertificates', 0)} "
79
+ f"Keys: {results.get('totalKeys', 0)}"
80
+ )
81
+
82
+ if results.get("vaultsWithErrors", 0) > 0:
83
+ console.print(f" [red]Vaults with errors: {results['vaultsWithErrors']}[/red]")
84
+
85
+ console.print()
86
+
87
+ # Expiry warnings
88
+ expiry = compute_expiry_summary(results)
89
+ has_expiry_issues = any(
90
+ sum(expiry[bucket].values()) > 0
91
+ for bucket in ("expired", "7_days", "30_days", "90_days")
92
+ )
93
+
94
+ if has_expiry_issues:
95
+ console.print("[bold]EXPIRY WARNINGS[/bold]")
96
+ _print_expiry_line(console, "EXPIRED", expiry["expired"], "bold red")
97
+ _print_expiry_line(console, "< 7 days", expiry["7_days"], "red")
98
+ _print_expiry_line(console, "< 30 days", expiry["30_days"], "yellow")
99
+ _print_expiry_line(console, "< 90 days", expiry["90_days"], "dim yellow")
100
+ console.print()
101
+
102
+ # Security findings
103
+ if findings:
104
+ counts = count_by_severity(findings)
105
+ console.print("[bold]SECURITY FINDINGS[/bold]")
106
+
107
+ # Summary counts
108
+ parts = []
109
+ for sev in ("CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"):
110
+ if sev in counts:
111
+ color = SEVERITY_COLORS.get(sev, "")
112
+ parts.append(f"[{color}]{sev}: {counts[sev]}[/{color}]")
113
+ console.print(" " + " ".join(parts))
114
+ console.print()
115
+
116
+ # Top findings (max 15)
117
+ table = Table(show_header=True, header_style="bold", box=None, pad_edge=False, padding=(0, 2))
118
+ table.add_column("Severity", width=10)
119
+ table.add_column("Finding", min_width=30)
120
+ table.add_column("Resource", min_width=20)
121
+
122
+ for finding in findings[:15]:
123
+ sev = finding["severity"]
124
+ color = SEVERITY_COLORS.get(sev, "")
125
+ table.add_row(
126
+ Text(sev, style=color),
127
+ finding["title"],
128
+ finding["resource"],
129
+ )
130
+
131
+ console.print(table)
132
+
133
+ remaining = len(findings) - 15
134
+ if remaining > 0:
135
+ console.print(f" [dim]... and {remaining} more findings[/dim]")
136
+ console.print()
137
+
138
+ if output_file:
139
+ console.file.close()
140
+
141
+ return None
142
+
143
+
144
+ def format_table_security_only(
145
+ results: dict[str, Any],
146
+ findings: list[dict[str, Any]],
147
+ output_file: str | None = None,
148
+ ) -> None:
149
+ """Show only security findings summary (--security flag)."""
150
+ console = Console(file=open(output_file, "w") if output_file else None, force_terminal=output_file is None)
151
+
152
+ if not findings:
153
+ console.print("[green]No security findings.[/green]")
154
+ if output_file:
155
+ console.file.close()
156
+ return
157
+
158
+ counts = count_by_severity(findings)
159
+ console.print("[bold]SECURITY FINDINGS[/bold]")
160
+
161
+ parts = []
162
+ for sev in ("CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"):
163
+ if sev in counts:
164
+ color = SEVERITY_COLORS.get(sev, "")
165
+ parts.append(f"[{color}]{sev}: {counts[sev]}[/{color}]")
166
+ console.print(" " + " ".join(parts))
167
+ console.print()
168
+
169
+ table = Table(show_header=True, header_style="bold", box=None, pad_edge=False, padding=(0, 2))
170
+ table.add_column("Severity", width=10)
171
+ table.add_column("Finding", min_width=30)
172
+ table.add_column("Resource", min_width=20)
173
+ table.add_column("Recommendation", min_width=20)
174
+
175
+ for finding in findings:
176
+ sev = finding["severity"]
177
+ color = SEVERITY_COLORS.get(sev, "")
178
+ table.add_row(
179
+ Text(sev, style=color),
180
+ finding["title"],
181
+ finding["resource"],
182
+ finding.get("recommendation", ""),
183
+ )
184
+
185
+ console.print(table)
186
+ console.print()
187
+
188
+ if output_file:
189
+ console.file.close()
190
+
191
+
192
+ def _print_expiry_line(console: Console, label: str, counts: dict[str, int], style: str) -> None:
193
+ """Print an expiry summary line if there are any items in this bucket."""
194
+ parts = []
195
+ if counts.get("secrets", 0) > 0:
196
+ parts.append(f"{counts['secrets']} secrets")
197
+ if counts.get("certificates", 0) > 0:
198
+ parts.append(f"{counts['certificates']} certificates")
199
+ if counts.get("keys", 0) > 0:
200
+ parts.append(f"{counts['keys']} keys")
201
+
202
+ if parts:
203
+ console.print(f" [{style}]{label:12s}{', '.join(parts)}[/{style}]")
@@ -0,0 +1,91 @@
1
+ """In-CLI registration flow for CertifyClouds Scanner.
2
+
3
+ Prompts for email, calls the license server, saves the key locally.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import re
8
+
9
+ import click
10
+ import httpx
11
+ from rich.console import Console
12
+
13
+ from . import config as cfg
14
+
15
+ LICENSE_SERVER_URL = "https://license.certifyclouds.com"
16
+ REGISTER_ENDPOINT = f"{LICENSE_SERVER_URL}/api/scanner/register"
17
+ REQUEST_TIMEOUT = 15.0
18
+
19
+ EMAIL_REGEX = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
20
+
21
+ console = Console()
22
+
23
+
24
+ def register() -> str | None:
25
+ """Run the interactive registration flow. Returns the API key or None on failure."""
26
+ console.print()
27
+ console.print("[bold]Register for a free API key to get started.[/bold]")
28
+ console.print("Takes 10 seconds — no password, no credit card.")
29
+ console.print()
30
+
31
+ # Prompt for email
32
+ email = click.prompt(" Email", type=str)
33
+ email = email.strip()
34
+
35
+ if not EMAIL_REGEX.match(email):
36
+ console.print("[red]Invalid email format. Please try again.[/red]")
37
+ return None
38
+
39
+ # Prompt for company (optional)
40
+ company = click.prompt(" Company (optional)", type=str, default="", show_default=False)
41
+ company = company.strip()
42
+
43
+ console.print()
44
+ console.print(" Registering...", end=" ")
45
+
46
+ try:
47
+ resp = httpx.post(
48
+ REGISTER_ENDPOINT,
49
+ json={"email": email, "company": company or None, "source": "cli"},
50
+ timeout=REQUEST_TIMEOUT,
51
+ )
52
+
53
+ if resp.status_code == 429:
54
+ console.print("[red]Rate limited — try again in a few minutes.[/red]")
55
+ return None
56
+
57
+ if resp.status_code >= 400:
58
+ data = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
59
+ error = data.get("error", f"HTTP {resp.status_code}")
60
+ console.print(f"[red]Failed: {error}[/red]")
61
+ return None
62
+
63
+ data = resp.json()
64
+ key = data.get("key")
65
+
66
+ if not key:
67
+ console.print("[red]Failed: no key returned from server.[/red]")
68
+ return None
69
+
70
+ # Save to config
71
+ cfg.save_registration(key, email)
72
+
73
+ console.print("[green]done.[/green]")
74
+ console.print(f" Key: [bold]{key}[/bold]")
75
+ console.print(f" Saved to {cfg.CONFIG_FILE}")
76
+ console.print()
77
+
78
+ return key
79
+
80
+ except httpx.ConnectError:
81
+ console.print("[red]Failed: could not reach license.certifyclouds.com[/red]")
82
+ console.print(" Check your internet connection and try again.")
83
+ console.print(" If you're behind a proxy, set HTTPS_PROXY in your environment.")
84
+ return None
85
+ except httpx.TimeoutException:
86
+ console.print("[red]Failed: request timed out.[/red]")
87
+ console.print(" Check your internet connection and try again.")
88
+ return None
89
+ except Exception as exc:
90
+ console.print(f"[red]Failed: {exc}[/red]")
91
+ return None