td-task 0.2.5__tar.gz → 0.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.
Files changed (30) hide show
  1. {td_task-0.2.5 → td_task-0.2.6}/PKG-INFO +38 -2
  2. {td_task-0.2.5 → td_task-0.2.6}/README.md +37 -1
  3. {td_task-0.2.5 → td_task-0.2.6}/pyproject.toml +1 -1
  4. {td_task-0.2.5 → td_task-0.2.6}/src/td/CHANGELOG.md +7 -0
  5. {td_task-0.2.5 → td_task-0.2.6}/src/td/__main__.py +100 -0
  6. td_task-0.2.6/src/td/backup.py +304 -0
  7. td_task-0.2.6/src/td/config.py +55 -0
  8. {td_task-0.2.5 → td_task-0.2.6}/src/td/tui.py +197 -2
  9. td_task-0.2.6/tests/test_backup.py +129 -0
  10. {td_task-0.2.5 → td_task-0.2.6}/tests/test_cli_notes.py +7 -0
  11. {td_task-0.2.5 → td_task-0.2.6}/tests/test_tui_notes.py +17 -0
  12. {td_task-0.2.5 → td_task-0.2.6}/uv.lock +1 -1
  13. {td_task-0.2.5 → td_task-0.2.6}/.env.enc +0 -0
  14. {td_task-0.2.5 → td_task-0.2.6}/.github/workflows/pages.yml +0 -0
  15. {td_task-0.2.5 → td_task-0.2.6}/.github/workflows/publish.yml +0 -0
  16. {td_task-0.2.5 → td_task-0.2.6}/.gitignore +0 -0
  17. {td_task-0.2.5 → td_task-0.2.6}/.python-version +0 -0
  18. {td_task-0.2.5 → td_task-0.2.6}/assets/demo.gif +0 -0
  19. {td_task-0.2.5 → td_task-0.2.6}/demo.tape +0 -0
  20. {td_task-0.2.5 → td_task-0.2.6}/index.html +0 -0
  21. {td_task-0.2.5 → td_task-0.2.6}/src/td/__init__.py +0 -0
  22. {td_task-0.2.5 → td_task-0.2.6}/src/td/db.py +0 -0
  23. {td_task-0.2.5 → td_task-0.2.6}/src/td/terminal.py +0 -0
  24. {td_task-0.2.5 → td_task-0.2.6}/tests/test_notes.py +0 -0
  25. {td_task-0.2.5 → td_task-0.2.6}/tests/test_resize.py +0 -0
  26. {td_task-0.2.5 → td_task-0.2.6}/tests/test_update.py +0 -0
  27. {td_task-0.2.5 → td_task-0.2.6}/website/README.md +0 -0
  28. {td_task-0.2.5 → td_task-0.2.6}/website/favicon.svg +0 -0
  29. {td_task-0.2.5 → td_task-0.2.6}/website/main.js +0 -0
  30. {td_task-0.2.5 → td_task-0.2.6}/website/style.css +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: td-task
3
- Version: 0.2.5
3
+ Version: 0.2.6
4
4
  Summary: Fast TUI and CLI manager for tasks and Markdown notes
5
5
  Requires-Python: >=3.14
6
6
  Requires-Dist: cryptography>=42.0.0
@@ -66,6 +66,8 @@ td [command] [--list <name>]
66
66
  | `td archive` | Open the archive screen |
67
67
  | `td export [file]` | Export database to JSON (stdout if no file) |
68
68
  | `td import <file>` | Merge a JSON backup into the database |
69
+ | `td backup github [repo]` | Encrypt and upload a backup to a private GitHub repo |
70
+ | `td restore github [repo]` | Download and restore an encrypted GitHub backup |
69
71
  | `td update` | Upgrade to the latest version |
70
72
  | `td --version` | Print the installed version |
71
73
  | `td --help` | Print the help menu |
@@ -207,7 +209,8 @@ marker. Press `Enter` or `Backspace` on an empty continuation to leave list mode
207
209
  | `q` / `Esc` | Return |
208
210
 
209
211
  Settings you can change: max tasks per list (3–50), max starred tasks, whole-database
210
- encryption, current-list encryption, and backup export / import.
212
+ encryption, current-list encryption, backup export / import, GitHub backup repo,
213
+ GitHub backup now, auto-sync, and GitHub backup encryption.
211
214
 
212
215
  ## Agent-friendly
213
216
 
@@ -292,9 +295,42 @@ td export > backup.json
292
295
 
293
296
  # restore on another machine
294
297
  td import backup.json
298
+
299
+ # encrypt and upload to a private GitHub repo (creates td-backup if missing)
300
+ td backup github --save-password
301
+ td backup github lucaspon/my-td-backup --save-password
302
+
303
+ # optional: store plain JSON instead of an encrypted backup
304
+ td backup github --no-encrypt
305
+
306
+ # restore and merge from GitHub
307
+ td restore github
295
308
  ```
296
309
 
297
310
  You can encrypt the whole database or only the current list from settings (`/`).
298
311
  Each encrypted list uses its own password-derived key. Tasks and note bodies stay
299
312
  encrypted in JSON backups. Whole-database and per-list encryption cannot run
300
313
  together. If you forget a password, there is no recovery.
314
+
315
+ By default, GitHub backups add a separate password-based encryption layer around
316
+ the full JSON export. In encrypted mode, `td` sends only the encrypted envelope
317
+ to GitHub. The `gh` CLI must be
318
+ installed and authenticated. `td backup github` creates the target repository as
319
+ private when it does not exist, and refuses to write to a public repository.
320
+
321
+ Auto-sync uses `~/.config/td/config.toml`:
322
+
323
+ ```toml
324
+ [github_backup]
325
+ auto_sync = true
326
+ repo = "owner/td-backup"
327
+ encrypt = true
328
+ ```
329
+
330
+ Auto-sync runs after TUI sessions and CLI writes. Encrypted auto-sync needs a
331
+ password saved in macOS Keychain with `td backup github --save-password`.
332
+ Set `encrypt = false` to
333
+ store plain JSON. The repository remains private, but GitHub can then read the
334
+ backup. Encrypted mode remains the default. `td` stores a digest of the last
335
+ successful export and skips the GitHub update when the data has not changed.
336
+ Manual backups update the same digest.
@@ -55,6 +55,8 @@ td [command] [--list <name>]
55
55
  | `td archive` | Open the archive screen |
56
56
  | `td export [file]` | Export database to JSON (stdout if no file) |
57
57
  | `td import <file>` | Merge a JSON backup into the database |
58
+ | `td backup github [repo]` | Encrypt and upload a backup to a private GitHub repo |
59
+ | `td restore github [repo]` | Download and restore an encrypted GitHub backup |
58
60
  | `td update` | Upgrade to the latest version |
59
61
  | `td --version` | Print the installed version |
60
62
  | `td --help` | Print the help menu |
@@ -196,7 +198,8 @@ marker. Press `Enter` or `Backspace` on an empty continuation to leave list mode
196
198
  | `q` / `Esc` | Return |
197
199
 
198
200
  Settings you can change: max tasks per list (3–50), max starred tasks, whole-database
199
- encryption, current-list encryption, and backup export / import.
201
+ encryption, current-list encryption, backup export / import, GitHub backup repo,
202
+ GitHub backup now, auto-sync, and GitHub backup encryption.
200
203
 
201
204
  ## Agent-friendly
202
205
 
@@ -281,9 +284,42 @@ td export > backup.json
281
284
 
282
285
  # restore on another machine
283
286
  td import backup.json
287
+
288
+ # encrypt and upload to a private GitHub repo (creates td-backup if missing)
289
+ td backup github --save-password
290
+ td backup github lucaspon/my-td-backup --save-password
291
+
292
+ # optional: store plain JSON instead of an encrypted backup
293
+ td backup github --no-encrypt
294
+
295
+ # restore and merge from GitHub
296
+ td restore github
284
297
  ```
