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