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