285
298
 
286
299
  You can encrypt the whole database or only the current list from settings (`/`).
287
300
  Each encrypted list uses its own password-derived key. Tasks and note bodies stay
288
301
  encrypted in JSON backups. Whole-database and per-list encryption cannot run
289
302
  together. If you forget a password, there is no recovery.
303
+
304
+ By default, GitHub backups add a separate password-based encryption layer around
305
+ the full JSON export. In encrypted mode, `td` sends only the encrypted envelope
306
+ to GitHub. The `gh` CLI must be
307
+ installed and authenticated. `td backup github` creates the target repository as
308
+ private when it does not exist, and refuses to write to a public repository.
309
+
310
+ Auto-sync uses `~/.config/td/config.toml`:
311
+
312
+ ```toml
313
+ [github_backup]
314
+ auto_sync = true
315
+ repo = "owner/td-backup"
316
+ encrypt = true
317
+ ```
318
+
319
+ Auto-sync runs after TUI sessions and CLI writes. Encrypted auto-sync needs a
320
+ password saved in macOS Keychain with `td backup github --save-password`.
321
+ Set `encrypt = false` to
322
+ store plain JSON. The repository remains private, but GitHub can then read the
323
+ backup. Encrypted mode remains the default. `td` stores a digest of the last
324
+ successful export and skips the GitHub update when the data has not changed.
325
+ Manual backups update the same digest.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "td-task"
3
- version = "0.2.5"
3
+ version = "0.2.6"
4
4
  description = "Fast TUI and CLI manager for tasks and Markdown notes"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.6] - 2026-11-04
4
+ - Back up and restore data through private GitHub repositories using the gh CLI.
5
+ - Encrypt backups by default, with optional plain JSON storage.
6
+ - Add configurable auto-sync that skips unchanged data and supports macOS Keychain passwords.
7
+ - Add Sync settings, backup progress screens, and a link to open the GitHub repository.
8
+ - Support backups to new and existing empty repositories.
9
+
3
10
  ## [0.2.5] - 2026-11-04
4
11
  - Redraw automatically when terminal dimensions change, without waiting for a keypress.
5
12
  - Preserve selection, unsaved edits, note cursor, and confirmations while resizing.
@@ -72,6 +72,10 @@ def main() -> None:
72
72
  _run_export(args)
73
73
  elif len(args) > 1 and args[1] in ("import", "-import", "--import"):
74
74
  _run_import(args)
75
+ elif len(args) > 1 and args[1] in ("backup", "-backup", "--backup"):
76
+ _run_backup(args)
77
+ elif len(args) > 1 and args[1] in ("restore", "-restore", "--restore"):
78
+ _run_restore(args)
75
79
  else:
76
80
  from . import db
77
81
  lists = db.get_all_lists()
@@ -123,6 +127,8 @@ def _run_help() -> None:
123
127
  commands_table.add_row(" archive", "Open TUI directly in the completed archive screen")
124
128
  commands_table.add_row(" export [file]", "Export database to JSON file or print to stdout")
125
129
  commands_table.add_row(" import <file>", "Import and merge database records from a JSON file")
130
+ commands_table.add_row(" backup github [repo]", "Encrypt and upload a backup to a private GitHub repo")
131
+ commands_table.add_row(" restore github [repo]", "Download and restore an encrypted GitHub backup")
126
132
  commands_table.add_row(" update", "Upgrade the `td` package to the latest version")
127
133
 
128
134
  console.print(commands_table)
@@ -136,6 +142,8 @@ def _run_help() -> None:
136
142
 
137
143
  options_table.add_row(" -l, --list <name>", "Specify/create list context (TUI list-lock, CLI scope)")
138
144
  options_table.add_row(" --dev", "Watch source code directory and restart interactive TUI on changes")
145
+ options_table.add_row(" --no-encrypt", "Store a GitHub backup as plain JSON (private repo required)")
146
+ options_table.add_row(" --save-password", "Save encrypted backup password in macOS Keychain for auto-sync")
139
147
  options_table.add_row(" -h, --help", "Print this detailed, LLM-friendly help menu and exit")
140
148
 
141
149
  console.print(options_table)
@@ -256,6 +264,7 @@ def _run_notes(list_name: str, has_list: bool, args: list[str]) -> None:
256
264
  print(json.dumps(note, indent=2))
257
265
  else:
258
266
  print(f"✓ Note added to '{list_name}' (note ID: {note['id']}, task ID: {note['task_id']})")
267
+ _auto_sync()
259
268
  return
260
269
 
261
270
  if action == "list":
@@ -315,6 +324,7 @@ def _run_notes(list_name: str, has_list: bool, args: list[str]) -> None:
315
324
  print(json.dumps(updated, indent=2))
316
325
  else:
317
326
  print(f"✓ Note {note_id} updated.")
327
+ _auto_sync()
318
328
  return
319
329
 
320
330
  deleted = db.delete_note(note_id)
@@ -322,6 +332,7 @@ def _run_notes(list_name: str, has_list: bool, args: list[str]) -> None:
322
332
  print(json.dumps({"id": note_id, "deleted": deleted}))
323
333
  else:
324
334
  print(f"✓ Note {note_id} deleted.")
335
+ _auto_sync()
325
336
 
326
337
 
327
338
  def _run_add(list_name: str, args: list[str]) -> None:
@@ -338,6 +349,7 @@ def _run_add(list_name: str, args: list[str]) -> None:
338
349
  print("✗ Failed to add task (maximum active tasks reached).")
339
350
  sys.exit(1)
340
351
  print(f"✓ Task added successfully to list '{list_name}' (ID: {result['id']})")
352
+ _auto_sync()
341
353
 
342
354
 
343
355
  def _run_list(list_name: str) -> None:
@@ -571,10 +583,98 @@ def _run_import(args: list[str]) -> None:
571
583
  json_str = f.read()
572
584
  db.import_from_json(json_str)
573
585
  print(f"✓ Database successfully imported and merged from {filepath}")
586
+ _auto_sync()
574
587
  except Exception as e:
575
588
  print(f"✗ Import failed: {e}")
576
589
  sys.exit(1)
577
590
 
578
591
 
