termux-sync 1.2.4__tar.gz → 1.2.8__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.
@@ -11,6 +11,34 @@ Version numbers follow [Semantic Versioning](https://semver.org/).
11
11
 
12
12
  ---
13
13
 
14
+ ## [1.2.8] - 2026-10-15
15
+
16
+ ## Fixed
17
+ - `status` tables fit narrow phone screens: long paths are shortened (`$PREFIX/bin/tar`, `~/.config/...`), the ✓/✗ sits in its own column, and `rclone` is shown as "not needed" unless Google Drive is the selected storage.
18
+ - **GitHub backups no longer need room for the whole archive.** The archive is streamed and uploaded in 200 MB parts, each deleted right after it is sent (before, the full archive *plus* a split copy were written to disk, which caused "No space left on device"). The backup format is unchanged, so `restore` works as before. Needs about 250 MB free.
19
+ - Upload retry logic: permanent HTTP errors (bad token, etc.) now stop immediately instead of retrying for minutes; "already uploaded" (422) is handled again.
20
+ - Running out of disk space during `backup` stops with a clear message instead of continuing with a broken backup.
21
+ - `backup` to GitHub now checks the repository (token, name, not empty) *before* the long archive step, instead of failing after the archive is built.
22
+ - GitHub API errors are shown as short readable messages (e.g. "repository is empty") instead of raw JSON.
23
+ - If an upload fails, the finished archives are kept in the temp folder instead of being deleted.
24
+
25
+ ---
26
+
27
+ ## [1.2.6] - 2026-10-15
28
+
29
+ ### Changed
30
+ - Errors are now shown as a clean panel (what happened, where, how to fix) instead of a raw Python traceback. The full traceback is written to the log file; use `--debug` to print it.
31
+ - `termux-sync help` / `-h` now renders a grouped, readable help screen instead of the long argparse dump.
32
+ - Unknown commands show "Did you mean ...?" suggestions (and point `update` to `tsctl updater`).
33
+ - Closed input (EOF / Ctrl+D) during a prompt is reported clearly instead of crashing.
34
+
35
+ ### Fixed
36
+ - Missing `rich` no longer causes a `NameError`; it is installed automatically on first run.
37
+ - Invalid "Max backups" input in `setup` no longer crashes.
38
+ - Error text containing square brackets can no longer break rich markup
39
+
40
+ ---
41
+
14
42
  ## [1.2.4] - 2026-07-28
15
43
 
16
44
  ### Security
@@ -42,7 +70,7 @@ Version numbers follow [Semantic Versioning](https://semver.org/).
42
70
 
43
71
  ### Fixed
44
72
 
45
- - **`restore` — stuck retrying and failing with `HTTP Error 404: Not Found` when the GitHub backend points to a private repo** — `GitHubStorage.download()` and the `manifest.json` lookup in `list_backups()` downloaded release assets via `asset["browser_download_url"]` using the standard `Accept: application/vnd.github.v3+json` header. That URL only resolves without further authentication for public repos; for private repos GitHub returns 404 even with a valid token, since the download must go through the `/repos/{repo}/releases/assets/{id}` API endpoint with `Accept: application/octet-stream`. Added `GitHubStorage._asset_headers()` and `_asset_url()` and switched both call sites to use them, so restoring from a private-repo backend now downloads correctly instead of exhausting all 8 retries.
73
+ - **`restore` — stuck retrying and failing with `HTTP Error 404: Not Found` when the GitHub backend points to a private repo** — `GitHubStorage.download()` and the `manifest.json` lookup in `list_backups()` downloaded release assets via `asset["browser_download_url"]` using the standard `Accept: application/vnd.github.v3+json` header. That URL only resolves without further authentication for public repos; for private repos GitHub returns 404 even with a valid token, since the download must go through the `/repos/{repo}/releases/assets/{id}` API endpoint with `Accept: application/octet-stream`. Added `GitHubStorage._asset_headers()` and `_asset_url()` and switched both call sites to use them, so restoring from a private-repo backend now downloads correctly instead of exhausting all 8 retries. [Issues #13](https://github.com/djunekz/termux-sync/issues/13)
46
74
 
47
75
  ---
48
76
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: termux-sync
3
- Version: 1.2.4
3
+ Version: 1.2.8
4
4
  Summary: Backup and restore your entire Termux environment across devices
5
5
  Project-URL: Homepage, https://github.com/djunekz/termux-sync
6
6
  Project-URL: Repository, https://github.com/djunekz/termux-sync
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "termux-sync"
7
- version = "1.2.4"
7
+ version = "1.2.8"
8
8
  description = "Backup and restore your entire Termux environment across devices"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -9,8 +9,12 @@ Supports Local, Google Drive, and GitHub (private repo) storage.
9
9
  """
10
10
 
11
11
  import os
12
+ import re
12
13
  import sys
13
14
  import json
15
+ import errno
16
+ import difflib
17
+ import traceback
14
18
  import time
15
19
  import shutil
16
20
  import hashlib
@@ -39,11 +43,24 @@ try:
39
43
  from rich.rule import Rule
40
44
  from rich.syntax import Syntax
41
45
  from rich.align import Align
46
+ from rich.markup import escape
47
+ from rich.padding import Padding
42
48
  RICH_AVAILABLE = True
43
49
  except ImportError:
44
- RICH_AVAILABLE = False
50
+ if os.environ.get("_TERMUX_SYNC_BOOTSTRAP") != "1":
51
+ sys.stderr.write("\n termux-sync: installing required package 'rich' ...\n\n")
52
+ _r = subprocess.run([sys.executable, "-m", "pip", "install", "--quiet", "rich"])
53
+ if _r.returncode == 0:
54
+ os.environ["_TERMUX_SYNC_BOOTSTRAP"] = "1"
55
+ os.execv(sys.executable, [sys.executable] + sys.argv)
56
+ sys.stderr.write(
57
+ "\n ERROR: the Python package 'rich' is required but could not be installed.\n"
58
+ " Install it manually, then run termux-sync again:\n\n"
59
+ " pip install rich\n\n"
60
+ )
61
+ sys.exit(1)
45
62
 
46
- VERSION = "1.2.4"
63
+ VERSION = "1.2.8"
47
64
  APP_NAME = "termux-sync"
48
65
  CONFIG_DIR = Path.home() / ".config" / APP_NAME
49
66
  CONFIG_FILE = CONFIG_DIR / "config.json"
@@ -101,8 +118,16 @@ DEFAULT_CONFIG = {
101
118
  def load_config() -> dict:
102
119
  CONFIG_DIR.mkdir(parents=True, exist_ok=True)
103
120
  if CONFIG_FILE.exists():
104
- with open(CONFIG_FILE) as f:
105
- data = json.load(f)
121
+ try:
122
+ with open(CONFIG_FILE) as f:
123
+ data = json.load(f)
124
+ except json.JSONDecodeError as e:
125
+ raise UserError(
126
+ f"The config file is not valid JSON (line {e.lineno}, column {e.colno}).\n"
127
+ f"File: {CONFIG_FILE}",
128
+ hint="Fix the file by hand, or delete it and run [bold]termux-sync setup[/bold] again.",
129
+ title="Config file is corrupted",
130
+ ) from e
106
131
  for k, v in DEFAULT_CONFIG.items():
107
132
  data.setdefault(k, v)
108
133
  return data
@@ -117,6 +142,123 @@ def save_config(cfg: dict):
117
142
 
118
143
  console = Console()
119
144
 
145
+ DEBUG = bool(os.environ.get("TERMUX_SYNC_DEBUG"))
146
+ ISSUES_URL = "https://github.com/djunekz/termux-sync/issues"
147
+
148
+
149
+ # ───────────────────────── Error handling ─────────────────────────
150
+ class UserError(Exception):
151
+ """An expected, fixable problem. Shown as a clean message — no traceback."""
152
+
153
+ def __init__(self, message: str, hint: str = "", title: str = "Cannot continue"):
154
+ super().__init__(message)
155
+ self.message = message
156
+ self.hint = hint # may contain rich markup (written by us, not user input)
157
+ self.title = title
158
+
159
+
160
+ def print_error(title: str, message: str, hint: str = "", where: str = ""):
161
+ """Render one tidy error panel. `message` is escaped; `hint` may use markup."""
162
+ body = [f"[bold red]{escape(title)}[/bold red]", "", escape(message)]
163
+ if where:
164
+ body += ["", f"[dim]at {escape(where)}[/dim]"]
165
+ if hint:
166
+ body += ["", "[bold yellow]How to fix[/bold yellow]", hint]
167
+ console.print()
168
+ console.print(Panel(
169
+ "\n".join(body),
170
+ title="[bold red] ERROR [/bold red]",
171
+ border_style="red",
172
+ padding=(1, 2),
173
+ expand=False,
174
+ ))
175
+ console.print()
176
+
177
+
178
+ def _origin(exc: BaseException) -> str:
179
+ """Last stack frame that lives in this file, e.g. 'termux-sync.py:1217 in cmd_setup()'."""
180
+ found, tb = "", exc.__traceback__
181
+ while tb is not None:
182
+ if tb.tb_frame.f_code.co_filename == __file__:
183
+ found = f"{Path(__file__).name}:{tb.tb_lineno} in {tb.tb_frame.f_code.co_name}()"
184
+ tb = tb.tb_next
185
+ return found
186
+
187
+
188
+ def _explain(exc: Exception) -> tuple:
189
+ """Translate an exception into (title, message, hint)."""
190
+ import urllib.error
191
+ import socket
192
+
193
+ if isinstance(exc, StreamAbort): # upload failure while streaming
194
+ exc = exc.original
195
+ if isinstance(exc, UserError):
196
+ return exc.title, exc.message, exc.hint
197
+ if isinstance(exc, EOFError):
198
+ return (
199
+ "Input was closed",
200
+ "termux-sync asked a question, but no answer could be read "
201
+ "(the input stream ended, or you pressed Ctrl+D).",
202
+ "Run the command directly in the Termux terminal — not through a pipe, "
203
+ "script, or widget — and answer the prompts.",
204
+ )
205
+ if isinstance(exc, FileNotFoundError):
206
+ return ("File or folder not found", str(exc.filename or exc),
207
+ "Check that the path exists and is spelled correctly.")
208
+ if isinstance(exc, PermissionError):
209
+ return ("Permission denied", str(exc.filename or exc),
210
+ "Pick a path inside your home directory, or run "
211
+ "[bold]termux-setup-storage[/bold] to grant access to shared storage.")
212
+ if isinstance(exc, OSError) and exc.errno == errno.ENOSPC:
213
+ return ("Device is out of space", "There is not enough free storage to finish this.",
214
+ "Free some space with [bold]termux-sync clear-cache[/bold] "
215
+ "or check usage with [bold]termux-sync check[/bold].")
216
+ if isinstance(exc, urllib.error.HTTPError):
217
+ hint = "Check your token and repository name with [bold]termux-sync setup[/bold]."
218
+ if exc.code in (401, 403):
219
+ hint = "Your token is invalid, expired, or lacks the [bold]repo[/bold] scope. Create a new one and run [bold]termux-sync setup[/bold]."
220
+ elif exc.code == 404:
221
+ hint = "The repository or release was not found. Check the repo name in [bold]termux-sync setup[/bold]."
222
+ return f"GitHub returned HTTP {exc.code}", str(exc.reason), hint
223
+ if isinstance(exc, (urllib.error.URLError, socket.timeout, TimeoutError, ConnectionError)):
224
+ return ("Network problem", str(getattr(exc, "reason", None) or exc),
225
+ "Check your internet connection and try again.")
226
+ if isinstance(exc, subprocess.CalledProcessError):
227
+ cmd = exc.cmd if isinstance(exc.cmd, str) else " ".join(map(str, exc.cmd))
228
+ return ("A command failed", f"{cmd}\nexit code {exc.returncode}",
229
+ "Run that command by hand to see its full output.")
230
+ if isinstance(exc, (RuntimeError, ValueError)):
231
+ return "Operation failed", str(exc), "See the message above. Details are saved in the log."
232
+ return (
233
+ "Unexpected error",
234
+ f"{type(exc).__name__}: {exc}",
235
+ f"Run again with [bold]--debug[/bold] for the full traceback.\n"
236
+ f"If it keeps happening, please report it: [cyan]{ISSUES_URL}[/cyan]",
237
+ )
238
+
239
+
240
+ def handle_crash(exc: Exception):
241
+ """Log the full traceback to the log file; show a clean panel on screen."""
242
+ try:
243
+ log("ERROR", f"{type(exc).__name__}: {exc}")
244
+ for line in "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)).splitlines():
245
+ log("TRACE", line)
246
+ except Exception:
247
+ pass
248
+
249
+ console.print() # leave the half-finished prompt line
250
+ title, message, hint = _explain(exc)
251
+ expected = isinstance(getattr(exc, "original", exc), UserError)
252
+ print_error(title, message, hint, where="" if expected and not DEBUG else _origin(exc))
253
+
254
+ if DEBUG:
255
+ console.print("[bold dim]Full traceback (--debug)[/bold dim]")
256
+ console.print_exception(show_locals=False, max_frames=10, suppress=[sys.modules["rich"]])
257
+ console.print()
258
+ else:
259
+ shown = str(LOG_FILE).replace(str(Path.home()), "~")
260
+ console.print(f" [dim]Log: {shown}[/dim]\n")
261
+
120
262
 
121
263
  def print_banner():
122
264
  console.print(
@@ -208,7 +350,10 @@ def create_archive(
208
350
  excludes: list[str],
209
351
  label: str,
210
352
  progress: Progress,
353
+ sink=None,
211
354
  ) -> Path:
355
+ """Create a tar archive. With `sink` (a file-like object) the archive is streamed
356
+ into it and nothing is written to `archive_path`."""
212
357
  valid_compressions = {"gz", "bz2", "xz"}
213
358
  if compression not in valid_compressions:
214
359
  compression = "gz"
@@ -226,6 +371,8 @@ def create_archive(
226
371
  out = archive_path.parent / (stem + ext)
227
372
 
228
373
  task = progress.add_task(f"[cyan]{label}", total=None)
374
+ if sink is not None and hasattr(sink, "attach"):
375
+ sink.attach(progress, task, label)
229
376
 
230
377
  def _filter(tarinfo):
231
378
  for pat in excludes:
@@ -265,7 +412,11 @@ def create_archive(
265
412
  try:
266
413
  if source_path.is_dir():
267
414
  _unlock(source_path)
268
- with tarfile.open(out, mode) as tf:
415
+ if sink is not None:
416
+ opened = tarfile.open(fileobj=sink, mode=f"w|{compression}")
417
+ else:
418
+ opened = tarfile.open(out, mode)
419
+ with opened as tf:
269
420
  if source_path.is_dir():
270
421
  tf.add(source_path, arcname=source_path.name, filter=_filter)
271
422
  else:
@@ -418,8 +569,12 @@ class GDriveStorage:
418
569
 
419
570
  def _check_rclone(self):
420
571
  if shutil.which("rclone") is None:
421
- console.print("[red]rclone not found. Install: pkg install rclone[/red]")
422
- sys.exit(1)
572
+ raise UserError(
573
+ "Google Drive storage needs rclone, but it is not installed.",
574
+ hint="Install it with [bold]pkg install rclone[/bold], then configure a remote "
575
+ "named [bold]termux-sync-gdrive[/bold] using [bold]rclone config[/bold].",
576
+ title="rclone not found",
577
+ )
423
578
 
424
579
  def _remote_path(self, name: str = "") -> str:
425
580
  base = f"{self.REMOTE}:"
@@ -504,8 +659,12 @@ class GitHubStorage:
504
659
  self.repo = cfg.get("github_repo", "")
505
660
  self.branch = cfg.get("github_branch", "main")
506
661
  if not self.token or not self.repo:
507
- console.print("[red]GitHub token and repo are required. Run: termux-sync setup[/red]")
508
- sys.exit(1)
662
+ raise UserError(
663
+ "GitHub storage is selected, but the token or repository is not set.",
664
+ hint="Run [bold]termux-sync setup[/bold] and enter your Personal Access Token "
665
+ "and repo (owner/repo).",
666
+ title="GitHub is not configured",
667
+ )
509
668
 
510
669
  def _headers(self) -> dict:
511
670
  return {
@@ -536,8 +695,90 @@ class GitHubStorage:
536
695
  return {}
537
696
  return json.loads(body)
538
697
  except urllib.error.HTTPError as e:
539
- body = e.read().decode()
540
- raise RuntimeError(f"GitHub API error {e.code}: {body}") from e
698
+ raise self._http_error(e.code, e.read().decode(errors="replace")) from e
699
+
700
+ def _http_error(self, code: int, raw: str) -> Exception:
701
+ """Turn a GitHub API error response into a short, human-readable exception."""
702
+ try:
703
+ data = json.loads(raw)
704
+ msg = str(data.get("message", "")).strip()
705
+ extras = "; ".join(
706
+ str(x.get("message")) for x in data.get("errors", [])
707
+ if isinstance(x, dict) and x.get("message")
708
+ )
709
+ except Exception:
710
+ msg, extras = raw.strip()[:300], ""
711
+ blob = f"{msg} {extras}".lower()
712
+
713
+ if code in (409, 422) and "empty" in blob:
714
+ return UserError(
715
+ f"The repository '{self.repo}' has no commits yet, and GitHub cannot "
716
+ "create a release in an empty repository.",
717
+ hint="Open the repository on GitHub, click [bold]Add a README[/bold] (or create "
718
+ "any file) and commit it. Then run the command again.",
719
+ title="GitHub repository is empty",
720
+ )
721
+ if code == 401:
722
+ return UserError(
723
+ "GitHub rejected the token (bad credentials).",
724
+ hint="Create a new token with the [bold]repo[/bold] scope at "
725
+ "[cyan]https://github.com/settings/tokens[/cyan], then run "
726
+ "[bold]termux-sync setup[/bold].",
727
+ title="Invalid GitHub token",
728
+ )
729
+ if code == 403 and "rate limit" in blob:
730
+ return UserError(
731
+ "GitHub is limiting requests from this token for now.",
732
+ hint="Wait a few minutes and try again.",
733
+ title="GitHub rate limit reached",
734
+ )
735
+ if code == 403:
736
+ return UserError(
737
+ msg or "The token is not allowed to do this.",
738
+ hint="Make sure the token has the [bold]repo[/bold] scope and can write to this repository.",
739
+ title="GitHub denied access",
740
+ )
741
+ if code == 404:
742
+ return UserError(
743
+ f"Repository '{self.repo}' was not found, or the token cannot see it.",
744
+ hint="Check the spelling (owner/repo) in [bold]termux-sync setup[/bold]. "
745
+ "For a private repository the token needs the [bold]repo[/bold] scope.",
746
+ title="GitHub repository not found",
747
+ )
748
+ detail = ": ".join(x for x in (msg, extras) if x) or "no details"
749
+ return RuntimeError(f"GitHub API error {code}: {detail}")
750
+
751
+ def preflight(self):
752
+ """Fail fast — before a long archive step — if the repo cannot hold a release."""
753
+ self._api("GET", f"/repos/{self.repo}") # token + repo name
754
+ self._api("GET", f"/repos/{self.repo}/commits?per_page=1") # 409 if empty
755
+
756
+ # ── streaming backup: parts are uploaded and deleted one by one ──
757
+ def begin_stream(self, backup_name: str, work_dir: Path):
758
+ work_dir.mkdir(parents=True, exist_ok=True)
759
+ need = int(self.CHUNK_SIZE * 1.2)
760
+ free = shutil.disk_usage(work_dir).free
761
+ if free < need:
762
+ raise UserError(
763
+ f"Only {human_size(free)} is free, but about {human_size(need)} is needed "
764
+ "to hold one upload part.",
765
+ hint="Free some space with [bold]termux-sync clear-cache[/bold], or remove "
766
+ "files you no longer need, then try again.",
767
+ title="Not enough free space",
768
+ )
769
+ release = self._get_release_by_tag(backup_name) or self._create_release(backup_name)
770
+ self._stream = {"release_id": release["id"], "work_dir": work_dir}
771
+
772
+ def stream_sink(self, base_name: str, progress: Progress):
773
+ return _PartWriter(self, self._stream["release_id"], base_name,
774
+ self._stream["work_dir"], progress)
775
+
776
+ def abort_stream(self, backup_name: str):
777
+ """Best effort: remove a half-finished release so it never counts as a backup."""
778
+ try:
779
+ self.delete_backup(backup_name)
780
+ except Exception:
781
+ pass
541
782
 
542
783
  def _push_file(self, local_path: Path, remote_path: str, message: str):
543
784
  import base64
@@ -626,19 +867,28 @@ class GitHubStorage:
626
867
  result = json.loads(resp.read())
627
868
  progress.update(progress_task, completed=file_size)
628
869
  return result
870
+ except urllib.error.HTTPError as e:
871
+ # (HTTPError is a URLError subclass, so it must be handled first)
872
+ body = e.read().decode(errors="replace")
873
+ if e.code == 422:
874
+ progress.update(progress_task, completed=file_size,
875
+ description=f"[yellow]{local_path.name} already uploaded, skipping")
876
+ return {}
877
+ if e.code >= 500 or e.code == 429: # temporary: retry
878
+ last_err = e
879
+ progress.update(progress_task,
880
+ description=f"[yellow]Retry {attempt+1}/{self.MAX_RETRIES} ({local_path.name})...")
881
+ _time.sleep(15 * (attempt + 1))
882
+ continue
883
+ raise self._http_error(e.code, body) from e # permanent: stop now
629
884
  except (urllib.error.URLError, OSError, ConnectionError) as e:
885
+ if getattr(e, "errno", None) == errno.ENOSPC:
886
+ raise
630
887
  last_err = e
631
888
  wait = 15 * (attempt + 1)
632
889
  progress.update(progress_task,
633
890
  description=f"[yellow]Retry {attempt+1}/{self.MAX_RETRIES} ({local_path.name})...")
634
891
  _time.sleep(wait)
635
- except urllib.error.HTTPError as e:
636
- body = e.read().decode()
637
- if e.code == 422:
638
- progress.update(progress_task, completed=file_size,
639
- description=f"[yellow]{local_path.name} already uploaded, skipping")
640
- return {}
641
- raise RuntimeError(f"Asset upload failed {e.code}: {body}") from e
642
892
 
643
893
  raise RuntimeError(
644
894
  f"Failed to upload {local_path.name} after {self.MAX_RETRIES} attempts: {last_err}"
@@ -792,6 +1042,103 @@ class GitHubStorage:
792
1042
  raise RuntimeError(f"Failed to delete backup from GitHub: {e}") from e
793
1043
 
794
1044
 
1045
+ class StreamAbort(Exception):
1046
+ """Raised when an upload fails while the archive is being streamed."""
1047
+ def __init__(self, original: Exception):
1048
+ super().__init__(str(original))
1049
+ self.original = original
1050
+
1051
+
1052
+ class _PartWriter:
1053
+ """File-like sink for tarfile. Cuts the stream into CHUNK_SIZE parts and uploads
1054
+ each part as soon as it is full, then deletes it — so the device never needs room
1055
+ for the whole archive. Part names match what `restore` already reassembles."""
1056
+
1057
+ def __init__(self, storage, release_id: int, base_name: str, work_dir: Path, progress: Progress):
1058
+ self.storage, self.release_id, self.base = storage, release_id, base_name
1059
+ self.work_dir, self.progress = work_dir, progress
1060
+ self.limit = storage.CHUNK_SIZE
1061
+ self.idx = 0 # parts uploaded so far
1062
+ self.total = 0 # bytes of the whole archive
1063
+ self.sha = hashlib.sha256() # checksum of the whole archive
1064
+ self._fh = None
1065
+ self._path = None
1066
+ self._size = 0
1067
+ self._task = None
1068
+ self._label = base_name
1069
+ self._next_note = 0
1070
+
1071
+ def attach(self, progress, task, label):
1072
+ self.progress, self._task, self._label = progress, task, label
1073
+
1074
+ def _note(self):
1075
+ if self._task is not None:
1076
+ self.progress.update(
1077
+ self._task,
1078
+ description=f"[cyan]{self._label} [dim]{human_size(self.total)} sent[/dim]",
1079
+ )
1080
+
1081
+ def writable(self):
1082
+ return True
1083
+
1084
+ def flush(self):
1085
+ pass
1086
+
1087
+ def write(self, data) -> int:
1088
+ view, pos, n = memoryview(data), 0, len(data)
1089
+ while pos < n:
1090
+ if self._fh is not None and self._size >= self.limit:
1091
+ self._flush(final=False)
1092
+ if self._fh is None:
1093
+ self._path = self.work_dir / f"{self.base}.part{self.idx:04d}"
1094
+ self._fh, self._size = open(self._path, "wb"), 0
1095
+ piece = view[pos: pos + (self.limit - self._size)]
1096
+ self._fh.write(piece)
1097
+ self._size += len(piece)
1098
+ self.total += len(piece)
1099
+ self.sha.update(piece)
1100
+ pos += len(piece)
1101
+ if self.total >= self._next_note:
1102
+ self._next_note = self.total + (8 << 20)
1103
+ self._note()
1104
+ return n
1105
+
1106
+ def _flush(self, final: bool):
1107
+ self._fh.close()
1108
+ self._fh = None
1109
+ path = self._path
1110
+ if final and self.idx == 0: # small archive: one plain file, no ".part"
1111
+ plain = self.work_dir / self.base
1112
+ path.rename(plain)
1113
+ path = plain
1114
+ task = self.progress.add_task(f"[cyan]{path.name}…", total=max(path.stat().st_size, 1))
1115
+ try:
1116
+ self.storage._upload_one_asset(self.release_id, path, task, self.progress)
1117
+ except Exception as e:
1118
+ raise StreamAbort(e) from e
1119
+ finally:
1120
+ self.progress.remove_task(task)
1121
+ path.unlink(missing_ok=True)
1122
+ self.idx += 1
1123
+ self._note()
1124
+
1125
+ def finish(self):
1126
+ """Upload the last (possibly partial) part."""
1127
+ if self._fh is not None:
1128
+ self._flush(final=True)
1129
+
1130
+ def abort(self):
1131
+ """Drop whatever is still on disk."""
1132
+ try:
1133
+ if self._fh is not None:
1134
+ self._fh.close()
1135
+ self._fh = None
1136
+ if self._path is not None:
1137
+ Path(self._path).unlink(missing_ok=True)
1138
+ except Exception:
1139
+ pass
1140
+
1141
+
795
1142
  def get_storage(cfg: dict):
796
1143
  s = cfg.get("storage", "local")
797
1144
  if s == "gdrive":
@@ -815,6 +1162,11 @@ def cmd_backup(cfg: dict, label: str = ""):
815
1162
  def _cleanup_tmp():
816
1163
  shutil.rmtree(tmp_dir, ignore_errors=True)
817
1164
 
1165
+ def _abort_backup():
1166
+ if streaming:
1167
+ storage.abort_stream(backup_name)
1168
+ _cleanup_tmp()
1169
+
818
1170
  compression = cfg.get("compression", "gz")
819
1171
  if compression not in ("gz", "bz2", "xz"):
820
1172
  console.print(f" [yellow]⚠ Compression '{compression}' is invalid, falling back to 'gz'.[/yellow]")
@@ -823,6 +1175,28 @@ def cmd_backup(cfg: dict, label: str = ""):
823
1175
  excludes = cfg.get("exclude_patterns", [])
824
1176
  storage = get_storage(cfg)
825
1177
 
1178
+ check_storage = getattr(storage, "preflight", None)
1179
+ if check_storage:
1180
+ console.print(" [bold]Check[/bold] Verifying GitHub repository…")
1181
+ try:
1182
+ check_storage()
1183
+ except BaseException:
1184
+ _cleanup_tmp()
1185
+ raise
1186
+ console.print(f" {status_icon(True)} Repository is ready\n")
1187
+
1188
+ streaming = hasattr(storage, "begin_stream")
1189
+ if streaming:
1190
+ try:
1191
+ storage.begin_stream(backup_name, tmp_dir / ".parts")
1192
+ except BaseException:
1193
+ _cleanup_tmp()
1194
+ raise
1195
+ console.print(
1196
+ f" {status_icon(True)} Streaming upload: the archive is sent in parts, "
1197
+ "so your device never holds a full copy\n"
1198
+ )
1199
+
826
1200
  meta = {
827
1201
  "name": backup_name,
828
1202
  "date": datetime.datetime.now().isoformat(),
@@ -845,7 +1219,8 @@ def cmd_backup(cfg: dict, label: str = ""):
845
1219
  log("INFO", f"Collected {len(pkgs)} packages")
846
1220
 
847
1221
  console.print()
848
- console.print(" [bold]Step 2/4[/bold] Archiving directories…")
1222
+ console.print(" [bold]Step 2/4[/bold] " +
1223
+ ("Archiving & uploading directories…" if streaming else "Archiving directories…"))
849
1224
  console.print()
850
1225
 
851
1226
  with Progress(
@@ -866,18 +1241,44 @@ def cmd_backup(cfg: dict, label: str = ""):
866
1241
  console.print(f" [yellow]⚠ Skipping {src} (not found)[/yellow]")
867
1242
  continue
868
1243
  out_path = tmp_dir / f"{key}.tar.{compression}"
1244
+ sink = storage.stream_sink(out_path.name, progress) if streaming else None
869
1245
  try:
870
- actual_out = create_archive(src, out_path, compression, excludes, label_text, progress)
871
- checksum = sha256(actual_out)
1246
+ actual_out = create_archive(src, out_path, compression, excludes, label_text,
1247
+ progress, sink=sink)
1248
+ if sink is not None:
1249
+ sink.finish()
1250
+ archive_size, checksum = sink.total, sink.sha.hexdigest()
1251
+ else:
1252
+ checksum = sha256(actual_out)
1253
+ archive_size = actual_out.stat().st_size
872
1254
  meta["files"][key] = {
873
1255
  "archive": actual_out.name,
874
- "size": actual_out.stat().st_size,
1256
+ "size": archive_size,
875
1257
  "checksum": checksum,
876
1258
  "source": str(src),
877
1259
  }
878
- log("INFO", f"Archived {src} → {actual_out.name} ({human_size(actual_out.stat().st_size)})")
1260
+ log("INFO", f"Archived {src} → {actual_out.name} ({human_size(archive_size)})")
1261
+ except (StreamAbort, KeyboardInterrupt):
1262
+ if sink is not None:
1263
+ sink.abort()
1264
+ for t in list(progress.tasks):
1265
+ progress.remove_task(t.id)
1266
+ _abort_backup()
1267
+ raise
1268
+ except OSError as e:
1269
+ if sink is not None:
1270
+ sink.abort()
1271
+ if e.errno == errno.ENOSPC: # out of space: stop, don't continue silently
1272
+ for t in list(progress.tasks):
1273
+ progress.remove_task(t.id)
1274
+ _abort_backup()
1275
+ raise
1276
+ console.print(f" [red]✗ Failed to archive {src}: {escape(str(e))}[/red]")
1277
+ log("ERROR", f"Archive failed for {src}: {e}")
879
1278
  except Exception as e:
880
- console.print(f" [red]✗ Failed to archive {src}: {e}[/red]")
1279
+ if sink is not None:
1280
+ sink.abort()
1281
+ console.print(f" [red]✗ Failed to archive {src}: {escape(str(e))}[/red]")
881
1282
  log("ERROR", f"Archive failed for {src}: {e}")
882
1283
 
883
1284
  console.print()
@@ -890,6 +1291,7 @@ def cmd_backup(cfg: dict, label: str = ""):
890
1291
  console.print()
891
1292
 
892
1293
  location = ""
1294
+ upload_error = None
893
1295
  with Progress(
894
1296
  SpinnerColumn(),
895
1297
  TextColumn(" {task.description}"),
@@ -901,13 +1303,26 @@ def cmd_backup(cfg: dict, label: str = ""):
901
1303
  location = storage.upload(tmp_dir, backup_name, progress)
902
1304
  storage.delete_old(cfg.get("max_backups", 5))
903
1305
  except KeyboardInterrupt:
904
- _cleanup_tmp()
1306
+ _abort_backup()
905
1307
  raise
906
1308
  except Exception as e:
907
- console.print(f" [red]✗ Upload failed: {e}[/red]")
908
- log("ERROR", f"Upload failed: {e}")
909
- _cleanup_tmp()
910
- return
1309
+ upload_error = e
1310
+ for t in list(progress.tasks): # drop the half-finished spinner line
1311
+ progress.remove_task(t.id)
1312
+
1313
+ if upload_error is not None:
1314
+ log("ERROR", f"Upload failed: {upload_error}")
1315
+ title, message, hint = _explain(upload_error)
1316
+ print_error(f"Upload failed — {title}", message, hint)
1317
+ if streaming:
1318
+ _abort_backup() # remove the incomplete release so it never counts as a backup
1319
+ console.print(" [dim]The incomplete backup was removed from GitHub.[/dim]\n")
1320
+ else:
1321
+ console.print(" [bold]Your backup files were kept[/bold] so the work is not lost:")
1322
+ console.print(f" [white]{escape(str(tmp_dir))}[/white]", soft_wrap=True)
1323
+ console.print(" [dim]Delete them when you no longer need them:[/dim]")
1324
+ console.print(f" rm -rf {escape(str(tmp_dir))}\n", soft_wrap=True)
1325
+ return
911
1326
 
912
1327
  shutil.rmtree(tmp_dir, ignore_errors=True)
913
1328
 
@@ -1000,7 +1415,7 @@ def cmd_restore(cfg: dict, backup_name: str = ""):
1000
1415
  try:
1001
1416
  storage.download(backup_name, tmp_dir, progress)
1002
1417
  except Exception as e:
1003
- console.print(f" [red]✗ Download failed: {e}[/red]")
1418
+ console.print(f" [red]✗ Download failed: {escape(str(e))}[/red]")
1004
1419
  log("ERROR", f"Download failed: {e}")
1005
1420
  shutil.rmtree(tmp_dir, ignore_errors=True)
1006
1421
  return
@@ -1082,7 +1497,7 @@ def cmd_restore(cfg: dict, backup_name: str = ""):
1082
1497
  extract_archive(archive, src_dir, info["archive"], progress)
1083
1498
  log("INFO", f"Extracted {info['archive']} → {src_dir}")
1084
1499
  except Exception as e:
1085
- console.print(f" [red]✗ Extract failed: {e}[/red]")
1500
+ console.print(f" [red]✗ Extract failed: {escape(str(e))}[/red]")
1086
1501
  log("ERROR", f"Extract failed for {info['archive']}: {e}")
1087
1502
 
1088
1503
  pkgs = manifest.get("packages", [])
@@ -1198,7 +1613,7 @@ def cmd_delete(cfg: dict, backup_name: str = ""):
1198
1613
  console.print(f"\n [bold green]✓ Backup '{backup_name}' deleted successfully.[/bold green]\n")
1199
1614
  log("INFO", f"Deleted backup: {backup_name}")
1200
1615
  except Exception as e:
1201
- console.print(f"\n [red]✗ Failed to delete backup: {e}[/red]\n")
1616
+ console.print(f"\n [red]✗ Failed to delete backup: {escape(str(e))}[/red]\n")
1202
1617
  log("ERROR", f"Delete failed for {backup_name}: {e}")
1203
1618
 
1204
1619
 
@@ -1282,9 +1697,12 @@ def cmd_setup():
1282
1697
  " Compression (gz=fast, bz2=balanced, xz=best)",
1283
1698
  choices=["gz", "bz2", "xz"], default=cfg.get("compression", "gz")
1284
1699
  )
1285
- cfg["max_backups"] = int(Prompt.ask(
1286
- " Max backups to keep", default=str(cfg.get("max_backups", 5))
1287
- ))
1700
+ while True:
1701
+ raw = Prompt.ask(" Max backups to keep", default=str(cfg.get("max_backups", 5))).strip()
1702
+ if raw.isdigit() and int(raw) >= 1:
1703
+ cfg["max_backups"] = int(raw)
1704
+ break
1705
+ console.print(" [red]✗ Enter a whole number of 1 or more.[/red]")
1288
1706
 
1289
1707
  console.print()
1290
1708
  current_patterns = cfg.get("exclude_patterns", [])
@@ -1412,18 +1830,30 @@ def cmd_daemon(cfg: dict):
1412
1830
  log("ERROR", f"Daemon backup failed: {e}")
1413
1831
 
1414
1832
 
1833
+ def short_path(p) -> str:
1834
+ """Shorten long Termux paths for narrow screens: $PREFIX/bin/tar, ~/.config/..."""
1835
+ text = str(p)
1836
+ prefix = os.environ.get("PREFIX", "/data/data/com.termux/files/usr")
1837
+ home = str(Path.home())
1838
+ if text == prefix or text.startswith(prefix + "/"):
1839
+ return "$PREFIX" + text[len(prefix):]
1840
+ if text == home or text.startswith(home + "/"):
1841
+ return "~" + text[len(home):]
1842
+ return text
1843
+
1844
+
1415
1845
  def cmd_status(cfg: dict):
1416
1846
  print_section("STATUS", "Current configuration and environment")
1417
1847
 
1418
1848
  sched = load_schedule()
1419
1849
 
1420
- cfg_table = Table(box=box.ROUNDED, show_header=False, padding=(0, 2))
1421
- cfg_table.add_column("Key", style="dim cyan", min_width=20)
1422
- cfg_table.add_column("Value", style="bold white")
1850
+ cfg_table = Table(box=box.ROUNDED, show_header=False, padding=(0, 1))
1851
+ cfg_table.add_column("Key", style="dim cyan", no_wrap=True)
1852
+ cfg_table.add_column("Value", style="bold white", overflow="fold")
1423
1853
 
1424
1854
  cfg_table.add_row("Storage backend", cfg.get("storage", "local").upper())
1425
1855
  if cfg["storage"] == "local":
1426
- cfg_table.add_row("Local path", cfg.get("local_path", "—"))
1856
+ cfg_table.add_row("Local path", short_path(cfg.get("local_path", "—")))
1427
1857
  elif cfg["storage"] == "gdrive":
1428
1858
  cfg_table.add_row("Drive folder", cfg.get("gdrive_folder_id", "(root)") or "(root)")
1429
1859
  elif cfg["storage"] == "github":
@@ -1435,30 +1865,35 @@ def cmd_status(cfg: dict):
1435
1865
  if sched.get("enabled"):
1436
1866
  cfg_table.add_row("Schedule", f"Daily at {sched['hour']:02d}:{sched['minute']:02d}")
1437
1867
  cfg_table.add_row("App version", f"v{VERSION}")
1438
- cfg_table.add_row("Config file", str(CONFIG_FILE))
1439
- cfg_table.add_row("Log file", str(LOG_FILE))
1868
+ for key, path in (("Config file", CONFIG_FILE), ("Log file", LOG_FILE)):
1869
+ folder, _, fname = short_path(path).rpartition("/")
1870
+ cfg_table.add_row(key, f"{escape(folder)}/\n{escape(fname)}")
1440
1871
 
1441
1872
  console.print(cfg_table)
1442
1873
  console.print()
1443
1874
 
1444
- env_table = Table(box=box.ROUNDED, show_header=False, padding=(0, 2),
1875
+ env_table = Table(box=box.ROUNDED, show_header=False, padding=(0, 1),
1445
1876
  title="[bold yellow]Environment[/bold yellow]")
1446
- env_table.add_column("Tool", style="dim cyan", min_width=20)
1447
- env_table.add_column("Status")
1877
+ env_table.add_column("Tool", style="dim cyan", no_wrap=True)
1878
+ env_table.add_column("", no_wrap=True, width=1)
1879
+ env_table.add_column("Path", overflow="fold")
1448
1880
 
1881
+ storage = cfg.get("storage", "local")
1449
1882
  checks = [
1450
- ("python3", shutil.which("python3")),
1451
- ("dpkg", shutil.which("dpkg")),
1452
- ("tar", shutil.which("tar")),
1453
- ("rclone", shutil.which("rclone")),
1454
- ("git", shutil.which("git")),
1455
- ("curl", shutil.which("curl")),
1883
+ ("python3", shutil.which("python3"), True),
1884
+ ("dpkg", shutil.which("dpkg"), True),
1885
+ ("tar", shutil.which("tar"), True),
1886
+ ("rclone", shutil.which("rclone"), storage == "gdrive"), # only needed for Google Drive
1887
+ ("git", shutil.which("git"), True),
1888
+ ("curl", shutil.which("curl"), True),
1456
1889
  ]
1457
- for name, path in checks:
1890
+ for name, path, needed in checks:
1458
1891
  if path:
1459
- env_table.add_row(name, f"[green]✓ {path}[/green]")
1892
+ env_table.add_row(name, "[green]✓[/green]", f"[green]{escape(short_path(path))}[/green]")
1893
+ elif needed:
1894
+ env_table.add_row(name, "[red]✗[/red]", f"[red]missing[/red] [dim]— pkg install {name}[/dim]")
1460
1895
  else:
1461
- env_table.add_row(name, "[red]✗ not found[/red]")
1896
+ env_table.add_row(name, "[dim]–[/dim]", f"[dim]not needed for {storage.upper()}[/dim]")
1462
1897
 
1463
1898
  console.print(env_table)
1464
1899
 
@@ -1479,14 +1914,25 @@ def cmd_logs(lines: int = 40):
1479
1914
  if not LOG_FILE.exists():
1480
1915
  console.print(" [dim]No log file yet.[/dim]")
1481
1916
  return
1482
- log_lines = LOG_FILE.read_text().splitlines()[-lines:]
1917
+ all_lines = LOG_FILE.read_text().splitlines()
1918
+ if not DEBUG:
1919
+ all_lines = [ln for ln in all_lines if "[TRACE]" not in ln]
1920
+ log_lines = all_lines[-lines:]
1921
+ if not log_lines:
1922
+ console.print(" [dim]No log entries.[/dim]")
1923
+ return
1924
+ if not DEBUG:
1925
+ console.print(" [dim]Tip: add --debug to also show saved tracebacks.[/dim]\n")
1483
1926
  for line in log_lines:
1927
+ safe = escape(line)
1484
1928
  if "[ERROR]" in line:
1485
- console.print(f" [red]{line}[/red]")
1929
+ console.print(f" [red]{safe}[/red]")
1486
1930
  elif "[WARN]" in line:
1487
- console.print(f" [yellow]{line}[/yellow]")
1931
+ console.print(f" [yellow]{safe}[/yellow]")
1932
+ elif "[TRACE]" in line:
1933
+ console.print(f" [dim red]{safe}[/dim red]")
1488
1934
  else:
1489
- console.print(f" [dim]{line}[/dim]")
1935
+ console.print(f" [dim]{safe}[/dim]")
1490
1936
 
1491
1937
 
1492
1938
  def cmd_clear_logs():
@@ -1716,7 +2162,7 @@ def cmd_clear_cache():
1716
2162
  else:
1717
2163
  child.unlink(missing_ok=True)
1718
2164
  except Exception as e:
1719
- log("WARN", f"Could not remove {child}: {e}")
2165
+ log("WARN", f"Could not remove {child}: {escape(str(e))}")
1720
2166
  prog.update(task, total=1, completed=1,
1721
2167
  description=f"[green]{label} cleared ({human_size(size)}) ✓")
1722
2168
  cleared += size
@@ -1764,15 +2210,17 @@ def cmd_import_config(input_path: str = ""):
1764
2210
 
1765
2211
  src = Path(input_path).expanduser()
1766
2212
  if not src.exists():
1767
- console.print(f" [red]File not found: {src}[/red]\n")
1768
- return
2213
+ raise UserError(str(src), hint="Check the path and try again.", title="File not found")
1769
2214
 
1770
2215
  try:
1771
2216
  with open(src) as f:
1772
2217
  new_cfg = json.load(f)
1773
2218
  except json.JSONDecodeError as e:
1774
- console.print(f" [red]Invalid JSON file: {e}[/red]\n")
1775
- return
2219
+ raise UserError(
2220
+ f"{src}\nLine {e.lineno}, column {e.colno}: {e.msg}",
2221
+ hint="Make sure the file is a valid JSON export from [bold]termux-sync export-config[/bold].",
2222
+ title="Not a valid JSON file",
2223
+ ) from e
1776
2224
 
1777
2225
  existing = load_config()
1778
2226
 
@@ -1789,117 +2237,127 @@ def cmd_import_config(input_path: str = ""):
1789
2237
  log("INFO", f"Config imported from {src}")
1790
2238
 
1791
2239
 
1792
- def ensure_dependencies():
1793
- try:
1794
- import rich
1795
- except ImportError:
1796
- print("Installing required Python packages (rich)…")
1797
- subprocess.run([sys.executable, "-m", "pip", "install", "--quiet", "rich"], check=True)
1798
- print("Done. Please re-run termux-sync.")
1799
- sys.exit(0)
1800
-
1801
-
1802
- def main():
1803
- ensure_dependencies()
1804
-
1805
- parser = argparse.ArgumentParser(
1806
- prog="termux-sync",
1807
- description="Termux Backup & Restore — sync your environment across devices",
1808
- formatter_class=argparse.RawDescriptionHelpFormatter,
1809
- epilog="""\
1810
- Commands:
1811
- backup Create a new backup
1812
- restore Restore from a backup
1813
- list List all available backups
1814
- delete Delete a specific backup
1815
- setup Interactive configuration wizard
1816
- schedule Configure auto-backup schedule
1817
- daemon Run background scheduler (called by Termux:Boot)
1818
- status Show current config and environment
1819
- logs Show recent log entries
1820
- clear-logs Clear / reset the log file
1821
- check Show disk usage (check ~, $PREFIX, or a custom path)
1822
- clear-cache Remove temporary and cached files
1823
- export-config Export config to a file (tokens redacted)
1824
- import-config Import config from a file
1825
-
1826
- Examples:
1827
- termux-sync setup
1828
- termux-sync backup --label "before-new-phone"
1829
- termux-sync list
1830
- termux-sync restore
1831
- termux-sync delete
1832
- termux-sync delete --name termux_backup_20240501_120000
1833
- termux-sync schedule
1834
- termux-sync status
1835
- termux-sync logs --lines 100
1836
- termux-sync clear-logs
1837
- termux-sync check
1838
- termux-sync check ~
1839
- termux-sync check $PREFIX
1840
- termux-sync clear-cache
1841
- termux-sync export-config
1842
- termux-sync import-config ~/termux-sync-config-export.json
1843
- """,
1844
- )
1845
- parser.add_argument("command", nargs="?", default="help",
1846
- choices=["backup", "restore", "list", "delete", "setup", "schedule",
1847
- "daemon", "status", "logs", "clear-logs", "check",
1848
- "clear-cache", "export-config", "import-config", "help"])
1849
- parser.add_argument("target", nargs="?", default="", help="Optional path for check / import-config")
1850
- parser.add_argument("--label", "-l", default="", help="Label for this backup")
1851
- parser.add_argument("--name", "-n", default="", help="Backup name to restore / delete")
1852
- parser.add_argument("--lines", default=40, type=int, help="Lines to show in logs")
1853
- parser.add_argument("--version", "-v", action="store_true", help="Print version and exit")
1854
-
1855
- args = parser.parse_args()
2240
+ # ───────────────────────── Command line ─────────────────────────
2241
+ COMMAND_GROUPS = [
2242
+ ("Backup & Restore", [
2243
+ ("backup", "Create a new backup"),
2244
+ ("restore", "Restore from a backup"),
2245
+ ("list", "List all available backups"),
2246
+ ("delete", "Delete a specific backup"),
2247
+ ]),
2248
+ ("Configuration", [
2249
+ ("setup", "Interactive configuration wizard"),
2250
+ ("schedule", "Configure auto-backup schedule"),
2251
+ ("daemon", "Run background scheduler (used by Termux:Boot)"),
2252
+ ("export-config", "Export config to a file (tokens redacted)"),
2253
+ ("import-config", "Import config from a file"),
2254
+ ]),
2255
+ ("Maintenance", [
2256
+ ("status", "Show current config and environment"),
2257
+ ("logs", "Show recent log entries"),
2258
+ ("clear-logs", "Clear / reset the log file"),
2259
+ ("check", "Show disk usage (~, $PREFIX, or a custom path)"),
2260
+ ("clear-cache", "Remove temporary and cached files"),
2261
+ ("help", "Show this help"),
2262
+ ]),
2263
+ ]
2264
+ COMMANDS = [name for _, items in COMMAND_GROUPS for name, _ in items]
2265
+
2266
+ OPTIONS = [
2267
+ ("-l, --label LABEL", "Label for this backup"),
2268
+ ("-n, --name NAME", "Backup name to restore / delete"),
2269
+ ("--lines N", "Lines to show in logs (default 40)"),
2270
+ ("--debug", "Show the full traceback if something fails"),
2271
+ ("-v, --version", "Print version and exit"),
2272
+ ("-h, --help", "Show this help"),
2273
+ ]
1856
2274
 
1857
- if args.version:
1858
- print(f"termux-sync v{VERSION}")
1859
- return
2275
+ EXAMPLES = [
2276
+ ("termux-sync setup", "first-time configuration"),
2277
+ ("termux-sync backup --label before-new-phone", "create a labelled backup"),
2278
+ ("termux-sync list", "see all backups"),
2279
+ ("termux-sync restore", "pick a backup to restore"),
2280
+ ("termux-sync delete --name <backup-name>", "delete a specific backup"),
2281
+ ("termux-sync logs --lines 100", "show the last 100 log lines"),
2282
+ ("termux-sync check ~", "disk usage of your home"),
2283
+ ("termux-sync import-config <file>", "load a config file"),
2284
+ ]
1860
2285
 
1861
- if args.command == "help" or args.command is None:
1862
- print_banner()
1863
- parser.print_help()
1864
- return
2286
+ # Commands people commonly try that live elsewhere.
2287
+ COMMAND_HINTS = {
2288
+ "update": "termux-sync has no built-in update command. Use [bold]tsctl updater[/bold] to update.",
2289
+ "upgrade": "To update, use [bold]tsctl updater[/bold].",
2290
+ "install": "To install or reinstall, use the install script, or [bold]tsctl[/bold].",
2291
+ "remove": "To delete a backup use [bold]termux-sync delete[/bold]. To uninstall the tool use [bold]tsctl uninstall[/bold].",
2292
+ "config": "Use [bold]termux-sync setup[/bold] to change settings, or [bold]termux-sync status[/bold] to view them.",
2293
+ "sync": "Use [bold]termux-sync backup[/bold] to upload, or [bold]termux-sync restore[/bold] to download.",
2294
+ }
1865
2295
 
1866
- cfg = load_config()
1867
2296
 
1868
- try:
1869
- if args.command == "setup":
1870
- cmd_setup()
1871
- elif args.command == "backup":
1872
- cmd_backup(cfg, label=args.label)
1873
- elif args.command == "restore":
1874
- cmd_restore(cfg, backup_name=args.name)
1875
- elif args.command == "list":
1876
- cmd_list(cfg)
1877
- elif args.command == "delete":
1878
- cmd_delete(cfg, backup_name=args.name)
1879
- elif args.command == "schedule":
1880
- cmd_schedule()
1881
- elif args.command == "daemon":
1882
- cmd_daemon(cfg)
1883
- elif args.command == "status":
1884
- cmd_status(cfg)
1885
- elif args.command == "logs":
1886
- cmd_logs(args.lines)
1887
- elif args.command == "clear-logs":
1888
- cmd_clear_logs()
1889
- elif args.command == "check":
1890
- cmd_check(args.target or "")
1891
- elif args.command == "clear-cache":
1892
- cmd_clear_cache()
1893
- elif args.command == "export-config":
1894
- cmd_export_config(args.target or "")
1895
- elif args.command == "import-config":
1896
- cmd_import_config(args.target or "")
1897
- except KeyboardInterrupt:
1898
- _abort()
2297
+ def print_help():
2298
+ print_banner()
2299
+ console.print(f" [bold]Usage:[/bold] termux-sync [cyan]<command>[/cyan] [dim]{escape('[target] [options]')}[/dim]")
2300
+ console.print()
2301
+
2302
+ for group, items in COMMAND_GROUPS:
2303
+ table = Table(box=None, show_header=False, padding=(0, 2), pad_edge=False)
2304
+ table.add_column(style="bold cyan", no_wrap=True)
2305
+ table.add_column()
2306
+ for name, desc in items:
2307
+ table.add_row(name, escape(desc))
2308
+ console.print(f" [bold yellow]{group}[/bold yellow]")
2309
+ console.print(Padding(table, (0, 0, 1, 2)))
2310
+
2311
+ table = Table(box=None, show_header=False, padding=(0, 2), pad_edge=False)
2312
+ table.add_column(style="green", no_wrap=True)
2313
+ table.add_column()
2314
+ for flag, desc in OPTIONS:
2315
+ table.add_row(escape(flag), escape(desc))
2316
+ console.print(" [bold yellow]Options[/bold yellow]")
2317
+ console.print(Padding(table, (0, 0, 1, 2)))
2318
+
2319
+ console.print(" [bold yellow]Examples[/bold yellow]")
2320
+ for cmd, _ in EXAMPLES:
2321
+ console.print(f" [dim]$[/dim] [white]{escape(cmd)}[/white]", soft_wrap=True)
2322
+ console.print()
2323
+
2324
+
2325
+ class FriendlyParser(argparse.ArgumentParser):
2326
+ """argparse, but errors are shown as a tidy panel instead of a usage dump."""
2327
+
2328
+ def error(self, message):
2329
+ m = re.search(r"invalid choice: '([^']*)'", message)
2330
+ if m:
2331
+ bad = m.group(1)
2332
+ hint_lines = []
2333
+ if bad in COMMAND_HINTS:
2334
+ hint_lines.append(COMMAND_HINTS[bad])
2335
+ close = [] if bad in COMMAND_HINTS else difflib.get_close_matches(bad, COMMANDS, n=3, cutoff=0.6)
2336
+ if close:
2337
+ hint_lines.append("Did you mean: " + " ".join(f"[bold cyan]{c}[/bold cyan]" for c in close))
2338
+ hint_lines.append("Run [bold]termux-sync help[/bold] to see all commands.")
2339
+ print_error("Unknown command", f"'{bad}' is not a termux-sync command.", "\n\n".join(hint_lines))
2340
+ else:
2341
+ nice = message.replace("argument --lines/", "--lines").replace("argument ", "")
2342
+ print_error("Invalid usage", nice,
2343
+ "Run [bold]termux-sync help[/bold] to see the correct usage.")
2344
+ sys.exit(2)
2345
+
2346
+
2347
+ def build_parser() -> argparse.ArgumentParser:
2348
+ parser = FriendlyParser(prog="termux-sync", add_help=False)
2349
+ parser.add_argument("command", nargs="?", default="help", choices=COMMANDS, metavar="command")
2350
+ parser.add_argument("target", nargs="?", default="")
2351
+ parser.add_argument("--label", "-l", default="")
2352
+ parser.add_argument("--name", "-n", default="")
2353
+ parser.add_argument("--lines", default=40, type=int)
2354
+ parser.add_argument("--debug", action="store_true")
2355
+ parser.add_argument("--version", "-v", action="store_true")
2356
+ parser.add_argument("--help", "-h", action="store_true")
2357
+ return parser
1899
2358
 
1900
2359
 
1901
2360
  def _abort():
1902
- console.print()
1903
2361
  console.print()
1904
2362
  console.print(
1905
2363
  Panel(
@@ -1909,9 +2367,49 @@ def _abort():
1909
2367
  " Run the command again whenever you're ready.",
1910
2368
  border_style="yellow",
1911
2369
  padding=(1, 2),
2370
+ expand=False,
1912
2371
  )
1913
2372
  )
1914
- sys.exit(0)
2373
+ sys.exit(130)
2374
+
2375
+
2376
+ def main(argv=None):
2377
+ global DEBUG
2378
+ args = build_parser().parse_args(argv)
2379
+ DEBUG = DEBUG or args.debug
2380
+
2381
+ if args.version:
2382
+ print(f"termux-sync v{VERSION}")
2383
+ return
2384
+
2385
+ if args.help or args.command in (None, "help"):
2386
+ print_help()
2387
+ return
2388
+
2389
+ try:
2390
+ cfg = load_config()
2391
+ handlers = {
2392
+ "setup": lambda: cmd_setup(),
2393
+ "backup": lambda: cmd_backup(cfg, label=args.label),
2394
+ "restore": lambda: cmd_restore(cfg, backup_name=args.name),
2395
+ "list": lambda: cmd_list(cfg),
2396
+ "delete": lambda: cmd_delete(cfg, backup_name=args.name),
2397
+ "schedule": lambda: cmd_schedule(),
2398
+ "daemon": lambda: cmd_daemon(cfg),
2399
+ "status": lambda: cmd_status(cfg),
2400
+ "logs": lambda: cmd_logs(args.lines),
2401
+ "clear-logs": lambda: cmd_clear_logs(),
2402
+ "check": lambda: cmd_check(args.target or ""),
2403
+ "clear-cache": lambda: cmd_clear_cache(),
2404
+ "export-config": lambda: cmd_export_config(args.target or ""),
2405
+ "import-config": lambda: cmd_import_config(args.target or ""),
2406
+ }
2407
+ handlers[args.command]()
2408
+ except KeyboardInterrupt:
2409
+ _abort()
2410
+ except Exception as exc:
2411
+ handle_crash(exc)
2412
+ sys.exit(1)
1915
2413
 
1916
2414
 
1917
2415
  if __name__ == "__main__":
File without changes
File without changes