gardusig-cli 0.1.0__py3-none-any.whl

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 (93) hide show
  1. cli/__init__.py +3 -0
  2. cli/__main__.py +4 -0
  3. cli/cli.py +71 -0
  4. cli/commands/__init__.py +1 -0
  5. cli/commands/backup.py +49 -0
  6. cli/commands/bookmarks.py +21 -0
  7. cli/commands/chrome.py +77 -0
  8. cli/commands/contest.py +120 -0
  9. cli/commands/docker.py +386 -0
  10. cli/commands/drive.py +223 -0
  11. cli/commands/gh.py +419 -0
  12. cli/commands/git.py +790 -0
  13. cli/commands/links.py +81 -0
  14. cli/commands/notion.py +215 -0
  15. cli/commands/publish.py +63 -0
  16. cli/commands/restore.py +8 -0
  17. cli/integration/__init__.py +5 -0
  18. cli/integration/cli_api_checks.py +531 -0
  19. cli/integration/contest_integration.py +156 -0
  20. cli/integration/docker_integration.py +346 -0
  21. cli/integration/docker_mocks.py +125 -0
  22. cli/integration/git_mocks.py +59 -0
  23. cli/integration/integration_coverage.py +329 -0
  24. cli/integration/public_commands.py +82 -0
  25. cli/integration/public_endpoints.py +1151 -0
  26. cli/integration/tag_zip_integration.py +449 -0
  27. cli/integration/workflow_integration.py +804 -0
  28. cli/integration/workspaces.py +83 -0
  29. cli/internal/__init__.py +1 -0
  30. cli/internal/read/__init__.py +9 -0
  31. cli/internal/read/git.py +47 -0
  32. cli/internal/read/safety.py +99 -0
  33. cli/internal/write/__init__.py +13 -0
  34. cli/internal/write/gate.py +66 -0
  35. cli/internal/write/git.py +38 -0
  36. cli/models/__init__.py +1 -0
  37. cli/models/backup.py +10 -0
  38. cli/models/bookmark.py +14 -0
  39. cli/models/repository.py +9 -0
  40. cli/models/task.py +68 -0
  41. cli/providers/__init__.py +1 -0
  42. cli/providers/base.py +23 -0
  43. cli/providers/chrome.py +9 -0
  44. cli/providers/drive_client.py +56 -0
  45. cli/providers/gh.py +47 -0
  46. cli/providers/github.py +13 -0
  47. cli/providers/google_drive.py +31 -0
  48. cli/providers/icloud_drive.py +17 -0
  49. cli/providers/notion.py +244 -0
  50. cli/providers/onedrive.py +31 -0
  51. cli/providers/proton_drive.py +31 -0
  52. cli/services/__init__.py +3 -0
  53. cli/services/backup_repository.py +181 -0
  54. cli/services/backup_zip.py +65 -0
  55. cli/services/bookmark_sync.py +9 -0
  56. cli/services/contest_docker.py +193 -0
  57. cli/services/contest_runner.py +274 -0
  58. cli/services/contest_serde.py +30 -0
  59. cli/services/docker_runtime.py +332 -0
  60. cli/services/drive_sync.py +108 -0
  61. cli/services/gh_sequence.py +66 -0
  62. cli/services/gh_service.py +344 -0
  63. cli/services/git_archive.py +5 -0
  64. cli/services/git_review.py +34 -0
  65. cli/services/git_shortcuts.py +610 -0
  66. cli/services/notion_markdown.py +173 -0
  67. cli/services/notion_pairs.py +232 -0
  68. cli/services/notion_sync.py +220 -0
  69. cli/services/pypi_publish.py +80 -0
  70. cli/services/replica_deploy.py +128 -0
  71. cli/utils/__init__.py +12 -0
  72. cli/utils/catalog.py +179 -0
  73. cli/utils/config.py +316 -0
  74. cli/utils/confirm.py +18 -0
  75. cli/utils/external_client.py +143 -0
  76. cli/utils/fs.py +13 -0
  77. cli/utils/hashing.py +12 -0
  78. cli/utils/http.py +10 -0
  79. cli/utils/logger.py +15 -0
  80. cli/utils/process.py +62 -0
  81. cli/utils/quick_defaults.py +40 -0
  82. cli/utils/retry.py +30 -0
  83. cli/utils/yaml.py +22 -0
  84. cli/utils/zip.py +18 -0
  85. cli/workflows/__init__.py +1 -0
  86. cli/workflows/backup.py +1 -0
  87. cli/workflows/restore.py +1 -0
  88. gardusig_cli-0.1.0.dist-info/METADATA +274 -0
  89. gardusig_cli-0.1.0.dist-info/RECORD +93 -0
  90. gardusig_cli-0.1.0.dist-info/WHEEL +5 -0
  91. gardusig_cli-0.1.0.dist-info/entry_points.txt +2 -0
  92. gardusig_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  93. gardusig_cli-0.1.0.dist-info/top_level.txt +1 -0