592
+ def _github_backup_repo(args: list[str]) -> str:
593
+ repo = _option_value(args, "--repo")
594
+ if repo is None and len(args) > 3 and not args[3].startswith("-"):
595
+ repo = args[3]
596
+ return (repo or "td-backup").strip()
597
+
598
+
599
+ def _backup_password(*, confirm: bool) -> str:
600
+ import getpass
601
+
602
+ try:
603
+ password = getpass.getpass("Backup password: ")
604
+ if confirm:
605
+ repeated = getpass.getpass("Confirm backup password: ")
606
+ if password != repeated:
607
+ raise ValueError("Backup passwords do not match")
608
+ if not password:
609
+ raise ValueError("Backup password cannot be empty")
610
+ return password
611
+ except (KeyboardInterrupt, EOFError):
612
+ print("\nCancelled.")
613
+ sys.exit(0)
614
+
615
+
616
+ def _auto_sync() -> None:
617
+ from . import backup, config, db
618
+
619
+ try:
620
+ repo = backup.sync_if_configured(db.export_to_json())
621
+ if repo:
622
+ print(
623
+ f"✓ auto-synced backup to {repo}/{backup.BACKUP_FILENAME}",
624
+ file=sys.stderr,
625
+ )
626
+ except Exception as error:
627
+ print(f"✗ auto-sync failed: {error}", file=sys.stderr)
628
+
629
+
630
+ def _run_backup(args: list[str]) -> None:
631
+ if len(args) < 3 or args[2] != "github":
632
+ print("Usage: td backup github [repo|--repo <owner/name>] [--no-encrypt] [--save-password]")
633
+ sys.exit(1)
634
+
635
+ from . import backup, config, db
636
+
637
+ _cli_ensure_unlocked()
638
+ try:
639
+ repo_name = _github_backup_repo(args)
640
+ should_encrypt = "--no-encrypt" not in args
641
+ if not should_encrypt and "--save-password" in args:
642
+ raise ValueError("--save-password requires encrypted backup mode")
643
+ password = _backup_password(confirm=True) if should_encrypt else None
644
+ data = db.export_to_json()
645
+ payload = backup.encrypt_backup(data, password) if password else data
646
+ repo = backup.upload_backup(repo_name, payload)
647
+ config.set_github_backup(
648
+ repo=repo,
649
+ last_sync_digest=backup.backup_digest(data, should_encrypt),
650
+ )
651
+ if password and "--save-password" in args:
652
+ backup.store_password(repo, password)
653
+ mode = "encrypted" if password else "unencrypted"
654
+ print(f"✓ {mode} backup uploaded to {repo}/{backup.BACKUP_FILENAME}")
655
+ except Exception as error:
656
+ print(f"✗ GitHub backup failed: {error}")
657
+ sys.exit(1)
658
+
659
+
660
+ def _run_restore(args: list[str]) -> None:
661
+ if len(args) < 3 or args[2] != "github":
662
+ print("Usage: td restore github [repo|--repo <owner/name>]")
663
+ sys.exit(1)
664
+
665
+ from . import backup, db
666
+
667
+ _cli_ensure_unlocked()
668
+ try:
669
+ payload = backup.download_backup(_github_backup_repo(args))
670
+ password = _backup_password(confirm=False) if backup.is_encrypted_backup(payload) else None
671
+ db.import_from_json(backup.decode_backup(payload, password))
672
+ mode = "encrypted" if password else "unencrypted"
673
+ print(f"✓ {mode} GitHub backup restored and merged")
674
+ except Exception as error:
675
+ print(f"✗ GitHub restore failed: {error}")
676
+ sys.exit(1)
677
+
678
+
579
679
  if __name__ == "__main__":
580
680
  main()
