termux-sync 1.2.0__tar.gz → 1.2.2__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.
@@ -18,6 +18,27 @@ Version numbers follow [Semantic Versioning](https://semver.org/).
18
18
 
19
19
  ---
20
20
 
21
+ ## [1.2.2] - 2026-07-11
22
+
23
+ ### Fixed
24
+
25
+ - **`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.
26
+
27
+ ---
28
+
29
+ ## [1.2.1] - 2026-05-22
30
+
31
+ ### Fixed
32
+
33
+ - **`setup` — required fields accepted empty input silently** — pressing Enter on the `GitHub Personal Access Token` and `GitHub repo (owner/repo)` prompts passed through an empty string, which would later cause a crash or silent failure when actually running a backup or restore. Both fields are now validated in a loop that keeps prompting until a non-empty value is provided, with a descriptive error message on each failed attempt.
34
+ - `github_token`: re-prompts with `✗ Token cannot be empty. Paste your Personal Access Token.`
35
+ - `github_repo`: re-prompts with `✗ Invalid format. Use: username/repo-name` if empty or if the value does not contain exactly one `/` in a valid position
36
+ - `local_path`: re-prompts with `✗ Path cannot be empty.` if the user clears the default and submits nothing
37
+ - **`setup` — existing token not acknowledged** — when a GitHub token was already saved in config, the password prompt showed an empty field with no indication that a value already existed. A hint line is now printed before the prompt: `A token is already saved. Press Enter to keep it, or paste a new one.`
38
+ - **`KeyboardInterrupt` traceback instead of clean exit** — pressing Ctrl+C during any interactive prompt (e.g. `setup`, `schedule`, `restore`, `delete`) printed a full Python traceback instead of the intended cancellation panel. The root cause was that the `try/except KeyboardInterrupt` block only wrapped `main()` inside `if __name__ == "__main__"`, which is never executed when the script is invoked via a pip entry point or the tsctl launcher. The handler has been moved inside `main()` itself so it fires regardless of invocation method. The panel logic was extracted into a dedicated `_abort()` helper and `if __name__ == "__main__"` simplified to a bare `main()` call.
39
+
40
+ ---
41
+
21
42
  ## [1.2.0] - 2026-05-22
22
43
 
23
44
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termux-sync
3
- Version: 1.2.0
3
+ Version: 1.2.2
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.0"
7
+ version = "1.2.2"
8
8
  description = "Backup and restore your entire Termux environment across devices"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -43,7 +43,7 @@ try:
43
43
  except ImportError:
44
44
  RICH_AVAILABLE = False
45
45
 
46
- VERSION = "1.2.0"
46
+ VERSION = "1.2.1"
47
47
  APP_NAME = "termux-sync"
48
48
  CONFIG_DIR = Path.home() / ".config" / APP_NAME
49
49
  CONFIG_FILE = CONFIG_DIR / "config.json"
@@ -469,6 +469,15 @@ class GitHubStorage:
469
469
  "Accept": "application/vnd.github.v3+json",
470
470
  }
471
471
 
472
+ def _asset_headers(self) -> dict:
473
+ return {
474
+ "Authorization": f"token {self.token}",
475
+ "Accept": "application/octet-stream",
476
+ }
477
+
478
+ def _asset_url(self, asset_id: int) -> str:
479
+ return f"{self.API}/repos/{self.repo}/releases/assets/{asset_id}"
480
+
472
481
  def _api(self, method: str, endpoint: str, **kwargs):
473
482
  import urllib.request, urllib.error
474
483
  url = f"{self.API}{endpoint}"
@@ -633,8 +642,8 @@ class GitHubStorage:
633
642
  try:
634
643
  import urllib.request
635
644
  req = urllib.request.Request(
636
- asset["browser_download_url"],
637
- headers=self._headers()
645
+ self._asset_url(asset["id"]),
646
+ headers=self._asset_headers()
638
647
  )
639
648
  with urllib.request.urlopen(req, timeout=30) as r:
640
649
  manifest = json.loads(r.read())
@@ -659,14 +668,14 @@ class GitHubStorage:
659
668
  dl_task = progress.add_task("[cyan]Downloading from GitHub...", total=len(assets))
660
669
 
661
670
  for asset in assets:
662
- url = asset["browser_download_url"]
671
+ url = self._asset_url(asset["id"])
663
672
  out = dest / asset["name"]
664
673
  size = asset.get("size", 0)
665
674
  f_task = progress.add_task(f"[cyan]{asset['name']}...", total=max(size, 1))
666
675
  last_err = None
667
676
  for attempt in range(8):
668
677
  try:
669
- req = urllib.request.Request(url, headers=self._headers())
678
+ req = urllib.request.Request(url, headers=self._asset_headers())
670
679
  with urllib.request.urlopen(req, timeout=120) as r:
671
680
  data = r.read()
672
681
  out.write_bytes(data)
@@ -1138,9 +1147,14 @@ def cmd_setup():
1138
1147
  cfg["storage"] = storage_map[choice]
1139
1148
 
1140
1149
  if cfg["storage"] == "local":
1141
- cfg["local_path"] = Prompt.ask(
1142
- " Local backup path", default=cfg.get("local_path", str(DEFAULT_DEST))
1143
- )
1150
+ while True:
1151
+ val = Prompt.ask(
1152
+ " Local backup path", default=cfg.get("local_path", str(DEFAULT_DEST))
1153
+ ).strip()
1154
+ if val:
1155
+ cfg["local_path"] = val
1156
+ break
1157
+ console.print(" [red]✗ Path cannot be empty.[/red]")
1144
1158
 
1145
1159
  elif cfg["storage"] == "gdrive":
1146
1160
  console.print()
@@ -1159,12 +1173,34 @@ def cmd_setup():
1159
1173
  )
1160
1174
  console.print(" [dim]Token URL:[/dim] [white]https://github.com/settings/tokens[/white]")
1161
1175
  console.print()
1162
- cfg["github_token"] = Prompt.ask(
1163
- " GitHub Personal Access Token", default=cfg.get("github_token", ""), password=True
1164
- )
1165
- cfg["github_repo"] = Prompt.ask(
1166
- " GitHub repo (owner/repo)", default=cfg.get("github_repo", "")
1167
- )
1176
+
1177
+ existing_token = cfg.get("github_token", "")
1178
+ if existing_token:
1179
+ console.print(" [dim]A token is already saved. Press Enter to keep it, or paste a new one.[/dim]")
1180
+ while True:
1181
+ val = Prompt.ask(
1182
+ " GitHub Personal Access Token",
1183
+ default=existing_token,
1184
+ password=True,
1185
+ ).strip()
1186
+ if val:
1187
+ cfg["github_token"] = val
1188
+ break
1189
+ console.print(" [red]✗ Token cannot be empty. Paste your Personal Access Token.[/red]")
1190
+
1191
+ while True:
1192
+ val = Prompt.ask(
1193
+ " GitHub repo (owner/repo)", default=cfg.get("github_repo", "")
1194
+ ).strip()
1195
+ if not val:
1196
+ console.print(" [red]✗ Repo cannot be empty. Enter in format: username/repo-name[/red]")
1197
+ continue
1198
+ if "/" not in val or val.startswith("/") or val.endswith("/"):
1199
+ console.print(" [red]✗ Invalid format. Use: username/repo-name (e.g. djunekz/my-termux-backup)[/red]")
1200
+ continue
1201
+ cfg["github_repo"] = val
1202
+ break
1203
+
1168
1204
  cfg["github_branch"] = Prompt.ask(
1169
1205
  " Branch", default=cfg.get("github_branch", "main")
1170
1206
  )
@@ -1757,51 +1793,54 @@ Examples:
1757
1793
 
1758
1794
  cfg = load_config()
1759
1795
 
1760
- if args.command == "setup":
1761
- cmd_setup()
1762
- elif args.command == "backup":
1763
- cmd_backup(cfg, label=args.label)
1764
- elif args.command == "restore":
1765
- cmd_restore(cfg, backup_name=args.name)
1766
- elif args.command == "list":
1767
- cmd_list(cfg)
1768
- elif args.command == "delete":
1769
- cmd_delete(cfg, backup_name=args.name)
1770
- elif args.command == "schedule":
1771
- cmd_schedule()
1772
- elif args.command == "daemon":
1773
- cmd_daemon(cfg)
1774
- elif args.command == "status":
1775
- cmd_status(cfg)
1776
- elif args.command == "logs":
1777
- cmd_logs(args.lines)
1778
- elif args.command == "clear-logs":
1779
- cmd_clear_logs()
1780
- elif args.command == "check":
1781
- cmd_check(args.target or "")
1782
- elif args.command == "clear-cache":
1783
- cmd_clear_cache()
1784
- elif args.command == "export-config":
1785
- cmd_export_config(args.target or "")
1786
- elif args.command == "import-config":
1787
- cmd_import_config(args.target or "")
1788
-
1789
-
1790
- if __name__ == "__main__":
1791
1796
  try:
1792
- main()
1797
+ if args.command == "setup":
1798
+ cmd_setup()
1799
+ elif args.command == "backup":
1800
+ cmd_backup(cfg, label=args.label)
1801
+ elif args.command == "restore":
1802
+ cmd_restore(cfg, backup_name=args.name)
1803
+ elif args.command == "list":
1804
+ cmd_list(cfg)
1805
+ elif args.command == "delete":
1806
+ cmd_delete(cfg, backup_name=args.name)
1807
+ elif args.command == "schedule":
1808
+ cmd_schedule()
1809
+ elif args.command == "daemon":
1810
+ cmd_daemon(cfg)
1811
+ elif args.command == "status":
1812
+ cmd_status(cfg)
1813
+ elif args.command == "logs":
1814
+ cmd_logs(args.lines)
1815
+ elif args.command == "clear-logs":
1816
+ cmd_clear_logs()
1817
+ elif args.command == "check":
1818
+ cmd_check(args.target or "")
1819
+ elif args.command == "clear-cache":
1820
+ cmd_clear_cache()
1821
+ elif args.command == "export-config":
1822
+ cmd_export_config(args.target or "")
1823
+ elif args.command == "import-config":
1824
+ cmd_import_config(args.target or "")
1793
1825
  except KeyboardInterrupt:
1794
- console = Console()
1795
- console.print()
1796
- console.print()
1797
- console.print(
1798
- Panel(
1799
- "[bold yellow]Operation cancelled[/bold yellow]\n\n"
1800
- " You pressed [bold]Ctrl+C[/bold] — termux-sync was stopped.\n"
1801
- " [dim]Any partial temporary files have been cleaned up.[/dim]\n\n"
1802
- " Run the command again whenever you're ready.",
1803
- border_style="yellow",
1804
- padding=(1, 2),
1805
- )
1826
+ _abort()
1827
+
1828
+
1829
+ def _abort():
1830
+ console.print()
1831
+ console.print()
1832
+ console.print(
1833
+ Panel(
1834
+ "[bold yellow]Operation cancelled[/bold yellow]\n\n"
1835
+ " You pressed [bold]Ctrl+C[/bold] — termux-sync was stopped.\n"
1836
+ " [dim]Any partial temporary files have been cleaned up.[/dim]\n\n"
1837
+ " Run the command again whenever you're ready.",
1838
+ border_style="yellow",
1839
+ padding=(1, 2),
1806
1840
  )
1807
- sys.exit(0)
1841
+ )
1842
+ sys.exit(0)
1843
+
1844
+
1845
+ if __name__ == "__main__":
1846
+ main()
File without changes
File without changes