cli/commands/docker.py ADDED
@@ -0,0 +1,386 @@
1
+ """Docker container and image shortcuts (monitor, stop, delete, reset)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ import typer
8
+ from rich import print as rprint
9
+ from rich.table import Table
10
+
11
+ from cli.internal.write.gate import require_write_gate
12
+ from cli.services.docker_runtime import (
13
+ ContainerRow,
14
+ ContainerStatsRow,
15
+ ImageRow,
16
+ docker_available,
17
+ format_bytes,
18
+ list_container_stats,
19
+ list_containers,
20
+ list_images,
21
+ prune_build_cache,
22
+ prune_images,
23
+ remove_containers,
24
+ reset_docker,
25
+ stop_containers,
26
+ system_df,
27
+ )
28
+
29
+ docker_app = typer.Typer(
30
+ help="Docker monitor and cleanup (no container start).",
31
+ no_args_is_help=True,
32
+ )
33
+
34
+ StatsDomain = Literal["cpu", "memory", "storage", "all"]
35
+
36
+
37
+ def _require_docker() -> None:
38
+ if not docker_available():
39
+ raise typer.Exit("docker is not installed or not on PATH")
40
+
41
+
42
+ def _print_container_table(rows: list[ContainerRow], *, title: str) -> None:
43
+ table = Table(title=title, show_header=True, header_style="bold")
44
+ table.add_column("SIZE", justify="right")
45
+ table.add_column("NAME")
46
+ table.add_column("STATUS")
47
+ table.add_column("ID")
48
+ for row in rows:
49
+ table.add_row(
50
+ format_bytes(row.size_bytes),
51
+ row.display_name,
52
+ row.status,
53
+ row.id,
54
+ )
55
+ rprint(table)
56
+
57
+
58
+ def _print_stats_table(rows: list[ContainerStatsRow], *, title: str) -> None:
59
+ table = Table(title=title, show_header=True, header_style="bold")
60
+ table.add_column("CPU", justify="right")
61
+ table.add_column("MEM", justify="right")
62
+ table.add_column("MEM %", justify="right")
63
+ table.add_column("NAME")
64
+ table.add_column("ID")
65
+ for row in rows:
66
+ mem = format_bytes(row.mem_used_bytes)
67
+ if row.mem_limit_bytes:
68
+ mem = f"{mem} / {format_bytes(row.mem_limit_bytes)}"
69
+ table.add_row(
70
+ f"{row.cpu_percent:.1f}%",
71
+ mem,
72
+ f"{row.mem_percent:.1f}%",
73
+ row.display_name,
74
+ row.id,
75
+ )
76
+ rprint(table)
77
+
78
+
79
+ def _print_image_table(rows: list[ImageRow], *, title: str) -> None:
80
+ table = Table(title=title, show_header=True, header_style="bold")
81
+ table.add_column("SIZE", justify="right")
82
+ table.add_column("IMAGE")
83
+ table.add_column("ID")
84
+ for row in rows:
85
+ table.add_row(format_bytes(row.size), row.name, row.id)
86
+ rprint(table)
87
+
88
+
89
+ def _container_preview(rows: list[ContainerRow], *, limit: int = 5) -> list[str]:
90
+ lines: list[str] = []
91
+ for row in rows[:limit]:
92
+ lines.append(f" - {row.display_name} ({row.status})")
93
+ if len(rows) > limit:
94
+ lines.append(f" ... ({len(rows) - limit} more)")
95
+ return lines
96
+
97
+
98
+ @docker_app.command("ps")
99
+ def ps_cmd(
100
+ top: int = typer.Option(20, "--top", "-n", help="Max rows to show."),
101
+ ) -> None:
102
+ """Running containers sorted by writable layer size."""
103
+ _require_docker()
104
+ rows = list_containers(running_only=True)[:top]
105
+ if not rows:
106
+ rprint("[dim]no running containers[/dim]")
107
+ return
108
+ _print_container_table(rows, title="Running containers (by storage)")
109
+
110
+
111
+ @docker_app.command("stats")
112
+ def stats_cmd(
113
+ by: StatsDomain = typer.Option(
114
+ "all",
115
+ "--by",
116
+ "-b",
117
+ help="cpu | memory | storage | all",
118
+ ),
119
+ top: int = typer.Option(10, "--top", "-n", help="Max rows per section."),
120
+ ) -> None:
121
+ """Top resource consumers by CPU, memory, or container storage."""
122
+ _require_docker()
123
+ if by in {"cpu", "memory", "all"}:
124
+ live = list_container_stats()
125
+ if not live:
126
+ rprint("[dim]no running containers[/dim]")
127
+ elif by in {"cpu", "all"}:
128
+ cpu_rows = sorted(live, key=lambda row: row.cpu_percent, reverse=True)[:top]
129
+ _print_stats_table(cpu_rows, title=f"Top {len(cpu_rows)} by CPU")
130
+ if by in {"memory", "all"} and live:
131
+ if by == "all":
132
+ rprint()
133
+ mem_rows = sorted(live, key=lambda row: row.mem_used_bytes, reverse=True)[:top]
134
+ _print_stats_table(mem_rows, title=f"Top {len(mem_rows)} by memory")
135
+ if by in {"storage", "all"}:
136
+ storage_rows = list_containers(running_only=True)[:top]
137
+ if not storage_rows:
138
+ if by == "storage":
139
+ rprint("[dim]no running containers[/dim]")
140
+ else:
141
+ if by == "all":
142
+ rprint()
143
+ _print_container_table(storage_rows, title=f"Top {len(storage_rows)} by container storage")
144
+
145
+
146
+ @docker_app.command("containers")
147
+ def containers_cmd(
148
+ top: int = typer.Option(20, "--top", "-n", help="Max rows to show."),
149
+ running: bool = typer.Option(False, "--running", help="Only running containers."),
150
+ ) -> None:
151
+ """All containers sorted by on-disk size."""
152
+ _require_docker()
153
+ rows = list_containers(all_containers=not running, running_only=running)[:top]
154
+ if not rows:
155
+ rprint("[dim]no containers[/dim]")
156
+ return
157
+ title = "Running containers (by storage)" if running else "Containers (by storage)"
158
+ _print_container_table(rows, title=title)
159
+
160
+
161
+ @docker_app.command("images")
162
+ def images_cmd(
163
+ top: int = typer.Option(20, "--top", "-n", help="Max rows to show."),
164
+ ) -> None:
165
+ """Images sorted by size."""
166
+ _require_docker()
167
+ rows = list_images()[:top]
168
+ if not rows:
169
+ rprint("[dim]no images[/dim]")
170
+ return
171
+ _print_image_table(rows, title="Images (by storage)")
172
+
173
+
174
+ @docker_app.command("top")
175
+ def top_cmd(
176
+ n: int = typer.Option(5, "--top", "-n", help="Rows per domain section."),
177
+ ) -> None:
178
+ """Dashboard: heaviest CPU, memory, and storage consumers."""
179
+ _require_docker()
180
+ live = list_container_stats()
181
+ if live:
182
+ cpu_rows = sorted(live, key=lambda row: row.cpu_percent, reverse=True)[:n]
183
+ _print_stats_table(cpu_rows, title=f"CPU — top {len(cpu_rows)} running")
184
+ rprint()
185
+ mem_rows = sorted(live, key=lambda row: row.mem_used_bytes, reverse=True)[:n]
186
+ _print_stats_table(mem_rows, title=f"Memory — top {len(mem_rows)} running")
187
+ else:
188
+ rprint("[dim]no running containers (cpu/memory)[/dim]")
189
+
190
+ all_containers = list_containers(all_containers=True)[:n]
191
+ images = list_images()[:n]
192
+ if all_containers:
193
+ rprint()
194
+ _print_container_table(all_containers, title=f"Storage — top {len(all_containers)} containers")
195
+ if images:
196
+ rprint()
197
+ _print_image_table(images, title=f"Storage — top {len(images)} images")
198
+ if not live and not all_containers and not images:
199
+ rprint("[dim]docker is empty[/dim]")
200
+
201
+
202
+ @docker_app.command("df")
203
+ def df_cmd() -> None:
204
+ """Docker disk usage summary (docker system df)."""
205
+ _require_docker()
206
+ rprint(system_df())
207
+
208
+
209
+ @docker_app.command("stop")
210
+ def stop_cmd(
211
+ names: list[str] = typer.Argument(None, help="Container names/ids (default: all running)."),
212
+ yes: bool = typer.Option(False, "--yes", "-y", help="Confirm stop."),
213
+ ) -> None:
214
+ """Stop running containers."""
215
+ _require_docker()
216
+ running = list_containers(running_only=True)
217
+ targets = list(names) if names else [row.display_name for row in running]
218
+ if not targets:
219
+ rprint("[yellow]no running containers[/yellow]")
220
+ return
221
+ extra = [f"containers_to_stop: {len(targets)}", *[f" - {name}" for name in targets[:10]]]
222
+ if len(targets) > 10:
223
+ extra.append(f" ... ({len(targets) - 10} more)")
224
+ require_write_gate(
225
+ "docker-stop",
226
+ summary_lines=["intent: docker stop"],
227
+ question=f"Stop {len(targets)} container(s)?",
228
+ yes=yes,
229
+ extra_lines=extra,
230
+ )
231
+ stopped = stop_containers(names=names)
232
+ rprint(f"[green]stopped[/green] {len(stopped)} container(s)")
233
+
234
+
235
+ @docker_app.command("container-delete")
236
+ def container_delete_cmd(
237
+ names: list[str] = typer.Argument(None, help="Container names/ids (default: all)."),
238
+ yes: bool = typer.Option(False, "--yes", "-y", help="Confirm delete."),
239
+ ) -> None:
240
+ """Remove containers (stopped and running)."""
241
+ _require_docker()
242
+ if names:
243
+ preview = [row for row in list_containers(all_containers=True) if row.display_name in names or row.id in names]
244
+ if not preview:
245
+ preview = [
246
+ ContainerRow(name=f"/{name}", id=name[:12], status="unknown", size_rw=0, size_rootfs=0)
247
+ for name in names
248
+ ]
249
+ target_count = len(names)
250
+ question = f"Delete {target_count} container(s)?"
251
+ else:
252
+ preview = list_containers(all_containers=True)
253
+ target_count = len(preview)
254
+ question = f"Delete all {target_count} container(s)?"
255
+ extra = [f"containers_to_delete: {target_count}", *_container_preview(preview)]
256
+ require_write_gate(
257
+ "docker-container-delete",
258
+ summary_lines=["intent: docker rm -f"],
259
+ question=question,
260
+ yes=yes,
261
+ extra_lines=extra,
262
+ )
263
+ removed = remove_containers(names=names)
264
+ rprint(f"[green]deleted[/green] {len(removed)} container(s)")
265
+
266
+
267
+ @docker_app.command("image-delete")
268
+ def image_delete_cmd(
269
+ yes: bool = typer.Option(False, "--yes", "-y", help="Confirm image prune."),
270
+ all_images: bool = typer.Option(
271
+ False,
272
+ "--all-images",
273
+ help="Prune all unused images (not only dangling).",
274
+ ),
275
+ ) -> None:
276
+ """Prune unused images."""
277
+ _require_docker()
278
+ images = list_images()
279
+ extra = [
280
+ f"images_present: {len(images)}",
281
+ f"prune_mode: {'all unused' if all_images else 'dangling only'}",
282
+ ]
283
+ require_write_gate(
284
+ "docker-image-delete",
285
+ summary_lines=["intent: docker image prune"],
286
+ question="Prune unused images?",
287
+ yes=yes,
288
+ extra_lines=extra,
289
+ )
290
+ out = prune_images(all_unused=all_images)
291
+ if out:
292
+ rprint(out)
293
+ rprint("[green]image prune[/green] complete")
294
+
295
+
296
+ @docker_app.command("reset")
297
+ def reset_cmd(
298
+ yes: bool = typer.Option(False, "--yes", "-y", help="Confirm full docker reset."),
299
+ all_images: bool = typer.Option(
300
+ True,
301
+ "--all-images/--dangling-only",
302
+ help="Prune all unused images (default) or dangling only.",
303
+ ),
304
+ ) -> None:
305
+ """Stop all containers, remove them, and prune images + build cache."""
306
+ _require_docker()
307
+ running = list_containers(running_only=True)
308
+ all_rows = list_containers(all_containers=True)
309
+ extra = [
310
+ "intent: stop all → rm all containers → image prune → build cache prune",
311
+ f"running_containers: {len(running)}",
312
+ f"total_containers: {len(all_rows)}",
313
+ f"image_prune: {'all unused' if all_images else 'dangling only'}",
314
+ "build_cache: prune",
315
+ ]
316
+ extra.extend(_container_preview(all_rows))
317
+ require_write_gate(
318
+ "docker-reset",
319
+ summary_lines=["intent: docker reset"],
320
+ question="Reset docker (stop, delete containers, prune images and cache)?",
321
+ yes=yes,
322
+ extra_lines=extra,
323
+ )
324
+ summary = reset_docker(all_images=all_images)
325
+ rprint(f"[green]stopped[/green] {len(summary.stopped)} container(s)")
326
+ rprint(f"[green]deleted[/green] {len(summary.removed_containers)} container(s)")
327
+ if summary.image_prune_output:
328
+ rprint(summary.image_prune_output)
329
+ rprint("[green]image prune[/green] complete")
330
+ if summary.cache_prune_output:
331
+ rprint(summary.cache_prune_output)
332
+ rprint("[green]build cache prune[/green] complete")
333
+
334
+
335
+ @docker_app.command("clean")
336
+ def clean_cmd(
337
+ target: str = typer.Argument(
338
+ "containers",
339
+ help="containers | images | cache | all",
340
+ ),
341
+ yes: bool = typer.Option(False, "--yes", "-y", help="Confirm destructive cleanup."),
342
+ all_images: bool = typer.Option(
343
+ False,
344
+ "--all-images",
345
+ help="With images: prune all unused images (not only dangling).",
346
+ ),
347
+ ) -> None:
348
+ """Targeted cleanup (containers, images, cache). Prefer `reset` for full wipe."""
349
+ _require_docker()
350
+
351
+ target = target.lower()
352
+ if target not in {"containers", "images", "cache", "all"}:
353
+ raise typer.BadParameter("target must be containers, images, cache, or all")
354
+
355
+ preview_containers = list_containers(all_containers=True) if target in {"containers", "all"} else []
356
+
357
+ extra: list[str] = [f"target: {target}"]
358
+ if preview_containers:
359
+ extra.append(f"containers_to_remove: {len(preview_containers)}")
360
+ extra.extend(_container_preview(preview_containers))
361
+ if target in {"images", "all"}:
362
+ extra.append(f"image_prune: {'all unused' if all_images else 'dangling only'}")
363
+ if target in {"cache", "all"}:
364
+ extra.append("build_cache: prune")
365
+
366
+ require_write_gate(
367
+ "docker-clean",
368
+ summary_lines=["intent: docker cleanup"],
369
+ question=f"Run docker clean {target}?",
370
+ yes=yes,
371
+ extra_lines=extra,
372
+ )
373
+
374
+ if target in {"containers", "all"}:
375
+ removed = remove_containers()
376
+ rprint(f"[green]removed[/green] {len(removed)} container(s)")
377
+ if target in {"images", "all"}:
378
+ out = prune_images(all_unused=all_images)
379
+ if out:
380
+ rprint(out)
381
+ rprint("[green]image prune[/green] complete")
382
+ if target in {"cache", "all"}:
383
+ out = prune_build_cache()
384
+ if out:
385
+ rprint(out)
386
+ rprint("[green]build cache prune[/green] complete")
cli/commands/drive.py ADDED
@@ -0,0 +1,223 @@
1
+ """Local git-tags store (iCloud) and cloud upload."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich import print as rprint
7
+
8
+ from cli.internal.read.git import git_worktree_snapshot
9
+ from cli.internal.write.gate import require_write_gate
10
+ from cli.services.backup_repository import (
11
+ SyncResult,
12
+ backup_status,
13
+ delete_repo_tag,
14
+ format_status_lines,
15
+ ingest_repositories,
16
+ list_downloaded_tags,
17
+ resolve_repo_path,
18
+ )
19
+ from cli.services.replica_deploy import deploy_replicas
20
+ from cli.services.drive_sync import UploadResult
21
+ from cli.services.git_shortcuts import GitShortcuts
22
+ from cli.utils.config import tags_dir_path
23
+ from cli.utils.external_client import ExternalCallError
24
+
25
+ drive_app = typer.Typer(
26
+ help="Local git-tags (iCloud), replicas (cloud + USB), and deploy.",
27
+ no_args_is_help=True,
28
+ )
29
+
30
+
31
+ def _print_ingest_rows(rows: list[tuple[Path, SyncResult]]) -> tuple[int, int, int]:
32
+ total_created = total_replaced = total_failed = 0
33
+ for repo, result in rows:
34
+ rprint(f"[bold]Repository[/bold] {repo.name} ({repo})")
35
+ for tag in result.created:
36
+ rprint(f" [green]✓[/green] {tag}")
37
+ for tag in result.replaced:
38
+ rprint(f" [green]↻[/green] {tag} (replaced)")
39
+ for tag, err in result.failed:
40
+ label = tag or "(repo)"
41
+ rprint(f" [red]✗[/red] {label}: {err}")
42
+ total_created += len(result.created)
43
+ total_replaced += len(result.replaced)
44
+ total_failed += len(result.failed)
45
+ return total_created, total_replaced, total_failed
46
+
47
+
48
+ def _print_upload_rows(uploads: list[tuple[str, UploadResult]]) -> tuple[int, int, int]:
49
+ total_up = total_skip = total_fail = 0
50
+ for name, upload in uploads:
51
+ rprint(f"[bold]Uploading to {name}[/bold]")
52
+ for rel in upload.uploaded:
53
+ rprint(f" [green]✓[/green] {rel}")
54
+ for rel in upload.skipped:
55
+ rprint(f" [dim]skip[/dim] {rel}")
56
+ for rel, err in upload.failed:
57
+ rprint(f" [red]✗[/red] {rel}: {err}")
58
+ total_up += len(upload.uploaded)
59
+ total_skip += len(upload.skipped)
60
+ total_fail += len(upload.failed)
61
+ return total_up, total_skip, total_fail
62
+
63
+
64
+ @drive_app.command("status")
65
+ def status_cmd() -> None:
66
+ """Show git tags vs local zips for configured repositories."""
67
+ for line in format_status_lines(backup_status()):
68
+ rprint(line.rstrip())
69
+
70
+
71
+ @drive_app.command("ingest")
72
+ def ingest_cmd(
73
+ path: str | None = typer.Argument(
74
+ None,
75
+ help="Repository path (default: every entry in backup.repositories).",
76
+ ),
77
+ ) -> None:
78
+ """Zip every local git tag into git-tags/REPO/ (create or replace)."""
79
+ try:
80
+ rows = ingest_repositories(path)
81
+ except RuntimeError as exc:
82
+ raise typer.Exit(str(exc)) from exc
83
+ total_created, total_replaced, total_failed = _print_ingest_rows(rows)
84
+ rprint(
85
+ f"Done. Created: {total_created} "
86
+ f"Replaced: {total_replaced} Failed: {total_failed}"
87
+ )
88
+
89
+
90
+ @drive_app.command("list")
91
+ def list_cmd(
92
+ path: str | None = typer.Argument(None, help="Repository path (default: cwd)."),
93
+ ) -> None:
94
+ """List tag zips stored under git-tags/REPO/."""
95
+ try:
96
+ repo = resolve_repo_path(path)
97
+ tags = list_downloaded_tags(repo)
98
+ except RuntimeError as exc:
99
+ raise typer.Exit(str(exc)) from exc
100
+ rprint(f"[bold]Local zips[/bold] {repo.name} ({len(tags)}):")
101
+ for tag in tags:
102
+ rprint(f" {tag}")
103
+ if not tags:
104
+ rprint(" (none)")
105
+
106
+
107
+ @drive_app.command("delete")
108
+ def delete_cmd(
109
+ path: str = typer.Argument(..., help="Repository path."),
110
+ tag: str = typer.Argument(..., help="Tag name (zip stem)."),
111
+ yes: bool = typer.Option(False, "--yes", "-y"),
112
+ ) -> None:
113
+ """Delete git-tags/REPO/TAG.zip from local store."""
114
+ try:
115
+ repo = resolve_repo_path(path)
116
+ except RuntimeError as exc:
117
+ raise typer.Exit(str(exc)) from exc
118
+ snapshot = git_worktree_snapshot(GitShortcuts(top=str(repo)))
119
+ require_write_gate(
120
+ "drive-delete",
121
+ snapshot.summary_lines(),
122
+ question=f"Delete local zip for tag {tag}?",
123
+ yes=yes,
124
+ extra_lines=[f"repo: {repo}", f"tag: {tag}"],
125
+ )
126
+ try:
127
+ deleted = delete_repo_tag(str(repo), tag)
128
+ except RuntimeError as exc:
129
+ raise typer.Exit(str(exc)) from exc
130
+ rprint(f"[green]deleted[/green] {deleted.name}")
131
+
132
+
133
+ @drive_app.command("sync")
134
+ def sync_cmd(
135
+ replica: str | None = typer.Argument(
136
+ None,
137
+ help="Replica name, provider, or USB path label (default: all replicas).",
138
+ ),
139
+ ) -> None:
140
+ """Ingest all configured repositories, then deploy to replicas (cloud + USB)."""
141
+ local_root = tags_dir_path()
142
+ if not local_root.is_dir():
143
+ raise typer.Exit(
144
+ f"Local git-tags folder not found: {local_root}\n"
145
+ "Set backup.tags_dir in config/config.yaml (default: iCloud git-tags)."
146
+ )
147
+ rprint("[bold]Phase 1 — ingest[/bold] (all backup.repositories)")
148
+ try:
149
+ ingest_rows = ingest_repositories()
150
+ except RuntimeError as exc:
151
+ raise typer.Exit(str(exc)) from exc
152
+ created, replaced, failed = _print_ingest_rows(ingest_rows)
153
+ rprint(
154
+ f"Ingest done. Created: {created} Replaced: {replaced} Failed: {failed}"
155
+ )
156
+ rprint()
157
+ rprint("[bold]Phase 2 — deploy[/bold] (replicas: cloud + USB)")
158
+ try:
159
+ deploy_rows = deploy_replicas(local_root, selected=replica)
160
+ up, skip, fail = _print_upload_rows(deploy_rows)
161
+ except RuntimeError as exc:
162
+ raise typer.Exit(str(exc)) from exc
163
+ except NotImplementedError as exc:
164
+ raise typer.Exit(str(exc)) from exc
165
+ except ExternalCallError as exc:
166
+ raise typer.Exit(exc.user_message) from exc
167
+ rprint(f"Sync done. Deployed: {up} Skipped: {skip} Failed: {fail}")
168
+
169
+
170
+ @drive_app.command("deploy")
171
+ def deploy_cmd(
172
+ replica: str | None = typer.Argument(
173
+ None,
174
+ help="Replica name or provider (default: all configured replicas).",
175
+ ),
176
+ ) -> None:
177
+ """Deploy local tag zips to replicas (cloud drives and/or USB paths)."""
178
+ local_root = tags_dir_path()
179
+ if not local_root.is_dir():
180
+ raise typer.Exit(
181
+ f"Local git-tags folder not found: {local_root}\n"
182
+ "Set backup.tags_dir in config/config.yaml (default: iCloud git-tags)."
183
+ )
184
+ try:
185
+ rows = deploy_replicas(local_root, selected=replica)
186
+ except RuntimeError as exc:
187
+ raise typer.Exit(str(exc)) from exc
188
+ except NotImplementedError as exc:
189
+ raise typer.Exit(str(exc)) from exc
190
+ except ExternalCallError as exc:
191
+ raise typer.Exit(exc.user_message) from exc
192
+ total_up, total_skip, total_fail = _print_upload_rows(rows)
193
+ rprint(
194
+ f"Done. Deployed: {total_up} Skipped: {total_skip} Failed: {total_fail}"
195
+ )
196
+
197
+
198
+ @drive_app.command("upload")
199
+ def upload_cmd(
200
+ provider: str | None = typer.Argument(
201
+ None,
202
+ help="google, onedrive, or proton (default: all enabled cloud replicas).",
203
+ ),
204
+ ) -> None:
205
+ """Deploy missing local zips to cloud replica(s) (append-only)."""
206
+ local_root = tags_dir_path()
207
+ if not local_root.is_dir():
208
+ raise typer.Exit(
209
+ f"Local git-tags folder not found: {local_root}\n"
210
+ "Set backup.tags_dir in config/config.yaml (default: iCloud git-tags)."
211
+ )
212
+ try:
213
+ rows = deploy_replicas(local_root, selected=provider, kinds=("cloud",))
214
+ except RuntimeError as exc:
215
+ raise typer.Exit(str(exc)) from exc
216
+ except NotImplementedError as exc:
217
+ raise typer.Exit(str(exc)) from exc
218
+ except ExternalCallError as exc:
219
+ raise typer.Exit(exc.user_message) from exc
220
+ total_up, total_skip, total_fail = _print_upload_rows(rows)
221
+ rprint(
222
+ f"Done. Uploaded: {total_up} Skipped: {total_skip} Failed: {total_fail}"
223
+ )