@@ -0,0 +1,304 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ from typing import Any
12
+ from pathlib import Path
13
+
14
+ from cryptography.fernet import Fernet, InvalidToken
15
+ from cryptography.hazmat.primitives import hashes
16
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
17
+
18
+
19
+ BACKUP_FILENAME = "td-backup.tdenc"
20
+ BACKUP_FORMAT = "td-github-backup-v1"
21
+ KEYCHAIN_SERVICE = "td-github-backup"
22
+ PBKDF2_ITERATIONS = 600_000
23
+
24
+
25
+ def _derive_key(password: str, salt: bytes, iterations: int) -> bytes:
26
+ if not password:
27
+ raise ValueError("Backup password cannot be empty")
28
+ return base64.urlsafe_b64encode(
29
+ PBKDF2HMAC(
30
+ algorithm=hashes.SHA256(),
31
+ length=32,
32
+ salt=salt,
33
+ iterations=iterations,
34
+ ).derive(password.encode("utf-8"))
35
+ )
36
+
37
+
38
+ def encrypt_backup(json_data: str, password: str) -> str:
39
+ """Encrypt a JSON export into a portable backup envelope."""
40
+ salt = os.urandom(16)
41
+ key = _derive_key(password, salt, PBKDF2_ITERATIONS)
42
+ ciphertext = Fernet(key).encrypt(json_data.encode("utf-8"))
43
+ envelope = {
44
+ "format": BACKUP_FORMAT,
45
+ "kdf": "pbkdf2-sha256",
46
+ "iterations": PBKDF2_ITERATIONS,
47
+ "salt": base64.b64encode(salt).decode("ascii"),
48
+ "ciphertext": ciphertext.decode("ascii"),
49
+ }
50
+ return json.dumps(envelope, indent=2) + "\n"
51
+
52
+
53
+ def backup_digest(json_data: str, encrypted: bool) -> str:
54
+ """Return a stable digest for the exported data and storage mode."""
55
+ marker = "encrypted" if encrypted else "plain"
56
+ return hashlib.sha256(f"{marker}\0{json_data}".encode("utf-8")).hexdigest()
57
+
58
+
59
+ def is_encrypted_backup(data: str) -> bool:
60
+ try:
61
+ return json.loads(data).get("format") == BACKUP_FORMAT
62
+ except (AttributeError, json.JSONDecodeError):
63
+ return False
64
+
65
+
66
+ def decrypt_backup(encrypted_data: str, password: str) -> str:
67
+ """Decrypt and validate a portable backup envelope."""
68
+ try:
69
+ envelope = json.loads(encrypted_data)
70
+ if envelope.get("format") != BACKUP_FORMAT:
71
+ raise ValueError("Unsupported backup format")
72
+ if envelope.get("kdf") != "pbkdf2-sha256":
73
+ raise ValueError("Unsupported backup KDF")
74
+ iterations = int(envelope["iterations"])
75
+ if iterations < 100_000 or iterations > 10_000_000:
76
+ raise ValueError("Unsupported backup KDF cost")
77
+ salt = base64.b64decode(envelope["salt"])
78
+ ciphertext = envelope["ciphertext"].encode("ascii")
79
+ key = _derive_key(password, salt, iterations)
80
+ return Fernet(key).decrypt(ciphertext).decode("utf-8")
81
+ except ValueError as error:
82
+ if str(error) in ("Unsupported backup format", "Unsupported backup KDF"):
83
+ raise
84
+ raise ValueError("Invalid backup password or corrupted backup") from error
85
+ except (KeyError, TypeError, InvalidToken, UnicodeDecodeError, binascii.Error) as error:
86
+ raise ValueError("Invalid backup password or corrupted backup") from error
87
+
88
+
89
+ def decode_backup(data: str, password: str | None = None) -> str:
90
+ """Return plaintext for either encrypted or intentionally plain backups."""
91
+ if not is_encrypted_backup(data):
92
+ return data
93
+ if password is None:
94
+ raise ValueError("Backup password required")
95
+ return decrypt_backup(data, password)
96
+
97
+
98
+ def _run_gh(
99
+ args: list[str], *, input_text: str | None = None
100
+ ) -> subprocess.CompletedProcess[str]:
101
+ try:
102
+ return subprocess.run(
103
+ ["gh", *args],
104
+ input=input_text,
105
+ capture_output=True,
106
+ text=True,
107
+ check=False,
108
+ )
109
+ except FileNotFoundError as error:
110
+ raise RuntimeError(
111
+ "gh CLI not found. Install it from https://cli.github.com/"
112
+ ) from error
113
+
114
+
115
+ def _require_gh(result: subprocess.CompletedProcess[str]) -> str:
116
+ if result.returncode != 0:
117
+ detail = result.stderr.strip() or result.stdout.strip() or "unknown gh error"
118
+ raise RuntimeError(detail)
119
+ return result.stdout
120
+
121
+
122
+ def _private_repo(repo: str, *, create: bool) -> tuple[str, bool]:
123
+ _require_gh(_run_gh(["auth", "status"]))
124
+ viewed = _run_gh(["repo", "view", repo, "--json", "nameWithOwner,isPrivate,isEmpty"])
125
+ if viewed.returncode != 0:
126
+ if not create:
127
+ raise RuntimeError(f'GitHub repository "{repo}" not found or inaccessible')
128
+ created = _run_gh([
129
+ "repo",
130
+ "create",
131
+ repo,
132
+ "--private",
133
+ "--add-readme",
134
+ "--description",
135
+ "Encrypted td backup",
136
+ ])
137
+ _require_gh(created)
138
+ viewed = _run_gh(["repo", "view", repo, "--json", "nameWithOwner,isPrivate,isEmpty"])
139
+
140
+ details = json.loads(_require_gh(viewed))
141
+ if not details.get("isPrivate"):
142
+ raise RuntimeError("Backup repository must be private")
143
+ return details["nameWithOwner"], details.get("isEmpty") is True
144
+
145
+
146
+ def _contents_endpoint(repo: str) -> str:
147
+ return f"repos/{repo}/contents/{BACKUP_FILENAME}"
148
+
149
+
150
+ def repo_url(repo: str) -> str:
151
+ """Resolve a configured repository to its web URL through gh."""
152
+ result = _run_gh(["repo", "view", repo, "--json", "url", "--jq", ".url"])
153
+ return _require_gh(result).strip()
154
+
155
+
156
+ def _run_git(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
157
+ try:
158
+ return subprocess.run(
159
+ ["git", *args],
160
+ cwd=cwd,
161
+ capture_output=True,
162
+ text=True,
163
+ check=False,
164
+ )
165
+ except FileNotFoundError as error:
166
+ raise RuntimeError("git CLI not found") from error
167
+
168
+
169
+ def _require_git(result: subprocess.CompletedProcess[str]) -> str:
170
+ if result.returncode != 0:
171
+ detail = result.stderr.strip() or result.stdout.strip() or "unknown git error"
172
+ raise RuntimeError(detail)
173
+ return result.stdout
174
+
175
+
176
+ def _initialize_empty_repo(repo: str, data: str) -> None:
177
+ """Create the first commit in an existing empty GitHub repository."""
178
+ with tempfile.TemporaryDirectory(prefix="td-github-backup-") as temp_dir:
179
+ clone_path = Path(temp_dir) / "repo"
180
+ _require_gh(_run_gh(["repo", "clone", repo, str(clone_path)]))
181
+ backup_path = clone_path / BACKUP_FILENAME
182
+ backup_path.write_text(data, encoding="utf-8")
183
+ _require_git(_run_git(["add", BACKUP_FILENAME], clone_path))
184
+ _require_git(_run_git([
185
+ "-c", "user.name=td-backup",
186
+ "-c", "user.email=td-backup@users.noreply.github.com",
187
+ "commit", "-m", "Create td backup",
188
+ ], clone_path))
189
+ _require_git(_run_git(["push", "-u", "origin", "HEAD:main"], clone_path))
190
+
191
+
192
+ def store_password(repo: str, password: str) -> None:
193
+ """Store a backup password in the macOS Keychain for auto-sync."""
194
+ if sys.platform != "darwin":
195
+ raise RuntimeError("automatic backup passwords require macOS Keychain")
196
+ result = subprocess.run(
197
+ [
198
+ "security",
199
+ "add-generic-password",
200
+ "-U",
201
+ "-a",
202
+ repo,
203
+ "-s",
204
+ KEYCHAIN_SERVICE,
205
+ "-w",
206
+ password,
207
+ ],
208
+ capture_output=True,
209
+ text=True,
210
+ check=False,
211
+ )
212
+ if result.returncode != 0:
213
+ detail = result.stderr.strip() or "Keychain update failed"
214
+ raise RuntimeError(detail)
215
+
216
+
217
+ def load_password(repo: str) -> str | None:
218
+ """Load a stored backup password, if one exists."""
219
+ if sys.platform != "darwin":
220
+ return None
221
+ result = subprocess.run(
222
+ [
223
+ "security",
224
+ "find-generic-password",
225
+ "-w",
226
+ "-a",
227
+ repo,
228
+ "-s",
229
+ KEYCHAIN_SERVICE,
230
+ ],
231
+ capture_output=True,
232
+ text=True,
233
+ check=False,
234
+ )
235
+ if result.returncode != 0:
236
+ return None
237
+ password = result.stdout.strip()
238
+ return password or None
239
+
240
+
241
+ def sync_if_configured(json_data: str) -> str | None:
242
+ """Upload a configured auto-sync backup. Return repo, or None if disabled."""
243
+ from . import config
244
+
245
+ settings = config.github_backup()
246
+ if settings.get("auto_sync") is not True:
247
+ return None
248
+ repo = settings.get("repo")
249
+ if not isinstance(repo, str) or not repo.strip():
250
+ return None
251
+ repo = repo.strip()
252
+ should_encrypt = settings.get("encrypt", True) is not False
253
+ digest = backup_digest(json_data, should_encrypt)
254
+ if settings.get("last_sync_digest") == digest:
255
+ return None
256
+ if should_encrypt:
257
+ password = load_password(repo)
258
+ if password is None:
259
+ return None
260
+ payload = encrypt_backup(json_data, password)
261
+ else:
262
+ payload = json_data
263
+ resolved_repo = upload_backup(repo, payload)
264
+ config.set_github_backup(
265
+ repo=resolved_repo,
266
+ last_sync_digest=digest,
267
+ )
268
+ return resolved_repo
269
+
270
+
271
+ def upload_backup(repo: str, encrypted_data: str) -> str:
272
+ """Create or update the encrypted backup file in a private GitHub repo."""
273
+ name_with_owner, is_empty = _private_repo(repo, create=True)
274
+ if is_empty:
275
+ _initialize_empty_repo(name_with_owner, encrypted_data)
276
+ return name_with_owner
277
+ endpoint = _contents_endpoint(name_with_owner)
278
+ existing = _run_gh(["api", endpoint, "--jq", ".sha"])
279
+ body: dict[str, Any] = {
280
+ "message": "Update encrypted td backup",
281
+ "content": base64.b64encode(encrypted_data.encode("utf-8")).decode("ascii"),
282
+ }
283
+ if existing.returncode == 0 and existing.stdout.strip():
284
+ body["sha"] = existing.stdout.strip()
285
+ elif existing.returncode != 0 and "not found" not in existing.stderr.lower():
286
+ _require_gh(existing)
287
+
288
+ uploaded = _run_gh(
289
+ ["api", endpoint, "--method", "PUT", "--input", "-"],
290
+ input_text=json.dumps(body),
291
+ )
292
+ _require_gh(uploaded)
293
+ return name_with_owner
294
+
295
+
296
+ def download_backup(repo: str) -> str:
297
+ """Download the encrypted backup file from a private GitHub repo."""
298
+ name_with_owner, _ = _private_repo(repo, create=False)
299
+ result = _run_gh(["api", _contents_endpoint(name_with_owner), "--jq", ".content"])
300
+ encoded = _require_gh(result)
301
+ try:
302
+ return base64.b64decode("".join(encoded.split())).decode("utf-8")
303
+ except (ValueError, UnicodeDecodeError) as error:
304
+ raise RuntimeError("GitHub backup file is not valid UTF-8") from error
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import json
5
+ import tomllib
6
+ from pathlib import Path
7
+
8
+
9
+ def path() -> Path:
10
+ return Path(
11
+ os.environ.get(
12
+ "TD_CONFIG_PATH",
13
+ Path.home() / ".config" / "td" / "config.toml",
14
+ )
15
+ )
16
+
17
+
18
+ def github_backup() -> dict[str, object]:
19
+ """Read GitHub backup settings without making config errors fatal."""
20
+ try:
21
+ with path().open("rb") as config_file:
22
+ section = tomllib.load(config_file).get("github_backup", {})
23
+ except (OSError, tomllib.TOMLDecodeError):
24
+ return {}
25
+ return section if isinstance(section, dict) else {}
26
+
27
+
28
+ def set_github_backup(
29
+ *,
30
+ repo: str | None = None,
31
+ auto_sync: bool | None = None,
32
+ encrypt: bool | None = None,
33
+ last_sync_digest: str | None = None,
34
+ ) -> None:
35
+ """Update GitHub backup settings while preserving the other values."""
36
+ current = github_backup()
37
+ if repo is not None:
38
+ current["repo"] = repo
39
+ if auto_sync is not None:
40
+ current["auto_sync"] = auto_sync
41
+ if encrypt is not None:
42
+ current["encrypt"] = encrypt
43
+ if last_sync_digest is not None:
44
+ current["last_sync_digest"] = last_sync_digest
45
+
46
+ config_path = path()
47
+ config_path.parent.mkdir(parents=True, exist_ok=True)
48
+ lines = ["[github_backup]"]
49
+ if isinstance(current.get("repo"), str) and current["repo"].strip():
50
+ lines.append(f"repo = {json.dumps(current['repo'].strip())}")
51
+ lines.append(f"auto_sync = {str(current.get('auto_sync', False)).lower()}")
52
+ lines.append(f"encrypt = {str(current.get('encrypt', True)).lower()}")
53
+ if isinstance(current.get("last_sync_digest"), str):
54
+ lines.append(f"last_sync_digest = {json.dumps(current['last_sync_digest'])}")
55
+ config_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
@@ -9,6 +9,7 @@ from rich.console import Console
9
9
  from rich.text import Text
10
10
 
11
11
  from . import db
12
+ from . import config
12
13
  from . import terminal as term
13
14
 
14
15
  console = Console()
@@ -136,6 +137,100 @@ def _copy_to_clipboard(text: str) -> bool:
136
137
  return False
137
138
 
138
139
 
140
+ def _auto_sync() -> None:
141
+ from . import backup
142
+
143
+ settings = config.github_backup()
144
+ if settings.get("auto_sync") is not True:
145
+ return
146
+ repo = settings.get("repo", "td-backup")
147
+ if not isinstance(repo, str) or not repo.strip():
148
+ return
149
+ should_encrypt = settings.get("encrypt", True) is not False
150
+ if should_encrypt and backup.load_password(repo.strip()) is None:
151
+ return
152
+
153
+ try:
154
+ _render_github_backup_loading(repo.strip(), should_encrypt)
155
+ repo = backup.sync_if_configured(db.export_to_json())
156
+ if repo:
157
+ print(
158
+ f"✓ auto-synced backup to {repo}/{backup.BACKUP_FILENAME}",
159
+ file=sys.stderr,
160
+ )
161
+ except Exception as error:
162
+ print(f"✗ auto-sync failed: {error}", file=sys.stderr)
163
+
164
+
165
+ @_buffered_frame
166
+ def _render_github_backup_loading(repo: str, encrypted: bool) -> None:
167
+ header = Text("GitHub backup • ", style="bold")
168
+ header.append(Text("working", style="bold yellow"))
169
+ console.print(header, end="\033[K\n")
170
+ console.print(Text("─" * min(len(_normal_hint_text(False)), console.width or 80), style="dim"), end="\033[K\n")
171
+ console.print(end="\033[K\n")
172
+ console.print(Text(f" Uploading {'encrypted ' if encrypted else ''}backup to {repo}...", style="yellow bold"), end="\033[K\n")
173
+ console.print(Text(" Please wait.", style="dim"), end="\033[K\n")
174
+
175
+
176
+ def _github_backup_now() -> str:
177
+ from . import backup
178
+
179
+ settings = config.github_backup()
180
+ repo = settings.get("repo", "td-backup")
181
+ if not isinstance(repo, str) or not repo.strip():
182
+ repo = "td-backup"
183
+ should_encrypt = settings.get("encrypt", True) is not False
184
+
185
+ try:
186
+ password: str | None = None
187
+ if should_encrypt:
188
+ password = prompt_password("Backup password: ")
189
+ if not password:
190
+ return "✗ backup password cannot be empty"
191
+ confirm = prompt_password("Confirm backup password: ")
192
+ if password != confirm:
193
+ return "✗ backup passwords do not match"
194
+
195
+ _render_github_backup_loading(repo.strip(), should_encrypt)
196
+ data = db.export_to_json()
197
+ payload = backup.encrypt_backup(data, password) if password else data
198
+ resolved_repo = backup.upload_backup(repo.strip(), payload)
199
+ config.set_github_backup(
200
+ repo=resolved_repo,
201
+ last_sync_digest=backup.backup_digest(data, should_encrypt),
202
+ )
203
+
204
+ if password:
205
+ key = _read_message([
206
+ Text(" Backup uploaded. Save password for auto-sync? [y/N]", style="yellow bold")
207
+ ])
208
+ if key.lower() == "y":
209
+ backup.store_password(resolved_repo, password)
210
+ return f"✓ encrypted backup uploaded to {resolved_repo}"
211
+ mode = "encrypted" if password else "unencrypted"
212
+ return f"✓ {mode} backup uploaded to {resolved_repo}"
213
+ except Exception as error:
214
+ return f"✗ GitHub backup failed: {error}"
215
+
216
+
217
+ def _open_github_repo() -> str:
218
+ import webbrowser
219
+ from . import backup
220
+
221
+ settings = config.github_backup()
222
+ repo = settings.get("repo", "td-backup")
223
+ if not isinstance(repo, str) or not repo.strip():
224
+ repo = "td-backup"
225
+ try:
226
+ url = backup.repo_url(repo.strip())
227
+ if not webbrowser.open_new_tab(url):
228
+ return "✗ could not open browser"
229
+ return f"✓ opened {url}"
230
+ except Exception as error:
231
+ return f"✗ could not open GitHub repo: {error}"
232
+
233
+
139
234
  def prompt_password(
140
235
  prompt_text: str = "Enter password: ", allow_back: bool = False
141
236
  ) -> str | None:
@@ -2161,6 +2256,13 @@ def _render_settings(
2161
2256
  ) -> None:
2162
2257
  max_tasks = db.get_max_tasks(list_name)
2163
2258
  max_starred = db.get_max_starred_tasks()
2259
+ github_settings = config.github_backup()
2260
+ github_repo = github_settings.get("repo", "td-backup")
2261
+ if not isinstance(github_repo, str) or not github_repo.strip():
2262
+ github_repo = "td-backup"
2263
+ github_auto_sync = github_settings.get("auto_sync") is True
2264
+ github_encrypt = github_settings.get("encrypt", True) is not False
2265
+
2164
2266
  header = Text("settings • ", style="bold")
2165
2267
  header.append(Text(f"preferences ({list_name})", style="dim"))
2166
2268
  console.print(header, end="\033[K\n")
@@ -2176,6 +2278,7 @@ def _render_settings(
2176
2278
  divider_width = min(len(_normal_hint_text(False)), console.width or 80)
2177
2279
  console.print(Text("─" * divider_width, style="dim"), end="\033[K\n")
2178
2280
  console.print(end="\033[K\n")
2281
+ console.print(Text("Limits", style="bold yellow"), end="\033[K\n")
2179
2282
 
2180
2283
  # Max tasks row
2181
2284
  is_hovered_max = hover == 0
@@ -2217,6 +2320,9 @@ def _render_settings(
2217
2320
  starred_line.append(Text(str(max_starred), style="dim"))
2218
2321
  console.print(starred_line, end="\033[K\n")
2219
2322
 
2323
+ console.print(end="\033[K\n")
2324
+ console.print(Text("Security", style="bold yellow"), end="\033[K\n")
2325
+
2220
2326
  # Database encryption row
2221
2327
  is_hovered_enc = hover == 2
2222
2328
  prefix_enc = "▸ " if is_hovered_enc else " "
@@ -2246,6 +2352,9 @@ def _render_settings(
2246
2352
  list_enc_line.append(Text(list_enc_status, style="dim"))
2247
2353
  console.print(list_enc_line, end="\033[K\n")
2248
2354
 
2355
+ console.print(end="\033[K\n")
2356
+ console.print(Text("Application", style="bold yellow"), end="\033[K\n")
2357
+
2249
2358
  # Update row
2250
2359
  is_hovered_update = hover == 4
2251
2360
  prefix2 = "▸ " if is_hovered_update else " "
@@ -2256,6 +2365,9 @@ def _render_settings(
2256
2365
  update_line.append(Text("update td", style="dim"))
2257
2366
  console.print(update_line, end="\033[K\n")
2258
2367
 
2368
+ console.print(end="\033[K\n")
2369
+ console.print(Text("Data", style="bold yellow"), end="\033[K\n")
2370
+
2259
2371
  # Export row
2260
2372
  is_hovered_export = hover == 5
2261
2373
  prefix_exp = "▸ " if is_hovered_export else " "
@@ -2290,6 +2402,63 @@ def _render_settings(
2290
2402
  import_line.append(Text("import database", style="dim"))
2291
2403
  console.print(import_line, end="\033[K\n")
2292
2404
 
2405
+ console.print(end="\033[K\n")
2406
+ console.print(Text("Sync", style="bold yellow"), end="\033[K\n")
2407
+
2408
+ # GitHub backup repo row
2409
+ is_hovered_github_repo = hover == 7
2410
+ prefix_github_repo = "▸ " if is_hovered_github_repo else " "
2411
+ github_repo_line = Text(prefix_github_repo)
2412
+ if mode == "edit" and hover == 7:
2413
+ github_repo_line.append(Text("GitHub backup repo: ", style="cyan bold"))
2414
+ github_repo_line.append(Text(edit_text[:edit_cursor], style="yellow bold"))
2415
+ char_under = edit_text[edit_cursor] if edit_cursor < len(edit_text) else " "
2416
+ github_repo_line.append(Text(char_under, style="reverse yellow bold"))
2417
+ if edit_cursor < len(edit_text):
2418
+ github_repo_line.append(Text(edit_text[edit_cursor + 1:], style="yellow bold"))
2419
+ elif is_hovered_github_repo:
2420
+ github_repo_line.append(Text("GitHub backup repo: ", style="cyan bold"))
2421
+ github_repo_line.append(Text(github_repo, style="bold"))
2422
+ else:
2423
+ github_repo_line.append(Text("GitHub backup repo: ", style="dim"))
2424
+ github_repo_line.append(Text(github_repo, style="dim"))
2425
+ console.print(github_repo_line, end="\033[K\n")
2426
+
2427
+ # GitHub backup action row
2428
+ is_hovered_github_backup = hover == 8
2429
+ prefix_github_backup = "▸ " if is_hovered_github_backup else " "
2430
+ github_backup_line = Text(prefix_github_backup)
2431
+ github_backup_line.append(Text(
2432
+ "backup to GitHub now",
2433
+ style="cyan bold" if is_hovered_github_backup else "dim",
2434
+ ))
2435
+ console.print(github_backup_line, end="\033[K\n")
2436
+
2437
+ # GitHub auto-sync row
2438
+ is_hovered_auto_sync = hover == 9
2439
+ prefix_auto_sync = "▸ " if is_hovered_auto_sync else " "
2440
+ auto_sync_line = Text(prefix_auto_sync)
2441
+ auto_sync_line.append(Text("GitHub auto-sync: ", style="cyan bold" if is_hovered_auto_sync else "dim"))
2442
+ auto_sync_line.append(Text("on" if github_auto_sync else "off", style="bold" if is_hovered_auto_sync else "dim"))
2443
+ console.print(auto_sync_line, end="\033[K\n")
2444
+
2445
+ # GitHub backup encryption row
2446
+ is_hovered_github_encrypt = hover == 10
2447
+ prefix_github_encrypt = "▸ " if is_hovered_github_encrypt else " "
2448
+ github_encrypt_line = Text(prefix_github_encrypt)
2449
+ github_encrypt_line.append(Text("GitHub backup encryption: ", style="cyan bold" if is_hovered_github_encrypt else "dim"))
2450
+ github_encrypt_line.append(Text("on" if github_encrypt else "off", style="bold" if is_hovered_github_encrypt else "dim"))
2451
+ console.print(github_encrypt_line, end="\033[K\n")
2452
+
2453
+ is_hovered_open_repo = hover == 11
2454
+ prefix_open_repo = "▸ " if is_hovered_open_repo else " "
2455
+ open_repo_line = Text(prefix_open_repo)
2456
+ open_repo_line.append(Text(
2457
+ "open GitHub repo on web",
2458
+ style="cyan bold" if is_hovered_open_repo else "dim",
2459
+ ))
2460
+ console.print(open_repo_line, end="\033[K\n")
2461
+
2293
2462
  if status_msg:
2294
2463
  console.print(end="\033[K\n")
2295
2464
  console.print(Text(f" {status_msg}", style="yellow bold"), end="\033[K\n")
@@ -2310,7 +2479,7 @@ def _run_settings_loop(list_name: str) -> None:
2310
2479
  edit_text = ""
2311
2480
  edit_cursor = 0
2312
2481
  status_msg = ""
2313
- num_items = 7
2482
+ num_items = 12
2314
2483
 
2315
2484
  while True:
2316
2485
  _render_settings(
@@ -2356,6 +2525,23 @@ def _run_settings_loop(list_name: str) -> None:
2356
2525
  mode = "edit"
2357
2526
  edit_text = "backup.json"
2358
2527
  edit_cursor = len(edit_text)
2528
+ elif hover == 7:
2529
+ mode = "edit"
2530
+ github_repo = config.github_backup().get("repo", "td-backup")
2531
+ edit_text = github_repo if isinstance(github_repo, str) else "td-backup"
2532
+ edit_cursor = len(edit_text)
2533
+ elif hover == 8:
2534
+ status_msg = _github_backup_now()
2535
+ elif hover == 9:
2536
+ enabled = config.github_backup().get("auto_sync") is True
2537
+ config.set_github_backup(auto_sync=not enabled)
2538
+ status_msg = f"✓ GitHub auto-sync {'enabled' if not enabled else 'disabled'}"
2539
+ elif hover == 10:
2540
+ encrypted = config.github_backup().get("encrypt", True) is not False
2541
+ config.set_github_backup(encrypt=not encrypted)
2542
+ status_msg = f"✓ GitHub backup encryption {'enabled' if not encrypted else 'disabled'}"
2543
+ elif hover == 11:
2544
+ status_msg = _open_github_repo()
2359
2545
  elif hover == 2:
2360
2546
  # Toggle encryption
2361
2547
  if not db.is_encryption_enabled() and db.get_encrypted_lists():
@@ -2504,6 +2690,13 @@ def _run_settings_loop(list_name: str) -> None:
2504
2690
  status_msg = f"✓ database imported from {filename}"
2505
2691
  except Exception as e:
2506
2692
  status_msg = f"✗ import failed: {e}"
2693
+ elif hover == 7:
2694
+ repo = edit_text.strip()
2695
+ if not repo:
2696
+ status_msg = "✗ GitHub backup repo cannot be empty"
2697
+ else:
2698
+ config.set_github_backup(repo=repo)
2699
+ status_msg = f"✓ GitHub backup repo set to {repo}"
2507
2700
  mode = "normal"
2508
2701
  edit_text = ""
2509
2702
  edit_cursor = 0
@@ -2555,7 +2748,7 @@ def _run_settings_loop(list_name: str) -> None:
2555
2748
  edit_cursor = 0
2556
2749
  elif key == term.KEY_END:
2557
2750
  edit_cursor = len(edit_text)
2558
- elif len(key) == 1 and (key.isdigit() or (hover in (5, 6) and ord(key) >= 32)):
2751
+ elif len(key) == 1 and (key.isdigit() or (hover in (5, 6, 7) and ord(key) >= 32)):
2559
2752
  edit_text = edit_text[:edit_cursor] + key + edit_text[edit_cursor:]
2560
2753
  edit_cursor += 1
2561
2754
 
@@ -2582,6 +2775,7 @@ def run_main(list_name: str = "main", lock_list: bool = False) -> None:
2582
2775
  if not _ensure_unlocked():
2583
2776
  return
2584
2777
  _run_main_loop(list_name, lock_list)
2778
+ _auto_sync()
2585
2779
  except KeyboardInterrupt:
2586
2780
  pass
2587
2781
  finally:
@@ -2598,6 +2792,7 @@ def run_archive(list_name: str = "main", lock_list: bool = False) -> None:
2598
2792
  if not _ensure_list_unlocked(list_name):
2599
2793
  return
2600
2794
  _run_archive_loop(list_name, lock_list)
2795
+ _auto_sync()
2601
2796
  except KeyboardInterrupt:
2602
2797
  pass
2603
2798
  finally:
@@ -0,0 +1,129 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import unittest
7
+ from pathlib import Path
8
+ from unittest.mock import patch
9
+
10
+ from td import backup, config
11
+
12
+
13
+ class BackupTests(unittest.TestCase):
14
+ def test_encrypt_round_trip_hides_plaintext(self) -> None:
15
+ source = json.dumps({"tasks": [{"text": "secret task"}]})
16
+
17
+ encrypted = backup.encrypt_backup(source, "backup-password")
18
+
19
+ self.assertNotIn("secret task", encrypted)
20
+ self.assertEqual(backup.decrypt_backup(encrypted, "backup-password"), source)
21
+
22
+ def test_wrong_password_fails(self) -> None:
23
+ encrypted = backup.encrypt_backup('{"secret": true}', "backup-password")
24
+
25
+ with self.assertRaisesRegex(ValueError, "Invalid backup password"):
26
+ backup.decrypt_backup(encrypted, "wrong-password")
27
+
28
+ def test_plain_backup_decodes_without_password(self) -> None:
29
+ source = '{"tasks": [{"text": "visible task"}]}'
30
+
31
+ self.assertFalse(backup.is_encrypted_backup(source))
32
+ self.assertEqual(backup.decode_backup(source), source)
33
+
34
+ def test_auto_sync_can_use_plaintext_config_mode(self) -> None:
35
+ config_file = self._write_config(
36
+ '[github_backup]\nauto_sync = true\nrepo = "alice/td-backup"\nencrypt = false\n'
37
+ )
38
+ source = '{"tasks": [{"text": "visible task"}]}'
39
+ with (
40
+ patch.dict(os.environ, {"TD_CONFIG_PATH": str(config_file)}),
41
+ patch.object(backup, "upload_backup", return_value="alice/td-backup") as upload,
42
+ ):
43
+ result = backup.sync_if_configured(source)
44
+
45
+ self.assertEqual(result, "alice/td-backup")
46
+ self.assertEqual(upload.call_args.args, ("alice/td-backup", source))
47
+
48
+ def test_auto_sync_skips_unchanged_export(self) -> None:
49
+ source = '{"tasks": [{"text": "unchanged"}]}'
50
+ digest = backup.backup_digest(source, encrypted=False)
51
+ config_file = self._write_config(
52
+ f'[github_backup]\nauto_sync = true\nrepo = "alice/td-backup"\nencrypt = false\nlast_sync_digest = "{digest}"\n'
53
+ )
54
+ with (
55
+ patch.dict(os.environ, {"TD_CONFIG_PATH": str(config_file)}),
56
+ patch.object(backup, "upload_backup") as upload,
57
+ ):
58
+ result = backup.sync_if_configured(source)
59
+
60
+ self.assertIsNone(result)
61
+ upload.assert_not_called()
62
+
63
+ def _write_config(self, contents: str):
64
+ import tempfile
65
+ from pathlib import Path
66
+
67
+ directory = tempfile.TemporaryDirectory()
68
+ self.addCleanup(directory.cleanup)
69
+ path = Path(directory.name) / "config.toml"
70
+ path.write_text(contents, encoding="utf-8")
71
+ return path
72
+
73
+ def test_upload_creates_private_repo_and_updates_file(self) -> None:
74
+ responses = [
75
+ subprocess.CompletedProcess([], 0, "", ""),
76
+ subprocess.CompletedProcess([], 1, "", "not found"),
77
+ subprocess.CompletedProcess([], 0, "https://github.com/alice/td-backup\n", ""),
78
+ subprocess.CompletedProcess([], 0, '{"nameWithOwner":"alice/td-backup","isPrivate":true}', ""),
79
+ subprocess.CompletedProcess([], 1, "", "HTTP 404: Not Found"),
80
+ subprocess.CompletedProcess([], 0, '{"content":{"name":"td-backup.tdenc"}}', ""),
81
+ ]
82
+ with patch.object(backup, "_run_gh", side_effect=responses) as run_gh:
83
+ repo = backup.upload_backup("alice/td-backup", '{"ciphertext":"x"}')
84
+
85
+ self.assertEqual(repo, "alice/td-backup")
86
+ upload_call = run_gh.call_args_list[-1]
87
+ self.assertEqual(upload_call.args[0][-4:], ["--method", "PUT", "--input", "-"])
88
+ body = json.loads(upload_call.kwargs["input_text"])
89
+ self.assertEqual(body["message"], "Update encrypted td backup")
90
+ self.assertNotIn("ciphertext", body["content"])
91
+
92
+ def test_public_repo_is_rejected(self) -> None:
93
+ responses = [
94
+ subprocess.CompletedProcess([], 0, "", ""),
95
+ subprocess.CompletedProcess([], 0, '{"nameWithOwner":"alice/public","isPrivate":false}', ""),
96
+ ]
97
+ with patch.object(backup, "_run_gh", side_effect=responses):
98
+ with self.assertRaisesRegex(RuntimeError, "must be private"):
99
+ backup.upload_backup("alice/public", "encrypted")
100
+
101
+ def test_upload_initializes_existing_empty_repo(self) -> None:
102
+ gh_responses = iter([
103
+ subprocess.CompletedProcess([], 0, "", ""),
104
+ subprocess.CompletedProcess([], 0, '{"nameWithOwner":"alice/empty","isPrivate":true,"isEmpty":true}', ""),
105
+ subprocess.CompletedProcess([], 0, "", ""),
106
+ ])
107
+ git_responses = iter([
108
+ subprocess.CompletedProcess([], 0, "", ""),
109
+ subprocess.CompletedProcess([], 0, "", ""),
110
+ subprocess.CompletedProcess([], 0, "", ""),
111
+ ])
112
+
113
+ def run_gh(args: list[str], **kwargs):
114
+ if args[:2] == ["repo", "clone"]:
115
+ Path(args[-1]).mkdir()
116
+ return next(gh_responses)
117
+
118
+ with (
119
+ patch.object(backup, "_run_gh", side_effect=run_gh),
120
+ patch.object(backup, "_run_git", side_effect=lambda *args, **kwargs: next(git_responses)) as run_git,
121
+ ):
122
+ repo = backup.upload_backup("alice/empty", "encrypted")
123
+
124
+ self.assertEqual(repo, "alice/empty")
125
+ self.assertEqual(run_git.call_args_list[-1].args[0], ["push", "-u", "origin", "HEAD:main"])
126
+
127
+
128
+ if __name__ == "__main__":
129
+ unittest.main()
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import io
4
4
  import json
5
+ import os
5
6
  import tempfile
6
7
  import unittest
7
8
  from contextlib import redirect_stdout
@@ -15,6 +16,8 @@ from td.__main__ import _run_notes
15
16
  class NoteCliTests(unittest.TestCase):
16
17
  def setUp(self) -> None:
17
18
  self.temp_dir = tempfile.TemporaryDirectory()
19
+ self.original_config_path = os.environ.get("TD_CONFIG_PATH")
20
+ os.environ["TD_CONFIG_PATH"] = str(Path(self.temp_dir.name) / "config.toml")
18
21
  self.original_path = db.DB_PATH
19
22
  self.original_key = db.ENCRYPTION_KEY
20
23
  self.original_list_keys = db.LIST_ENCRYPTION_KEYS.copy()
@@ -27,6 +30,10 @@ class NoteCliTests(unittest.TestCase):
27
30
  db.ENCRYPTION_KEY = self.original_key
28
31
  db.LIST_ENCRYPTION_KEYS.clear()
29
32
  db.LIST_ENCRYPTION_KEYS.update(self.original_list_keys)
33
+ if self.original_config_path is None:
34
+ os.environ.pop("TD_CONFIG_PATH", None)
35
+ else:
36
+ os.environ["TD_CONFIG_PATH"] = self.original_config_path
30
37
  self.temp_dir.cleanup()
31
38
 
32
39
  def _run(self, args: list[str], list_name: str = "work", has_list: bool = True) -> str:
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import io
4
+ import os
4
5
  import tempfile
5
6
  import unittest
6
7
  from pathlib import Path
@@ -16,6 +17,8 @@ from td import db, terminal, tui
16
17
  class NoteTuiTests(unittest.TestCase):
17
18
  def setUp(self) -> None:
18
19
  self.temp_dir = tempfile.TemporaryDirectory()
20
+ self.original_config_path = os.environ.get("TD_CONFIG_PATH")
21
+ os.environ["TD_CONFIG_PATH"] = str(Path(self.temp_dir.name) / "config.toml")
19
22
  self.original_path = db.DB_PATH
20
23
  self.original_key = db.ENCRYPTION_KEY
21
24
  self.original_list_keys = db.LIST_ENCRYPTION_KEYS.copy()
@@ -28,6 +31,10 @@ class NoteTuiTests(unittest.TestCase):
28
31
  db.ENCRYPTION_KEY = self.original_key
29
32
  db.LIST_ENCRYPTION_KEYS.clear()
30
33
  db.LIST_ENCRYPTION_KEYS.update(self.original_list_keys)
34
+ if self.original_config_path is None:
35
+ os.environ.pop("TD_CONFIG_PATH", None)
36
+ else:
37
+ os.environ["TD_CONFIG_PATH"] = self.original_config_path
31
38
  self.temp_dir.cleanup()
32
39
 
33
40
  def _edit_note(self, note_id: int, keys: list[str]) -> None:
@@ -606,6 +613,16 @@ class NoteTuiTests(unittest.TestCase):
606
613
  tui._run_settings_loop("main")
607
614
  self.assertFalse(db.is_list_encryption_enabled("main"))
608
615
 
616
+ def test_settings_can_run_github_backup(self) -> None:
617
+ keys = iter([terminal.KEY_ARROW_DOWN] * 8 + [terminal.KEY_ENTER, "q"])
618
+ with (
619
+ patch.object(terminal, "read_key", side_effect=lambda: next(keys)),
620
+ patch.object(tui, "_github_backup_now", return_value="✓ backup uploaded") as backup_now,
621
+ ):
622
+ tui._run_settings_loop("main")
623
+
624
+ backup_now.assert_called_once_with()
625
+
609
626
 
610
627
  if __name__ == "__main__":
611
628
  unittest.main()
@@ -142,7 +142,7 @@ wheels = [
142
142
 
143
143
  [[package]]
144
144
  name = "td-task"
145
- version = "0.2.5"
145
+ version = "0.2.6"
146
146
  source = { editable = "." }
147
147
  dependencies = [
148
148
  { name = "cryptography" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes