termux-sync 1.2.3__tar.gz → 1.2.6__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.
@@ -9,6 +9,33 @@ Version numbers follow [Semantic Versioning](https://semver.org/).
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ---
13
+
14
+ ## [1.2.6] - 2026-10-15
15
+
16
+ ### Changed
17
+ - 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.
18
+ - `termux-sync help` / `-h` now renders a grouped, readable help screen instead of the long argparse dump.
19
+ - Unknown commands show "Did you mean ...?" suggestions (and point `update` to `tsctl updater`).
20
+ - Closed input (EOF / Ctrl+D) during a prompt is reported clearly instead of crashing.
21
+
22
+ ### Fixed
23
+ - Missing `rich` no longer causes a `NameError`; it is installed automatically on first run.
24
+ - Invalid "Max backups" input in `setup` no longer crashes.
25
+ - Error text containing square brackets can no longer break rich markup.
26
+
27
+ ---
28
+
29
+ ## [1.2.4] - 2026-07-28
30
+
31
+ ### Security
32
+
33
+ - **`restore` — arbitrary file write via attacker-controlled `source` field in `manifest.json` (High, GHSA-259w-wmqg-fp76)** — during restore, `manifest["files"][key]["source"]` was taken from the backup archive itself (fully attacker-controlled) and used directly as the extraction destination, and `extract_archive()` called `tarfile.extract()` on every member with no path-containment check. A malicious or tampered backup could therefore write/overwrite arbitrary files anywhere the Termux user has write access, including shell startup files. Fixed by: (1) deriving the extraction destination only from the app's own known `BACKUP_ITEMS` roots instead of manifest content, refusing unknown manifest keys; (2) validating every tar member's resolved path (and symlink/hardlink targets) stays inside the destination before extraction, and rejecting device/fifo entries; (3) using Python's built-in `filter="data"` extraction filter on 3.12+; (4) sanitizing `backup_name` before it's used to build the temp restore path. Reported privately by [Niranj R Mahaswar](https://github.com/NiranjMahaswar).
34
+
35
+ ### Fixed
36
+
37
+ - **`delete` (GitHub storage) — `Failed to delete backup from GitHub: Expecting value: line 1 column 1 (char 0)`** — `GitHubStorage._api()` unconditionally called `json.loads()` on every API response, but GitHub's REST API returns `204 No Content` with an empty body for `DELETE /releases/{id}` and `DELETE /git/refs/tags/{tag}`, so the empty body failed JSON parsing and the delete was reported as failed (even when it actually succeeded on GitHub's side). `_api()` now returns `{}` for empty/204 responses instead of trying to parse them as JSON.
38
+
12
39
  ### Planned
13
40
 
14
41
  - Archive encryption using a user-provided password
@@ -30,7 +57,7 @@ Version numbers follow [Semantic Versioning](https://semver.org/).
30
57
 
31
58
  ### Fixed
32
59
 
33
- - **`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.
60
+ - **`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)
34
61
 
35
62
  ---
36
63
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: termux-sync
3
- Version: 1.2.3
3
+ Version: 1.2.6
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.3"
7
+ version = "1.2.6"
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.3"
63
+ VERSION = "1.2.6"
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,121 @@ 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, UserError):
194
+ return exc.title, exc.message, exc.hint
195
+ if isinstance(exc, EOFError):
196
+ return (
197
+ "Input was closed",
198
+ "termux-sync asked a question, but no answer could be read "
199
+ "(the input stream ended, or you pressed Ctrl+D).",
200
+ "Run the command directly in the Termux terminal — not through a pipe, "
201
+ "script, or widget — and answer the prompts.",
202
+ )
203
+ if isinstance(exc, FileNotFoundError):
204
+ return ("File or folder not found", str(exc.filename or exc),
205
+ "Check that the path exists and is spelled correctly.")
206
+ if isinstance(exc, PermissionError):
207
+ return ("Permission denied", str(exc.filename or exc),
208
+ "Pick a path inside your home directory, or run "
209
+ "[bold]termux-setup-storage[/bold] to grant access to shared storage.")
210
+ if isinstance(exc, OSError) and exc.errno == errno.ENOSPC:
211
+ return ("Device is out of space", "There is not enough free storage to finish this.",
212
+ "Free some space with [bold]termux-sync clear-cache[/bold] "
213
+ "or check usage with [bold]termux-sync check[/bold].")
214
+ if isinstance(exc, urllib.error.HTTPError):
215
+ hint = "Check your token and repository name with [bold]termux-sync setup[/bold]."
216
+ if exc.code in (401, 403):
217
+ hint = "Your token is invalid, expired, or lacks the [bold]repo[/bold] scope. Create a new one and run [bold]termux-sync setup[/bold]."
218
+ elif exc.code == 404:
219
+ hint = "The repository or release was not found. Check the repo name in [bold]termux-sync setup[/bold]."
220
+ return f"GitHub returned HTTP {exc.code}", str(exc.reason), hint
221
+ if isinstance(exc, (urllib.error.URLError, socket.timeout, TimeoutError, ConnectionError)):
222
+ return ("Network problem", str(getattr(exc, "reason", None) or exc),
223
+ "Check your internet connection and try again.")
224
+ if isinstance(exc, subprocess.CalledProcessError):
225
+ cmd = exc.cmd if isinstance(exc.cmd, str) else " ".join(map(str, exc.cmd))
226
+ return ("A command failed", f"{cmd}\nexit code {exc.returncode}",
227
+ "Run that command by hand to see its full output.")
228
+ if isinstance(exc, (RuntimeError, ValueError)):
229
+ return "Operation failed", str(exc), "See the message above. Details are saved in the log."
230
+ return (
231
+ "Unexpected error",
232
+ f"{type(exc).__name__}: {exc}",
233
+ f"Run again with [bold]--debug[/bold] for the full traceback.\n"
234
+ f"If it keeps happening, please report it: [cyan]{ISSUES_URL}[/cyan]",
235
+ )
236
+
237
+
238
+ def handle_crash(exc: Exception):
239
+ """Log the full traceback to the log file; show a clean panel on screen."""
240
+ try:
241
+ log("ERROR", f"{type(exc).__name__}: {exc}")
242
+ for line in "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)).splitlines():
243
+ log("TRACE", line)
244
+ except Exception:
245
+ pass
246
+
247
+ console.print() # leave the half-finished prompt line
248
+ title, message, hint = _explain(exc)
249
+ expected = isinstance(exc, UserError)
250
+ print_error(title, message, hint, where="" if expected and not DEBUG else _origin(exc))
251
+
252
+ if DEBUG:
253
+ console.print("[bold dim]Full traceback (--debug)[/bold dim]")
254
+ console.print_exception(show_locals=False, max_frames=10, suppress=[sys.modules["rich"]])
255
+ console.print()
256
+ else:
257
+ shown = str(LOG_FILE).replace(str(Path.home()), "~")
258
+ console.print(f" [dim]Log: {shown}[/dim]\n")
259
+
120
260
 
