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.
- {termux_sync-1.2.4 → termux_sync-1.2.6}/CHANGELOG.md +16 -1
- {termux_sync-1.2.4 → termux_sync-1.2.6}/PKG-INFO +2 -2
- {termux_sync-1.2.4 → termux_sync-1.2.6}/pyproject.toml +1 -1
- {termux_sync-1.2.4 → termux_sync-1.2.6}/src/termux_sync/__init__.py +344 -130
- {termux_sync-1.2.4 → termux_sync-1.2.6}/LICENSE +0 -0
- {termux_sync-1.2.4 → termux_sync-1.2.6}/README.md +0 -0
|
@@ -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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: termux-sync
|
|
3
|
-
Version: 1.2.
|
|
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
|
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
422
|
-
|
|
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
|
-
|
|
508
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]{
|
|
1645
|
+
console.print(f" [red]{safe}[/red]")
|
|
1486
1646
|
elif "[WARN]" in line:
|
|
1487
|
-
console.print(f" [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]{
|
|
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
|
-
|
|
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
|
-
|
|
1775
|
-
|
|
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
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
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
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
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
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
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
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
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(
|
|
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
|