termux-sync 1.2.4__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.
@@ -11,6 +11,21 @@ Version numbers follow [Semantic Versioning](https://semver.org/).
11
11
 
12
12
  ---
13
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
+
14
29
  ## [1.2.4] - 2026-07-28
15
30
 
16
31
  ### Security
@@ -42,7 +57,7 @@ Version numbers follow [Semantic Versioning](https://semver.org/).
42
57
 
43
58
  ### Fixed
44
59
 
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.
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)
46
61
 
47
62
  ---
48
63
 
@@ -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.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.4"
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.4"
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(
@@ -418,8 +558,12 @@ class GDriveStorage:
418
558
 
419
559
  def _check_rclone(self):
420
560
  if shutil.which("rclone") is None:
421
- console.print("[red]rclone not found. Install: pkg install rclone[/red]")
422
- 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
+ )
423
567
 
424
568
  def _remote_path(self, name: str = "") -> str:
425
569
  base = f"{self.REMOTE}:"
@@ -504,8 +648,12 @@ class GitHubStorage:
504
648
  self.repo = cfg.get("github_repo", "")
505
649
  self.branch = cfg.get("github_branch", "main")
506
650
  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)
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
+ )
509
657
 
510
658
  def _headers(self) -> dict:
511
659
  return {
@@ -877,7 +1025,7 @@ def cmd_backup(cfg: dict, label: str = ""):
877
1025
  }
878
1026
  log("INFO", f"Archived {src} → {actual_out.name} ({human_size(actual_out.stat().st_size)})")
879
1027
  except Exception as e:
880
- console.print(f" [red]✗ Failed to archive {src}: {e}[/red]")
1028
+ console.print(f" [red]✗ Failed to archive {src}: {escape(str(e))}[/red]")
881
1029
  log("ERROR", f"Archive failed for {src}: {e}")
882
1030
 
883
1031
  console.print()
@@ -904,7 +1052,7 @@ def cmd_backup(cfg: dict, label: str = ""):
904
1052
  _cleanup_tmp()
905
1053
  raise
906
1054
  except Exception as e:
907
- console.print(f" [red]✗ Upload failed: {e}[/red]")
1055
+ console.print(f" [red]✗ Upload failed: {escape(str(e))}[/red]")
908
1056
  log("ERROR", f"Upload failed: {e}")
909
1057
  _cleanup_tmp()
910
1058
  return
@@ -1000,7 +1148,7 @@ def cmd_restore(cfg: dict, backup_name: str = ""):
1000
1148
  try:
1001
1149
  storage.download(backup_name, tmp_dir, progress)
1002
1150
  except Exception as e:
1003
- console.print(f" [red]✗ Download failed: {e}[/red]")
1151
+ console.print(f" [red]✗ Download failed: {escape(str(e))}[/red]")
1004
1152
  log("ERROR", f"Download failed: {e}")
1005
1153
  shutil.rmtree(tmp_dir, ignore_errors=True)
1006
1154
  return
@@ -1082,7 +1230,7 @@ def cmd_restore(cfg: dict, backup_name: str = ""):
1082
1230
  extract_archive(archive, src_dir, info["archive"], progress)
1083
1231
  log("INFO", f"Extracted {info['archive']} → {src_dir}")
1084
1232
  except Exception as e:
1085
- console.print(f" [red]✗ Extract failed: {e}[/red]")
1233
+ console.print(f" [red]✗ Extract failed: {escape(str(e))}[/red]")
1086
1234
  log("ERROR", f"Extract failed for {info['archive']}: {e}")
1087
1235
 
1088
1236
  pkgs = manifest.get("packages", [])
@@ -1198,7 +1346,7 @@ def cmd_delete(cfg: dict, backup_name: str = ""):
1198
1346
  console.print(f"\n [bold green]✓ Backup '{backup_name}' deleted successfully.[/bold green]\n")
1199
1347
  log("INFO", f"Deleted backup: {backup_name}")
1200
1348
  except Exception as e:
1201
- 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")
1202
1350
  log("ERROR", f"Delete failed for {backup_name}: {e}")
1203
1351
 
1204
1352
 
@@ -1282,9 +1430,12 @@ def cmd_setup():
1282
1430
  " Compression (gz=fast, bz2=balanced, xz=best)",
1283
1431
  choices=["gz", "bz2", "xz"], default=cfg.get("compression", "gz")
1284
1432
  )
1285
- cfg["max_backups"] = int(Prompt.ask(
1286
- " Max backups to keep", default=str(cfg.get("max_backups", 5))
1287
- ))
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]")
1288
1439
 
1289
1440
  console.print()
1290
1441
  current_patterns = cfg.get("exclude_patterns", [])
@@ -1479,14 +1630,25 @@ def cmd_logs(lines: int = 40):
1479
1630
  if not LOG_FILE.exists():
1480
1631
  console.print(" [dim]No log file yet.[/dim]")
1481
1632
  return
1482
- 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")
1483
1642
  for line in log_lines:
1643
+ safe = escape(line)
1484
1644
  if "[ERROR]" in line:
1485
- console.print(f" [red]{line}[/red]")
1645
+ console.print(f" [red]{safe}[/red]")
1486
1646
  elif "[WARN]" in line:
1487
- 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]")
1488
1650
  else:
1489
- console.print(f" [dim]{line}[/dim]")
1651
+ console.print(f" [dim]{safe}[/dim]")
1490
1652
 
1491
1653
 
1492
1654
  def cmd_clear_logs():
@@ -1716,7 +1878,7 @@ def cmd_clear_cache():
1716
1878
  else:
1717
1879
  child.unlink(missing_ok=True)
1718
1880
  except Exception as e:
1719
- log("WARN", f"Could not remove {child}: {e}")
1881
+ log("WARN", f"Could not remove {child}: {escape(str(e))}")
1720
1882
  prog.update(task, total=1, completed=1,
1721
1883
  description=f"[green]{label} cleared ({human_size(size)}) ✓")
1722
1884
  cleared += size
@@ -1764,15 +1926,17 @@ def cmd_import_config(input_path: str = ""):
1764
1926
 
1765
1927
  src = Path(input_path).expanduser()
1766
1928
  if not src.exists():
1767
- console.print(f" [red]File not found: {src}[/red]\n")
1768
- return
1929
+ raise UserError(str(src), hint="Check the path and try again.", title="File not found")
1769
1930
 
1770
1931
  try:
1771
1932
  with open(src) as f:
1772
1933
  new_cfg = json.load(f)
1773
1934
  except json.JSONDecodeError as e:
1774
- console.print(f" [red]Invalid JSON file: {e}[/red]\n")
1775
- 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
1776
1940
 
1777
1941
  existing = load_config()
1778
1942
 
@@ -1789,117 +1953,127 @@ def cmd_import_config(input_path: str = ""):
1789
1953
  log("INFO", f"Config imported from {src}")
1790
1954
 
1791
1955
 
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()
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
+ ]
1856
1990
 
1857
- if args.version:
1858
- print(f"termux-sync v{VERSION}")
1859
- 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
+ ]
1860
2001
 
1861
- if args.command == "help" or args.command is None:
1862
- print_banner()
1863
- parser.print_help()
1864
- 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
+ }
1865
2011
 
1866
- cfg = load_config()
1867
2012
 
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()
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
1899
2074
 
1900
2075
 
1901
2076
  def _abort():
1902
- console.print()
1903
2077
  console.print()
1904
2078
  console.print(
1905
2079
  Panel(
@@ -1909,9 +2083,49 @@ def _abort():
1909
2083
  " Run the command again whenever you're ready.",
1910
2084
  border_style="yellow",
1911
2085
  padding=(1, 2),
2086
+ expand=False,
1912
2087
  )
1913
2088
  )
1914
- 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)
1915
2129
 
1916
2130
 
1917
2131
  if __name__ == "__main__":
File without changes
File without changes