121
261
  def print_banner():
122
262
  console.print(
@@ -277,18 +417,62 @@ def create_archive(
277
417
  return out
278
418
 
279
419
 
420
+ def _safe_extract_member(member: tarfile.TarInfo, dest_path: Path):
421
+ dest_root = dest_path.resolve()
422
+
423
+ member_name = member.name
424
+ if member_name.startswith("/") or member_name.startswith("\\"):
425
+ raise ValueError(f"Unsafe absolute path in archive: {member_name!r}")
426
+ if any(part == ".." for part in Path(member_name).parts):
427
+ raise ValueError(f"Path traversal attempt in archive: {member_name!r}")
428
+
429
+ target = (dest_path / member_name).resolve()
430
+ try:
431
+ target.relative_to(dest_root)
432
+ except ValueError:
433
+ raise ValueError(f"Archive member escapes destination: {member_name!r} -> {target}")
434
+
435
+ if member.issym() or member.islnk():
436
+ link_target = member.linkname
437
+ if os.path.isabs(link_target):
438
+ raise ValueError(f"Unsafe absolute link target in archive: {member_name!r} -> {link_target!r}")
439
+ resolved_link = (target.parent / link_target).resolve()
440
+ try:
441
+ resolved_link.relative_to(dest_root)
442
+ except ValueError:
443
+ raise ValueError(f"Archive link escapes destination: {member_name!r} -> {link_target!r}")
444
+
445
+ if member.ischr() or member.isblk() or member.isfifo():
446
+ raise ValueError(f"Refusing device/fifo entry in archive: {member_name!r}")
447
+
448
+
280
449
  def extract_archive(archive_path: Path, dest_path: Path, label: str, progress: Progress):
281
450
  task = progress.add_task(f"[cyan]{label}", total=None)
282
451
  dest_path.mkdir(parents=True, exist_ok=True)
452
+
453
+ has_data_filter = hasattr(tarfile, "data_filter")
454
+
283
455
  with tarfile.open(archive_path, "r:*") as tf:
284
456
  for member in tf.getmembers():
285
457
  try:
286
- tf.extract(member, dest_path, set_attrs=True)
458
+ _safe_extract_member(member, dest_path)
459
+ except ValueError as e:
460
+ log("WARNING", f"Skipped unsafe archive member during restore: {e}")
461
+ continue
462
+
463
+ try:
464
+ if has_data_filter:
465
+ tf.extract(member, dest_path, set_attrs=True, filter="data")
466
+ else:
467
+ tf.extract(member, dest_path, set_attrs=True)
287
468
  except Exception:
288
469
  try:
289
470
  member.uid = os.getuid()
290
471
  member.gid = os.getgid()
291
- tf.extract(member, dest_path, set_attrs=False)
472
+ if has_data_filter:
473
+ tf.extract(member, dest_path, set_attrs=False, filter="data")
474
+ else:
475
+ tf.extract(member, dest_path, set_attrs=False)
292
476
  except Exception:
293
477
  pass
294
478
  progress.update(task, total=1, completed=1, description=f"[green]{label} ✓")
@@ -374,8 +558,12 @@ class GDriveStorage:
374
558
 
375
559
  def _check_rclone(self):
376
560
  if shutil.which("rclone") is None:
377
- console.print("[red]rclone not found. Install: pkg install rclone[/red]")
378
- sys.exit(1)
561
+ raise UserError(
562
+ "Google Drive storage needs rclone, but it is not installed.",
563
+ hint="Install it with [bold]pkg install rclone[/bold], then configure a remote "
564
+ "named [bold]termux-sync-gdrive[/bold] using [bold]rclone config[/bold].",
565
+ title="rclone not found",
566
+ )
379
567
 
380
568
  def _remote_path(self, name: str = "") -> str:
381
569
  base = f"{self.REMOTE}:"
@@ -460,8 +648,12 @@ class GitHubStorage:
460
648
  self.repo = cfg.get("github_repo", "")
461
649
  self.branch = cfg.get("github_branch", "main")
462
650
  if not self.token or not self.repo:
463
- console.print("[red]GitHub token and repo are required. Run: termux-sync setup[/red]")
464
- sys.exit(1)
651
+ raise UserError(
652
+ "GitHub storage is selected, but the token or repository is not set.",
653
+ hint="Run [bold]termux-sync setup[/bold] and enter your Personal Access Token "
654
+ "and repo (owner/repo).",
655
+ title="GitHub is not configured",
656
+ )
465
657
 
466
658
  def _headers(self) -> dict:
467
659
  return {
@@ -485,7 +677,12 @@ class GitHubStorage:
485
677
  req = urllib.request.Request(url, data=data, headers=self._headers(), method=method)
486
678
  try:
487
679
  with urllib.request.urlopen(req) as resp:
488
- return json.loads(resp.read())
680
+ if resp.status == 204:
681
+ return {}
682
+ body = resp.read()
683
+ if not body:
684
+ return {}
685
+ return json.loads(body)
489
686
  except urllib.error.HTTPError as e:
490
687
  body = e.read().decode()
491
688
  raise RuntimeError(f"GitHub API error {e.code}: {body}") from e
@@ -828,7 +1025,7 @@ def cmd_backup(cfg: dict, label: str = ""):
828
1025
  }
829
1026
  log("INFO", f"Archived {src} → {actual_out.name} ({human_size(actual_out.stat().st_size)})")
830
1027
  except Exception as e:
831
- console.print(f" [red]✗ Failed to archive {src}: {e}[/red]")
1028
+ console.print(f" [red]✗ Failed to archive {src}: {escape(str(e))}[/red]")
832
1029
  log("ERROR", f"Archive failed for {src}: {e}")
833
1030
 
834
1031
  console.print()
@@ -855,7 +1052,7 @@ def cmd_backup(cfg: dict, label: str = ""):
855
1052
  _cleanup_tmp()
856
1053
  raise
857
1054
  except Exception as e:
858
- console.print(f" [red]✗ Upload failed: {e}[/red]")
1055
+ console.print(f" [red]✗ Upload failed: {escape(str(e))}[/red]")
859
1056
  log("ERROR", f"Upload failed: {e}")
860
1057
  _cleanup_tmp()
861
1058
  return
@@ -930,6 +1127,12 @@ def cmd_restore(cfg: dict, backup_name: str = ""):
930
1127
  except ValueError:
931
1128
  backup_name = choice
932
1129
 
1130
+ safe_backup_name = os.path.basename(str(backup_name).replace("\\", "/")).strip()
1131
+ if not safe_backup_name or safe_backup_name in (".", "..") or "/" in safe_backup_name:
1132
+ console.print(f" [red]✗ Invalid backup name: {backup_name!r}[/red]")
1133
+ return
1134
+ backup_name = safe_backup_name
1135
+
933
1136
  tmp_dir = Path(os.environ.get("TMPDIR", "/data/data/com.termux/files/usr/tmp")) / f"restore_{backup_name}"
934
1137
  tmp_dir.mkdir(parents=True, exist_ok=True)
935
1138
 
@@ -945,7 +1148,7 @@ def cmd_restore(cfg: dict, backup_name: str = ""):
945
1148
  try:
946
1149
  storage.download(backup_name, tmp_dir, progress)
947
1150
  except Exception as e:
948
- console.print(f" [red]✗ Download failed: {e}[/red]")
1151
+ console.print(f" [red]✗ Download failed: {escape(str(e))}[/red]")
949
1152
  log("ERROR", f"Download failed: {e}")
950
1153
  shutil.rmtree(tmp_dir, ignore_errors=True)
951
1154
  return
@@ -1003,14 +1206,31 @@ def cmd_restore(cfg: dict, backup_name: str = ""):
1003
1206
  TimeElapsedColumn(),
1004
1207
  console=console,
1005
1208
  ) as progress:
1209
+ # Build the set of destinations this app is actually willing to restore into.
1210
+ # Manifest-supplied "source" values are attacker-controlled backup content
1211
+ # and must never be trusted as an extraction root — only used to double
1212
+ # check they agree with the known-safe root for that item key.
1213
+ allowed_roots = {}
1214
+ for item_key, _label, item_root in BACKUP_ITEMS:
1215
+ if item_root is not None:
1216
+ allowed_roots[item_key] = Path(item_root).resolve().parent
1217
+
1006
1218
  for key, info in manifest.get("files", {}).items():
1007
1219
  archive = tmp_dir / info["archive"]
1008
- src_dir = Path(info.get("source", str(TERMUX_FILES))).parent
1220
+
1221
+ src_dir = allowed_roots.get(key)
1222
+ if src_dir is None:
1223
+ # Unknown key not part of this app's known backup items — refuse
1224
+ # to derive a destination from untrusted manifest content.
1225
+ console.print(f" [red]✗ Refusing unknown/untrusted restore target for {key!r}[/red]")
1226
+ log("WARNING", f"Refused restore for unknown manifest key: {key!r}")
1227
+ continue
1228
+
1009
1229
  try:
1010
1230
  extract_archive(archive, src_dir, info["archive"], progress)
1011
1231
  log("INFO", f"Extracted {info['archive']} → {src_dir}")
1012
1232
  except Exception as e:
1013
- console.print(f" [red]✗ Extract failed: {e}[/red]")
1233
+ console.print(f" [red]✗ Extract failed: {escape(str(e))}[/red]")
1014
1234
  log("ERROR", f"Extract failed for {info['archive']}: {e}")
1015
1235
 
1016
1236
  pkgs = manifest.get("packages", [])
@@ -1126,7 +1346,7 @@ def cmd_delete(cfg: dict, backup_name: str = ""):
1126
1346
  console.print(f"\n [bold green]✓ Backup '{backup_name}' deleted successfully.[/bold green]\n")
1127
1347
  log("INFO", f"Deleted backup: {backup_name}")
1128
1348
  except Exception as e:
1129
- console.print(f"\n [red]✗ Failed to delete backup: {e}[/red]\n")
1349
+ console.print(f"\n [red]✗ Failed to delete backup: {escape(str(e))}[/red]\n")
1130
1350
  log("ERROR", f"Delete failed for {backup_name}: {e}")
1131
1351
 
1132
1352
 
@@ -1210,9 +1430,12 @@ def cmd_setup():
1210
1430
  " Compression (gz=fast, bz2=balanced, xz=best)",
1211
1431
  choices=["gz", "bz2", "xz"], default=cfg.get("compression", "gz")
1212
1432
  )
1213
- cfg["max_backups"] = int(Prompt.ask(
1214
- " Max backups to keep", default=str(cfg.get("max_backups", 5))
1215
- ))
1433
+ while True:
1434
+ raw = Prompt.ask(" Max backups to keep", default=str(cfg.get("max_backups", 5))).strip()
1435
+ if raw.isdigit() and int(raw) >= 1:
1436
+ cfg["max_backups"] = int(raw)
1437
+ break
1438
+ console.print(" [red]✗ Enter a whole number of 1 or more.[/red]")
1216
1439
 
1217
1440
  console.print()
1218
1441
  current_patterns = cfg.get("exclude_patterns", [])
@@ -1407,14 +1630,25 @@ def cmd_logs(lines: int = 40):
1407
1630
  if not LOG_FILE.exists():
1408
1631
  console.print(" [dim]No log file yet.[/dim]")
1409
1632
  return
1410
- log_lines = LOG_FILE.read_text().splitlines()[-lines:]
1633
+ all_lines = LOG_FILE.read_text().splitlines()
1634
+ if not DEBUG:
1635
+ all_lines = [ln for ln in all_lines if "[TRACE]" not in ln]
1636
+ log_lines = all_lines[-lines:]
1637
+ if not log_lines:
1638
+ console.print(" [dim]No log entries.[/dim]")
1639
+ return
1640
+ if not DEBUG:
1641
+ console.print(" [dim]Tip: add --debug to also show saved tracebacks.[/dim]\n")
1411
1642
  for line in log_lines:
