ghostbit-cli 1.2.0__tar.gz → 1.4.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ghostbit-cli
3
- Version: 1.2.0
3
+ Version: 1.4.0
4
4
  Summary: Ghostbit CLI — create end-to-end encrypted pastes from the terminal
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/stackopshq/ghostbit
@@ -0,0 +1,568 @@
1
+ """Ghostbit CLI entry point + public re-exports.
2
+
3
+ The CLI is laid out as a small package so the crypto, config, history,
4
+ HTTP, and completion concerns each live in their own focused module.
5
+ This `__init__` hosts the command handlers (`cmd_paste`, `cmd_view`,
6
+ `cmd_delete`, `cmd_list`) and the argparse wiring in `main()`.
7
+
8
+ Underscore-prefixed names at the bottom of this file are re-exports
9
+ kept for backwards compatibility with `tests/test_cli_crypto.py`, which
10
+ imports `_encrypt`/`_decrypt`/etc. directly from the `cli` module.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import base64
17
+ import getpass
18
+ import json
19
+ import sys
20
+ import time
21
+ import urllib.error
22
+ import urllib.request
23
+ from pathlib import Path
24
+
25
+ from ._api import SSL_CTX, USER_AGENT, api_create
26
+ from ._completion import cmd_completion as _cmd_completion_raw
27
+ from ._config import DEFAULT_SERVER, cmd_config, load_config
28
+ from ._crypto import (
29
+ decrypt,
30
+ decrypt_bytes,
31
+ derive_key,
32
+ encrypt,
33
+ gen_key,
34
+ gen_salt,
35
+ key_to_fragment,
36
+ require_crypto,
37
+ )
38
+ from ._history import HISTORY_PATH, history_append, history_load
39
+
40
+ # Version used by --version output. Read from installed package metadata
41
+ # so bumping cli/pyproject.toml is the single knob.
42
+ try:
43
+ from importlib.metadata import PackageNotFoundError
44
+ from importlib.metadata import version as _pkg_version
45
+
46
+ try:
47
+ __version__ = _pkg_version("ghostbit-cli")
48
+ except PackageNotFoundError:
49
+ __version__ = "dev"
50
+ except ImportError:
51
+ __version__ = "dev"
52
+
53
+
54
+ LANGUAGES = [
55
+ "python", "javascript", "typescript", "go", "rust", "ruby", "php",
56
+ "java", "c", "cpp", "csharp", "bash", "powershell", "html", "css",
57
+ "sql", "json", "yaml", "toml", "xml", "markdown", "dockerfile",
58
+ "kotlin", "swift", "lua", "r", "diff",
59
+ ] # fmt: skip
60
+
61
+
62
+ # ── gbit paste ───────────────────────────────────────────────────────────────
63
+
64
+
65
+ def cmd_paste(args) -> None:
66
+ cfg = load_config()
67
+ server = args.server or cfg.get("server", DEFAULT_SERVER)
68
+
69
+ if args.file:
70
+ path = Path(args.file)
71
+ if not path.exists():
72
+ print(f"File not found: {args.file}", file=sys.stderr)
73
+ sys.exit(1)
74
+ content = path.read_text(errors="replace")
75
+ # Infer language from extension if not specified.
76
+ if args.lang is None:
77
+ ext_map = {
78
+ ".py": "python", ".js": "javascript", ".ts": "typescript",
79
+ ".go": "go", ".rs": "rust", ".rb": "ruby", ".php": "php",
80
+ ".java": "java", ".c": "c", ".cpp": "cpp", ".cs": "csharp",
81
+ ".sh": "bash", ".bash": "bash", ".zsh": "bash",
82
+ ".html": "html", ".css": "css", ".sql": "sql",
83
+ ".json": "json", ".yaml": "yaml", ".yml": "yaml",
84
+ ".toml": "toml", ".xml": "xml", ".md": "markdown",
85
+ ".dockerfile": "dockerfile", ".kt": "kotlin", ".swift": "swift",
86
+ ".lua": "lua", ".r": "r", ".diff": "diff", ".patch": "diff",
87
+ } # fmt: skip
88
+ # Handle extensionless files by name (e.g. Dockerfile, Makefile).
89
+ name_map = {"dockerfile": "dockerfile", "makefile": "makefile"}
90
+ args.lang = ext_map.get(path.suffix.lower()) or name_map.get(path.name.lower())
91
+ else:
92
+ if sys.stdin.isatty():
93
+ print("Reading from stdin… (Ctrl+D to finish)", file=sys.stderr)
94
+ content = sys.stdin.read()
95
+
96
+ if not content.strip():
97
+ print("Error: content is empty.", file=sys.stderr)
98
+ sys.exit(1)
99
+
100
+ require_crypto()
101
+
102
+ # Encrypt client-side (mirrors browser e2e.js).
103
+ # If --password was given without a value, prompt interactively.
104
+ password = args.password
105
+ if password is True or password == "":
106
+ password = getpass.getpass("Password: ")
107
+ if not password:
108
+ print("Error: password cannot be empty.", file=sys.stderr)
109
+ sys.exit(1)
110
+
111
+ if password:
112
+ kdf_salt = gen_salt()
113
+ key = derive_key(password, kdf_salt)
114
+ else:
115
+ key = gen_key()
116
+ kdf_salt = None
117
+
118
+ if args.compress:
119
+ import gzip
120
+
121
+ payload_input: str | bytes = gzip.compress(content.encode())
122
+ else:
123
+ payload_input = content
124
+ ciphertext, nonce = encrypt(payload_input, key)
125
+
126
+ payload = {
127
+ "content": ciphertext,
128
+ "nonce": nonce,
129
+ "kdf_salt": kdf_salt,
130
+ "language": args.lang,
131
+ "expires_in": args.expires,
132
+ "burn": args.burn,
133
+ "max_views": args.max_views,
134
+ "compressed": args.compress,
135
+ }
136
+
137
+ result = api_create(server, payload)
138
+
139
+ # Build full URL with fragment (key + delete token).
140
+ if password:
141
+ fragment = f"~{result['delete_token']}"
142
+ else:
143
+ fragment = f"{key_to_fragment(key)}~{result['delete_token']}"
144
+
145
+ full_url = f"{result['url']}#{fragment}"
146
+
147
+ # Append to local history (best-effort, privacy-first — stays on disk only).
148
+ if not args.no_history:
149
+ history_append(
150
+ {
151
+ "id": result["id"],
152
+ "url": result["url"],
153
+ "full_url": full_url,
154
+ "created_at": int(time.time()),
155
+ "language": args.lang,
156
+ "expires_at": result.get("expires_at"),
157
+ }
158
+ )
159
+
160
+ if args.json:
161
+ result["full_url"] = full_url
162
+ print(json.dumps(result, indent=2))
163
+ elif args.quiet:
164
+ print(full_url)
165
+ else:
166
+ print(full_url)
167
+ if sys.stdout.isatty():
168
+ parts = []
169
+ if result.get("expires_at"):
170
+ delta = result["expires_at"] - int(time.time())
171
+ if delta < 3600:
172
+ parts.append(f"expires in {delta // 60}m")
173
+ elif delta < 86400:
174
+ parts.append(f"expires in {delta // 3600}h")
175
+ else:
176
+ parts.append(f"expires in {delta // 86400}d")
177
+ if result.get("burn"):
178
+ parts.append("burn after read")
179
+ if result.get("max_views"):
180
+ parts.append(f"max {result['max_views']} views")
181
+ if password:
182
+ parts.append("password protected")
183
+ if parts:
184
+ print(" " + " · ".join(parts), file=sys.stderr)
185
+ if not password:
186
+ print(
187
+ " Share the full URL — the decryption key is in the #fragment.",
188
+ file=sys.stderr,
189
+ )
190
+
191
+
192
+ # ── gbit view ────────────────────────────────────────────────────────────────
193
+
194
+
195
+ def _print_highlighted(content: str, language: str | None) -> None:
196
+ """Print content with terminal syntax highlighting.
197
+
198
+ Markdown: rendered via rich if available.
199
+ Other languages: Pygments with a true-color terminal formatter.
200
+ Fallback: plain text.
201
+ """
202
+ if language == "markdown":
203
+ try:
204
+ from rich.console import Console
205
+ from rich.markdown import Markdown
206
+
207
+ Console().print(Markdown(content))
208
+ return
209
+ except ImportError:
210
+ pass
211
+
212
+ try:
213
+ from pygments import highlight
214
+ from pygments.formatters import TerminalTrueColorFormatter
215
+ from pygments.lexers import TextLexer, get_lexer_by_name
216
+ from pygments.util import ClassNotFound
217
+
218
+ try:
219
+ lexer = get_lexer_by_name(language) if language else TextLexer()
220
+ except ClassNotFound:
221
+ lexer = TextLexer()
222
+
223
+ print(highlight(content, lexer, TerminalTrueColorFormatter(style="monokai")), end="")
224
+ return
225
+ except ImportError:
226
+ pass
227
+
228
+ print(content)
229
+
230
+
231
+ def cmd_view(args) -> None:
232
+ from urllib.parse import urldefrag, urlparse
233
+
234
+ url_no_frag, fragment = urldefrag(args.url)
235
+ parsed = urlparse(url_no_frag)
236
+ server = f"{parsed.scheme}://{parsed.netloc}"
237
+ paste_id = parsed.path.strip("/")
238
+
239
+ if not paste_id or not parsed.scheme:
240
+ print("Error: invalid URL.", file=sys.stderr)
241
+ sys.exit(1)
242
+
243
+ # Fragment format: KEY_B64URL~DELETE_TOKEN (no password)
244
+ # ~DELETE_TOKEN (password-protected)
245
+ key_b64url = fragment.partition("~")[0]
246
+ is_password = not key_b64url
247
+
248
+ api_url = f"{server}/api/v1/pastes/{paste_id}"
249
+ req = urllib.request.Request(api_url, headers={"User-Agent": USER_AGENT})
250
+ try:
251
+ with urllib.request.urlopen(req, timeout=15, context=SSL_CTX) as resp:
252
+ data = json.loads(resp.read())
253
+ except urllib.error.HTTPError as e:
254
+ body = e.read().decode(errors="replace")
255
+ try:
256
+ detail = json.loads(body).get("detail", body)
257
+ except Exception: # noqa: BLE001
258
+ detail = body
259
+ print(f"Error {e.code}: {detail}", file=sys.stderr)
260
+ sys.exit(1)
261
+ except urllib.error.URLError as e:
262
+ print(f"Could not connect to {server}: {e.reason}", file=sys.stderr)
263
+ sys.exit(1)
264
+
265
+ require_crypto()
266
+
267
+ if is_password:
268
+ password = getpass.getpass("Password: ")
269
+ kdf_salt = data.get("kdf_salt")
270
+ if not kdf_salt:
271
+ print("Error: no KDF salt — paste is not password-protected.", file=sys.stderr)
272
+ sys.exit(1)
273
+ key = derive_key(password, kdf_salt)
274
+ else:
275
+ # Restore base64 padding stripped for URL safety.
276
+ padded = key_b64url + "=" * (-len(key_b64url) % 4)
277
+ key = base64.urlsafe_b64decode(padded)
278
+
279
+ try:
280
+ if data.get("compressed"):
281
+ import gzip
282
+
283
+ plaintext = gzip.decompress(decrypt_bytes(data["content"], data["nonce"], key)).decode()
284
+ else:
285
+ plaintext = decrypt(data["content"], data["nonce"], key)
286
+ except Exception: # noqa: BLE001
287
+ print("Error: decryption failed — wrong key or corrupted paste.", file=sys.stderr)
288
+ sys.exit(1)
289
+
290
+ # Warn if this view just burned the paste.
291
+ burned = data.get("burn") or (
292
+ data.get("max_views") and data.get("view_count", 0) >= data["max_views"]
293
+ )
294
+ if burned and sys.stderr.isatty():
295
+ print(
296
+ "⚠️ This paste has been burned and is no longer available on the server.",
297
+ file=sys.stderr,
298
+ )
299
+
300
+ language = data.get("language")
301
+ if sys.stdout.isatty():
302
+ _print_highlighted(plaintext, language)
303
+ else:
304
+ print(plaintext, end="")
305
+
306
+
307
+ # ── gbit delete ──────────────────────────────────────────────────────────────
308
+
309
+
310
+ def cmd_delete(url: str) -> None:
311
+ from urllib.parse import urldefrag, urlparse
312
+
313
+ url_no_frag, fragment = urldefrag(url)
314
+ parsed = urlparse(url_no_frag)
315
+ server = f"{parsed.scheme}://{parsed.netloc}"
316
+ paste_id = parsed.path.strip("/")
317
+
318
+ if not paste_id or not parsed.scheme:
319
+ print("Error: invalid URL.", file=sys.stderr)
320
+ sys.exit(1)
321
+
322
+ # Fragment: KEY~DELETE_TOKEN or ~DELETE_TOKEN
323
+ delete_token = fragment.partition("~")[2]
324
+ if not delete_token:
325
+ print(
326
+ "Error: delete token missing from URL fragment (expected KEY~TOKEN or ~TOKEN).",
327
+ file=sys.stderr,
328
+ )
329
+ sys.exit(1)
330
+
331
+ api_url = f"{server}/api/v1/pastes/{paste_id}"
332
+ req = urllib.request.Request(
333
+ api_url,
334
+ headers={"User-Agent": USER_AGENT, "X-Delete-Token": delete_token},
335
+ method="DELETE",
336
+ )
337
+ try:
338
+ with urllib.request.urlopen(req, timeout=15, context=SSL_CTX):
339
+ pass
340
+ print(f"Deleted {paste_id}.")
341
+ except urllib.error.HTTPError as e:
342
+ if e.code == 403:
343
+ print("Error: invalid delete token.", file=sys.stderr)
344
+ elif e.code == 404:
345
+ print("Error: paste not found (already deleted or expired).", file=sys.stderr)
346
+ else:
347
+ print(f"Error {e.code}.", file=sys.stderr)
348
+ sys.exit(1)
349
+ except urllib.error.URLError as e:
350
+ print(f"Could not connect to {server}: {e.reason}", file=sys.stderr)
351
+ sys.exit(1)
352
+
353
+
354
+ # ── gbit list ────────────────────────────────────────────────────────────────
355
+
356
+
357
+ def cmd_list(clear: bool = False) -> None:
358
+ if clear:
359
+ if HISTORY_PATH.exists():
360
+ HISTORY_PATH.unlink()
361
+ print("History cleared.")
362
+ else:
363
+ print("No history file found.")
364
+ return
365
+
366
+ entries = history_load()
367
+ if not entries:
368
+ print("No pastes in local history.", file=sys.stderr)
369
+ print(f" History file: {HISTORY_PATH}", file=sys.stderr)
370
+ return
371
+
372
+ now = int(time.time())
373
+
374
+ def _age(ts: int) -> str:
375
+ delta = now - ts
376
+ if delta < 120:
377
+ return "just now"
378
+ if delta < 3600:
379
+ return f"{delta // 60}m ago"
380
+ if delta < 86400:
381
+ return f"{delta // 3600}h ago"
382
+ return f"{delta // 86400}d ago"
383
+
384
+ def _expiry(expires_at) -> str:
385
+ if not expires_at:
386
+ return "never"
387
+ delta = expires_at - now
388
+ if delta <= 0:
389
+ return "expired"
390
+ if delta < 3600:
391
+ return f"in {delta // 60}m"
392
+ if delta < 86400:
393
+ return f"in {delta // 3600}h"
394
+ return f"in {delta // 86400}d"
395
+
396
+ print(f"{'ID':<12} {'Lang':<14} {'Created':<12} {'Expires':<10} URL")
397
+ print("-" * 80)
398
+ for e in reversed(entries):
399
+ row_id = e.get("id", "?")[:10]
400
+ lang = (e.get("language") or "plain")[:12]
401
+ created = _age(e.get("created_at", 0))
402
+ expires = _expiry(e.get("expires_at"))
403
+ full_url = e.get("full_url", e.get("url", ""))
404
+ print(f"{row_id:<12} {lang:<14} {created:<12} {expires:<10} {full_url}")
405
+
406
+
407
+ def cmd_completion(shell: str) -> None:
408
+ """Thin wrapper over `_completion.cmd_completion` that injects LANGUAGES."""
409
+ _cmd_completion_raw(shell, LANGUAGES)
410
+
411
+
412
+ # ── Config subcommand parser (local, calls into _config.cmd_config) ──────────
413
+
414
+
415
+ def _run_config(argv) -> None:
416
+ parser = argparse.ArgumentParser(
417
+ prog="gbit config", description="Manage Ghostbit CLI configuration."
418
+ )
419
+ sub = parser.add_subparsers(dest="action")
420
+
421
+ sub.add_parser("show", help="Show current configuration.")
422
+
423
+ p_set = sub.add_parser("set", help="Set a config value.")
424
+ p_set.add_argument("key", help="Config key (e.g. server)")
425
+ p_set.add_argument("value", help="Value to set")
426
+
427
+ p_unset = sub.add_parser("unset", help="Remove a config key.")
428
+ p_unset.add_argument("key", help="Config key to remove")
429
+
430
+ args = parser.parse_args(argv)
431
+ if not args.action:
432
+ parser.print_help()
433
+ sys.exit(0)
434
+ cmd_config(args.action, getattr(args, "key", None), getattr(args, "value", None))
435
+
436
+
437
+ # ── Entry point ──────────────────────────────────────────────────────────────
438
+
439
+
440
+ def main() -> None:
441
+ # Route subcommands early so file paths aren't mistaken for them.
442
+ if len(sys.argv) > 1 and sys.argv[1] == "config":
443
+ _run_config(sys.argv[2:])
444
+ return
445
+
446
+ if len(sys.argv) > 1 and sys.argv[1] == "delete":
447
+ if len(sys.argv) < 3:
448
+ print("Usage: gbit delete <url>", file=sys.stderr)
449
+ sys.exit(1)
450
+ cmd_delete(sys.argv[2])
451
+ return
452
+
453
+ if len(sys.argv) > 1 and sys.argv[1] == "list":
454
+ clear = "--clear" in sys.argv
455
+ cmd_list(clear=clear)
456
+ return
457
+
458
+ if len(sys.argv) > 1 and sys.argv[1] == "completion":
459
+ shells = ["bash", "zsh", "fish"]
460
+ if len(sys.argv) < 3 or sys.argv[2] not in shells:
461
+ print(f"Usage: gbit completion [{'|'.join(shells)}]", file=sys.stderr)
462
+ sys.exit(1)
463
+ cmd_completion(sys.argv[2])
464
+ return
465
+
466
+ if len(sys.argv) > 1 and sys.argv[1] == "view":
467
+ if len(sys.argv) < 3:
468
+ print("Usage: gbit view <url>", file=sys.stderr)
469
+ sys.exit(1)
470
+ view_parser = argparse.ArgumentParser(prog="gbit view")
471
+ view_parser.add_argument("url", help="Full paste URL (including #fragment).")
472
+ cmd_view(view_parser.parse_args(sys.argv[2:]))
473
+ return
474
+
475
+ cfg = load_config()
476
+ current_server = cfg.get("server", DEFAULT_SERVER)
477
+
478
+ parser = argparse.ArgumentParser(
479
+ prog="gbit",
480
+ description="Ghostbit CLI — create encrypted pastes from the terminal.",
481
+ formatter_class=argparse.RawDescriptionHelpFormatter,
482
+ epilog=f"""
483
+ examples:
484
+ cat main.py | gbit
485
+ gbit main.py
486
+ gbit main.py --lang python --burn
487
+ gbit main.py --expires 3600 --password secret
488
+ gbit view https://paste.example.com/abc123#KEY~TOKEN
489
+ gbit config set server https://paste.example.com
490
+ gbit config show
491
+
492
+ current server: {current_server}
493
+ """,
494
+ )
495
+
496
+ parser.add_argument(
497
+ "file",
498
+ nargs="?",
499
+ help="File to paste. Reads from stdin if omitted.",
500
+ )
501
+ parser.add_argument(
502
+ "--server", "-s", default=None, metavar="URL",
503
+ help=f"Server URL for this invocation only (current: {current_server})",
504
+ ) # fmt: skip
505
+ parser.add_argument(
506
+ "--lang", "-l", default=None,
507
+ help="Language (e.g. python, javascript, go). Auto-detected if omitted.",
508
+ ) # fmt: skip
509
+ parser.add_argument(
510
+ "--expires", "-e", type=int, default=None, metavar="SECONDS",
511
+ help="Expiry TTL in seconds (3600 = 1 h, 86400 = 1 d). Default: never.",
512
+ ) # fmt: skip
513
+ parser.add_argument(
514
+ "--burn", "-b", action="store_true",
515
+ help="Delete after the first view.",
516
+ ) # fmt: skip
517
+ parser.add_argument(
518
+ "--compress", "-z", action="store_true",
519
+ help="Gzip the plaintext locally BEFORE encryption (60–80%% smaller for text).",
520
+ ) # fmt: skip
521
+ parser.add_argument(
522
+ "--max-views", "-m", type=int, default=None, metavar="N",
523
+ help="Delete after N views.",
524
+ ) # fmt: skip
525
+ parser.add_argument(
526
+ "--password", "-p", nargs="?", const=True, default=None, metavar="PASS",
527
+ help="Encrypt with a password. Omit value to be prompted securely.",
528
+ ) # fmt: skip
529
+ parser.add_argument(
530
+ "--quiet", "-q", action="store_true",
531
+ help="Print only the URL.",
532
+ ) # fmt: skip
533
+ parser.add_argument("--json", action="store_true", help="Print the full JSON response.")
534
+ parser.add_argument(
535
+ "--no-history", action="store_true",
536
+ help="Don't save this paste to local history.",
537
+ ) # fmt: skip
538
+ parser.add_argument(
539
+ "--version", "-V", action="version", version=f"%(prog)s {__version__}",
540
+ ) # fmt: skip
541
+
542
+ args = parser.parse_args()
543
+ cmd_paste(args)
544
+
545
+
546
+ # ── Backwards-compat re-exports for tests/test_cli_crypto.py ─────────────────
547
+ # These aliases keep `from cli import _encrypt, _decrypt, …` working after
548
+ # the move from a flat module to a package. Drop them once the tests are
549
+ # updated to the new names.
550
+ _encrypt = encrypt
551
+ _decrypt = decrypt
552
+ _gen_key = gen_key
553
+ _gen_salt = gen_salt
554
+ _derive_key = derive_key
555
+ _key_to_fragment = key_to_fragment
556
+
557
+
558
+ __all__ = [
559
+ "LANGUAGES",
560
+ "__version__",
561
+ "cmd_completion",
562
+ "cmd_config",
563
+ "cmd_delete",
564
+ "cmd_list",
565
+ "cmd_paste",
566
+ "cmd_view",
567
+ "main",
568
+ ]
@@ -0,0 +1,61 @@
1
+ """HTTP plumbing — user-agent, SSL context, typed error reporting.
2
+
3
+ Kept deliberately thin (no requests/httpx dependency) so the installed
4
+ CLI wheel stays small. certifi is an optional hard-dep from pyproject
5
+ mostly to make macOS behave; everywhere else the system trust store is
6
+ fine.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import ssl
13
+ import sys
14
+ import urllib.error
15
+ import urllib.request
16
+
17
+ try:
18
+ from importlib.metadata import PackageNotFoundError
19
+ from importlib.metadata import version as _pkg_version
20
+
21
+ try:
22
+ _VERSION = _pkg_version("ghostbit-cli")
23
+ except PackageNotFoundError:
24
+ _VERSION = "dev"
25
+ except ImportError:
26
+ _VERSION = "dev"
27
+
28
+ USER_AGENT = f"Ghostbit-CLI/{_VERSION}"
29
+
30
+ try:
31
+ import certifi
32
+
33
+ SSL_CTX = ssl.create_default_context(cafile=certifi.where())
34
+ except ImportError:
35
+ SSL_CTX = ssl.create_default_context()
36
+
37
+
38
+ def api_create(server: str, payload: dict) -> dict:
39
+ url = server.rstrip("/") + "/api/v1/pastes"
40
+ data = json.dumps(payload).encode()
41
+ req = urllib.request.Request(
42
+ url,
43
+ data=data,
44
+ headers={"Content-Type": "application/json", "User-Agent": USER_AGENT},
45
+ method="POST",
46
+ )
47
+ try:
48
+ with urllib.request.urlopen(req, timeout=15, context=SSL_CTX) as resp:
49
+ return json.loads(resp.read())
50
+ except urllib.error.HTTPError as e:
51
+ body = e.read().decode(errors="replace")
52
+ try:
53
+ detail = json.loads(body).get("detail", body)
54
+ except Exception: # noqa: BLE001
55
+ detail = body
56
+ print(f"Error {e.code}: {detail}", file=sys.stderr)
57
+ sys.exit(1)
58
+ except urllib.error.URLError as e:
59
+ print(f"Could not connect to {server}: {e.reason}", file=sys.stderr)
60
+ print(" Tip: run `gbit config set server <URL>` to set your server.", file=sys.stderr)
61
+ sys.exit(1)