1643
+ safe = escape(line)
1412
1644
  if "[ERROR]" in line:
1413
- console.print(f" [red]{line}[/red]")
1645
+ console.print(f" [red]{safe}[/red]")
1414
1646
  elif "[WARN]" in line:
1415
- console.print(f" [yellow]{line}[/yellow]")
1647
+ console.print(f" [yellow]{safe}[/yellow]")
1648
+ elif "[TRACE]" in line:
1649
+ console.print(f" [dim red]{safe}[/dim red]")
1416
1650
  else:
1417
- console.print(f" [dim]{line}[/dim]")
1651
+ console.print(f" [dim]{safe}[/dim]")
1418
1652
 
1419
1653
 
1420
1654
  def cmd_clear_logs():
@@ -1644,7 +1878,7 @@ def cmd_clear_cache():
1644
1878
  else:
1645
1879
  child.unlink(missing_ok=True)
1646
1880
  except Exception as e:
1647
- log("WARN", f"Could not remove {child}: {e}")
1881
+ log("WARN", f"Could not remove {child}: {escape(str(e))}")
1648
1882
  prog.update(task, total=1, completed=1,
1649
1883
  description=f"[green]{label} cleared ({human_size(size)}) ✓")
1650
1884
  cleared += size
@@ -1692,15 +1926,17 @@ def cmd_import_config(input_path: str = ""):
1692
1926
 
1693
1927
  src = Path(input_path).expanduser()
1694
1928
  if not src.exists():
1695
- console.print(f" [red]File not found: {src}[/red]\n")
1696
- return
1929
+ raise UserError(str(src), hint="Check the path and try again.", title="File not found")
1697
1930
 
1698
1931
  try:
1699
1932
  with open(src) as f:
1700
1933
  new_cfg = json.load(f)
1701
1934
  except json.JSONDecodeError as e:
1702
- console.print(f" [red]Invalid JSON file: {e}[/red]\n")
1703
- return
1935
+ raise UserError(
1936
+ f"{src}\nLine {e.lineno}, column {e.colno}: {e.msg}",
1937
+ hint="Make sure the file is a valid JSON export from [bold]termux-sync export-config[/bold].",
1938
+ title="Not a valid JSON file",
1939
+ ) from e
1704
1940
 
1705
1941
  existing = load_config()
1706
1942
 
@@ -1717,117 +1953,127 @@ def cmd_import_config(input_path: str = ""):
1717
1953
  log("INFO", f"Config imported from {src}")
1718
1954
 
1719
1955
 
1720
- def ensure_dependencies():
1721
- try:
1722
- import rich
1723
- except ImportError:
1724
- print("Installing required Python packages (rich)…")
1725
- subprocess.run([sys.executable, "-m", "pip", "install", "--quiet", "rich"], check=True)
1726
- print("Done. Please re-run termux-sync.")
1727
- sys.exit(0)
1728
-
1729
-
1730
- def main():
1731
- ensure_dependencies()
1732
-
1733
- parser = argparse.ArgumentParser(
1734
- prog="termux-sync",
1735
- description="Termux Backup & Restore — sync your environment across devices",
1736
- formatter_class=argparse.RawDescriptionHelpFormatter,
1737
- epilog="""\
1738
- Commands:
1739
- backup Create a new backup
1740
- restore Restore from a backup
1741
- list List all available backups
1742
- delete Delete a specific backup
1743
- setup Interactive configuration wizard
1744
- schedule Configure auto-backup schedule
1745
- daemon Run background scheduler (called by Termux:Boot)
1746
- status Show current config and environment
1747
- logs Show recent log entries
1748
- clear-logs Clear / reset the log file
1749
- check Show disk usage (check ~, $PREFIX, or a custom path)
1750
- clear-cache Remove temporary and cached files
1751
- export-config Export config to a file (tokens redacted)
1752
- import-config Import config from a file
1753
-
1754
- Examples:
1755
- termux-sync setup
1756
- termux-sync backup --label "before-new-phone"
1757
- termux-sync list
1758
- termux-sync restore
1759
- termux-sync delete
1760
- termux-sync delete --name termux_backup_20240501_120000
1761
- termux-sync schedule
1762
- termux-sync status
1763
- termux-sync logs --lines 100
1764
- termux-sync clear-logs
1765
- termux-sync check
1766
- termux-sync check ~
1767
- termux-sync check $PREFIX
1768
- termux-sync clear-cache
1769
- termux-sync export-config
1770
- termux-sync import-config ~/termux-sync-config-export.json
1771
- """,
1772
- )
1773
- parser.add_argument("command", nargs="?", default="help",
1774
- choices=["backup", "restore", "list", "delete", "setup", "schedule",
1775
- "daemon", "status", "logs", "clear-logs", "check",
1776
- "clear-cache", "export-config", "import-config", "help"])
1777
- parser.add_argument("target", nargs="?", default="", help="Optional path for check / import-config")
1778
- parser.add_argument("--label", "-l", default="", help="Label for this backup")
1779
- parser.add_argument("--name", "-n", default="", help="Backup name to restore / delete")
1780
- parser.add_argument("--lines", default=40, type=int, help="Lines to show in logs")
1781
- parser.add_argument("--version", "-v", action="store_true", help="Print version and exit")
1782
-
1783
- args = parser.parse_args()
1956
+ # ───────────────────────── Command line ─────────────────────────
1957
+ COMMAND_GROUPS = [
1958
+ ("Backup & Restore", [
1959
+ ("backup", "Create a new backup"),
1960
+ ("restore", "Restore from a backup"),
1961
+ ("list", "List all available backups"),
1962
+ ("delete", "Delete a specific backup"),
1963
+ ]),
1964
+ ("Configuration", [
1965
+ ("setup", "Interactive configuration wizard"),
1966
+ ("schedule", "Configure auto-backup schedule"),
1967
+ ("daemon", "Run background scheduler (used by Termux:Boot)"),
1968
+ ("export-config", "Export config to a file (tokens redacted)"),
1969
+ ("import-config", "Import config from a file"),
1970
+ ]),
1971
+ ("Maintenance", [
1972
+ ("status", "Show current config and environment"),
1973
+ ("logs", "Show recent log entries"),
1974
+ ("clear-logs", "Clear / reset the log file"),
1975
+ ("check", "Show disk usage (~, $PREFIX, or a custom path)"),
1976
+ ("clear-cache", "Remove temporary and cached files"),
1977
+ ("help", "Show this help"),
1978
+ ]),
1979
+ ]
1980
+ COMMANDS = [name for _, items in COMMAND_GROUPS for name, _ in items]
1981
+
1982
+ OPTIONS = [
1983
+ ("-l, --label LABEL", "Label for this backup"),
1984
+ ("-n, --name NAME", "Backup name to restore / delete"),
1985
+ ("--lines N", "Lines to show in logs (default 40)"),
1986
+ ("--debug", "Show the full traceback if something fails"),
1987
+ ("-v, --version", "Print version and exit"),
1988
+ ("-h, --help", "Show this help"),
1989
+ ]
1784
1990
 
1785
- if args.version:
1786
- print(f"termux-sync v{VERSION}")
1787
- return
1991
+ EXAMPLES = [
1992
+ ("termux-sync setup", "first-time configuration"),
1993
+ ("termux-sync backup --label before-new-phone", "create a labelled backup"),
1994
+ ("termux-sync list", "see all backups"),
1995
+ ("termux-sync restore", "pick a backup to restore"),
1996
+ ("termux-sync delete --name <backup-name>", "delete a specific backup"),
1997
+ ("termux-sync logs --lines 100", "show the last 100 log lines"),
1998
+ ("termux-sync check ~", "disk usage of your home"),
1999
+ ("termux-sync import-config <file>", "load a config file"),
2000
+ ]
1788
2001
 
1789
- if args.command == "help" or args.command is None:
1790
- print_banner()
1791
- parser.print_help()
1792
- return
2002
+ # Commands people commonly try that live elsewhere.
2003
+ COMMAND_HINTS = {
2004
+ "update": "termux-sync has no built-in update command. Use [bold]tsctl updater[/bold] to update.",
2005
+ "upgrade": "To update, use [bold]tsctl updater[/bold].",
2006
+ "install": "To install or reinstall, use the install script, or [bold]tsctl[/bold].",
2007
+ "remove": "To delete a backup use [bold]termux-sync delete[/bold]. To uninstall the tool use [bold]tsctl uninstall[/bold].",
2008
+ "config": "Use [bold]termux-sync setup[/bold] to change settings, or [bold]termux-sync status[/bold] to view them.",
2009
+ "sync": "Use [bold]termux-sync backup[/bold] to upload, or [bold]termux-sync restore[/bold] to download.",
2010
+ }
1793
2011
 
1794
- cfg = load_config()
1795
2012
 
1796
- try:
1797
- if args.command == "setup":
1798
- cmd_setup()
1799
- elif args.command == "backup":
1800
- cmd_backup(cfg, label=args.label)
1801
- elif args.command == "restore":
1802
- cmd_restore(cfg, backup_name=args.name)
1803
- elif args.command == "list":
1804
- cmd_list(cfg)
1805
- elif args.command == "delete":
1806
- cmd_delete(cfg, backup_name=args.name)
1807
- elif args.command == "schedule":
1808
- cmd_schedule()
1809
- elif args.command == "daemon":
1810
- cmd_daemon(cfg)
1811
- elif args.command == "status":
1812
- cmd_status(cfg)
1813
- elif args.command == "logs":
1814
- cmd_logs(args.lines)
1815
- elif args.command == "clear-logs":
1816
- cmd_clear_logs()
1817
- elif args.command == "check":
1818
- cmd_check(args.target or "")
1819
- elif args.command == "clear-cache":
1820
- cmd_clear_cache()
1821
- elif args.command == "export-config":
1822
- cmd_export_config(args.target or "")
1823
- elif args.command == "import-config":
1824
- cmd_import_config(args.target or "")
1825
- except KeyboardInterrupt:
1826
- _abort()
2013
+ def print_help():
2014
+ print_banner()
2015
+ console.print(f" [bold]Usage:[/bold] termux-sync [cyan]<command>[/cyan] [dim]{escape('[target] [options]')}[/dim]")
2016
+ console.print()
2017
+
2018
+ for group, items in COMMAND_GROUPS:
2019
+ table = Table(box=None, show_header=False, padding=(0, 2), pad_edge=False)
2020
+ table.add_column(style="bold cyan", no_wrap=True)
2021
+ table.add_column()
2022
+ for name, desc in items:
2023
+ table.add_row(name, escape(desc))
2024
+ console.print(f" [bold yellow]{group}[/bold yellow]")
2025
+ console.print(Padding(table, (0, 0, 1, 2)))
2026
+
2027
+ table = Table(box=None, show_header=False, padding=(0, 2), pad_edge=False)
2028
+ table.add_column(style="green", no_wrap=True)
2029
+ table.add_column()
2030
+ for flag, desc in OPTIONS:
2031
+ table.add_row(escape(flag), escape(desc))
2032
+ console.print(" [bold yellow]Options[/bold yellow]")
2033
+ console.print(Padding(table, (0, 0, 1, 2)))
2034
+
2035
+ console.print(" [bold yellow]Examples[/bold yellow]")
2036
+ for cmd, _ in EXAMPLES:
2037
+ console.print(f" [dim]$[/dim] [white]{escape(cmd)}[/white]", soft_wrap=True)
2038
+ console.print()
2039
+
2040
+
2041
+ class FriendlyParser(argparse.ArgumentParser):
2042
+ """argparse, but errors are shown as a tidy panel instead of a usage dump."""
2043
+
2044
+ def error(self, message):
2045
+ m = re.search(r"invalid choice: '([^']*)'", message)
2046
+ if m:
2047
+ bad = m.group(1)
2048
+ hint_lines = []
2049
+ if bad in COMMAND_HINTS:
2050
+ hint_lines.append(COMMAND_HINTS[bad])
2051
+ close = [] if bad in COMMAND_HINTS else difflib.get_close_matches(bad, COMMANDS, n=3, cutoff=0.6)
2052
+ if close:
2053
+ hint_lines.append("Did you mean: " + " ".join(f"[bold cyan]{c}[/bold cyan]" for c in close))
2054
+ hint_lines.append("Run [bold]termux-sync help[/bold] to see all commands.")
2055
+ print_error("Unknown command", f"'{bad}' is not a termux-sync command.", "\n\n".join(hint_lines))
2056
+ else:
2057
+ nice = message.replace("argument --lines/", "--lines").replace("argument ", "")
2058
+ print_error("Invalid usage", nice,
2059
+ "Run [bold]termux-sync help[/bold] to see the correct usage.")
2060
+ sys.exit(2)
2061
+
2062
+
2063
+ def build_parser() -> argparse.ArgumentParser:
2064
+ parser = FriendlyParser(prog="termux-sync", add_help=False)
2065
+ parser.add_argument("command", nargs="?", default="help", choices=COMMANDS, metavar="command")
2066
+ parser.add_argument("target", nargs="?", default="")
2067
+ parser.add_argument("--label", "-l", default="")
2068
+ parser.add_argument("--name", "-n", default="")
2069
+ parser.add_argument("--lines", default=40, type=int)
2070
+ parser.add_argument("--debug", action="store_true")
2071
+ parser.add_argument("--version", "-v", action="store_true")
2072
+ parser.add_argument("--help", "-h", action="store_true")
2073
+ return parser
1827
2074
 
1828
2075
 
1829
2076
  def _abort():
1830
- console.print()
1831
2077
  console.print()
1832
2078
  console.print(
1833
2079
  Panel(
@@ -1837,9 +2083,49 @@ def _abort():
1837
2083
  " Run the command again whenever you're ready.",
1838
2084
  border_style="yellow",
1839
2085
  padding=(1, 2),
2086
+ expand=False,
1840
2087
  )
1841
2088
  )
1842
- sys.exit(0)
2089
+ sys.exit(130)
2090
+
2091
+
2092
+ def main(argv=None):
2093
+ global DEBUG
2094
+ args = build_parser().parse_args(argv)
2095
+ DEBUG = DEBUG or args.debug
2096
+
2097
+ if args.version:
2098
+ print(f"termux-sync v{VERSION}")
2099
+ return
2100
+
2101
+ if args.help or args.command in (None, "help"):
2102
+ print_help()
2103
+ return
2104
+
2105
+ try:
2106
+ cfg = load_config()
2107
+ handlers = {
2108
+ "setup": lambda: cmd_setup(),
2109
+ "backup": lambda: cmd_backup(cfg, label=args.label),
2110
+ "restore": lambda: cmd_restore(cfg, backup_name=args.name),
2111
+ "list": lambda: cmd_list(cfg),
2112
+ "delete": lambda: cmd_delete(cfg, backup_name=args.name),
2113
+ "schedule": lambda: cmd_schedule(),
2114
+ "daemon": lambda: cmd_daemon(cfg),
2115
+ "status": lambda: cmd_status(cfg),
2116
+ "logs": lambda: cmd_logs(args.lines),
2117
+ "clear-logs": lambda: cmd_clear_logs(),
2118
+ "check": lambda: cmd_check(args.target or ""),
2119
+ "clear-cache": lambda: cmd_clear_cache(),
2120
+ "export-config": lambda: cmd_export_config(args.target or ""),
2121
+ "import-config": lambda: cmd_import_config(args.target or ""),
2122
+ }
2123
+ handlers[args.command]()
2124
+ except KeyboardInterrupt:
2125
+ _abort()
2126
+ except Exception as exc:
2127
+ handle_crash(exc)
2128
+ sys.exit(1)
1843
2129
 
1844
2130
 
1845
2131
  if __name__ == "__main__":
File without changes
File without changes