visync 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.

Potentially problematic release.


This version of visync might be problematic. Click here for more details.

visync/main.py ADDED
@@ -0,0 +1,1172 @@
1
+ """Visync - Ventoy Package Manager.
2
+
3
+ Built with typer. Run `visync --help` for available commands.
4
+ """
5
+
6
+ import contextlib
7
+ import functools
8
+ from pathlib import Path
9
+
10
+ import typer
11
+ from rich.markup import escape as _esc
12
+
13
+ from visync.finder import (
14
+ find_installed_isos,
15
+ find_ventoy_drives,
16
+ get_iso_volume_id,
17
+ identify_distro,
18
+ keyword_hit,
19
+ load_all_metadata,
20
+ load_config,
21
+ )
22
+ from visync.output import (
23
+ console,
24
+ error,
25
+ failure_table,
26
+ header,
27
+ iso_table,
28
+ success,
29
+ warn,
30
+ )
31
+ from visync.output import info as output_info
32
+ from visync.verify import extract_version_from_filename, run_directory_verify
33
+
34
+ app = typer.Typer()
35
+
36
+ # Set by the global --yes flag. Module-level rather than threaded through every
37
+ # command because it is a single answer to a single question ("do not ask me
38
+ # about anything unusual"), and the alternative — eight copies of the same
39
+ # option — would let a script skip the check on one command and not another.
40
+ _ASSUME_YES = False
41
+
42
+
43
+ @app.callback()
44
+ def main_callback(
45
+ assume_yes: bool = typer.Option(
46
+ False,
47
+ "--yes",
48
+ "-y",
49
+ help="Answer yes to every confirmation, including using a --drive "
50
+ "that does not look like a Ventoy drive",
51
+ ),
52
+ ) -> None:
53
+ global _ASSUME_YES
54
+ _ASSUME_YES = assume_yes
55
+
56
+
57
+ def _skip_confirmations(command_yes: bool = False) -> bool:
58
+ """True when the user has asked not to be asked.
59
+
60
+ Commands that prompt for deletion keep their own ``--yes`` so the natural
61
+ form (``visync remove --yes``) keeps working, and also honour the global
62
+ flag so ``visync --yes remove`` means the same thing. Both spellings answer
63
+ one question, so both are folded together here rather than at each site.
64
+ """
65
+ return command_yes or _ASSUME_YES
66
+
67
+
68
+ def _parse_drives(raw: str | None) -> list[Path] | None:
69
+ """Parse comma-separated drive paths from CLI option."""
70
+ if not raw:
71
+ return None
72
+ return [Path(p.strip()) for p in raw.split(",") if p.strip()]
73
+
74
+
75
+ def _get_drives(drives: list[Path] | None = None) -> list[Path]:
76
+ """Resolve Ventoy drive paths.
77
+
78
+ If *drives* is provided, validate and return them.
79
+ If exactly one drive is detected, return it.
80
+ If multiple drives are detected, prompt the user to select one or more.
81
+
82
+ An explicit path that does not look like a Ventoy drive requires
83
+ confirmation. Writes into the wrong directory are not recoverable: cleanup
84
+ unlinks ISOs it recognises there, so a typo in --drive can delete real
85
+ images. The global --yes flag skips the prompt.
86
+ """
87
+ if drives is not None:
88
+ validated = []
89
+ for d in drives:
90
+ if not d.is_dir():
91
+ error(f"Not a directory: {d}")
92
+ raise typer.Exit(1)
93
+ if not (d / ".visync").is_dir() and not (d / "ventoy").is_dir():
94
+ console.print(
95
+ " [yellow]⚠[/yellow] Commands can write ISOs here, and"
96
+ " cleanup can [bold]delete[/bold] ISOs it recognises."
97
+ )
98
+ if _ASSUME_YES:
99
+ warn(
100
+ f"{d} is not a Ventoy/Visync-managed drive "
101
+ "(no .visync/ and no ventoy/ directory) — proceeding "
102
+ "because --yes was given."
103
+ )
104
+ else:
105
+ error(
106
+ f"{d} is not a Ventoy/Visync-managed drive "
107
+ "(no .visync/ and no ventoy/ directory)."
108
+ )
109
+ try:
110
+ proceed = typer.confirm("Use this directory anyway?")
111
+ except typer.Abort as exc:
112
+ error("Aborted.")
113
+ raise typer.Exit(1) from exc
114
+ if not proceed:
115
+ error("Aborted — nothing changed.")
116
+ raise typer.Exit(1)
117
+ validated.append(d)
118
+ return validated
119
+
120
+ detected = find_ventoy_drives()
121
+ if not detected:
122
+ error("No Ventoy drives detected.")
123
+ raise typer.Exit(1)
124
+
125
+ if len(detected) == 1:
126
+ return detected
127
+
128
+ # Multiple drives — prompt user to select
129
+ console.print()
130
+ console.print(" [bold]Multiple Ventoy drives detected:[/bold]")
131
+ for i, d in enumerate(detected, 1):
132
+ console.print(f" [cyan]{i}[/cyan]) {_esc(str(d))}")
133
+ console.print(" [dim]Enter numbers separated by commas (e.g. 1,3)[/dim]")
134
+ console.print()
135
+
136
+ while True:
137
+ try:
138
+ raw = typer.prompt("Select drive(s)")
139
+ except (typer.Abort, EOFError) as exc:
140
+ raise typer.Exit(1) from exc
141
+ try:
142
+ indices = [int(x.strip()) for x in raw.split(",") if x.strip()]
143
+ if not indices:
144
+ raise ValueError("empty")
145
+ selected = []
146
+ for idx in indices:
147
+ if 1 <= idx <= len(detected):
148
+ selected.append(detected[idx - 1])
149
+ else:
150
+ raise ValueError(f"invalid index {idx}")
151
+ return selected
152
+ except ValueError as e:
153
+ error(
154
+ f"Invalid input: {e}. Enter numbers 1-{len(detected)} separated by commas."
155
+ )
156
+
157
+
158
+ def _drives_optional(drive: str | None) -> list[Path]:
159
+ """Resolve drives for read-only catalogue commands.
160
+
161
+ Unlike _get_drives this returns an empty list instead of failing when no
162
+ Ventoy drive is present. `search` and `info` answer questions about the
163
+ configured catalogue, which is exactly what someone wants to ask before
164
+ plugging the drive in; hard-failing meant the only way to browse the
165
+ catalogue was to have the drive mounted. An explicit --drive is still
166
+ validated normally, since naming a path that is not there is a typo.
167
+ """
168
+ explicit = _parse_drives(drive)
169
+ if explicit is not None:
170
+ return _get_drives(explicit)
171
+ return find_ventoy_drives()
172
+
173
+
174
+ def _interruptible(fn):
175
+ """Turn Ctrl-C into a clean exit(130) instead of a traceback.
176
+
177
+ *fn* is the body of a command. Downloads already delete their own in-flight
178
+ .part file when interrupted (see download._discard_partial), so this only
179
+ has to stop the spinner, say what happened, and use the conventional exit
180
+ status for SIGINT. Previously the KeyboardInterrupt reached the interpreter
181
+ top level, which produced a traceback and — because the handler that was
182
+ supposed to tidy up lived in download.py's __main__ block, a path the CLI
183
+ never executes — left partial downloads behind on the drive.
184
+ """
185
+
186
+ @functools.wraps(fn)
187
+ def wrapper(*args, **kwargs):
188
+ try:
189
+ return fn(*args, **kwargs)
190
+ except KeyboardInterrupt:
191
+ console.print()
192
+ console.print(
193
+ "[red]✕ Interrupted.[/red] Partial downloads were removed; "
194
+ "nothing was changed on the drive."
195
+ )
196
+ raise typer.Exit(130) from None
197
+
198
+ return wrapper
199
+
200
+
201
+ def _sync_one_drive(
202
+ drive: Path,
203
+ only: list[str] | None,
204
+ config: Path | None,
205
+ dry_run: bool = False,
206
+ force: bool = False,
207
+ clean: bool = False,
208
+ no_verify: bool = False,
209
+ use_buffer: bool = True,
210
+ reset_visync: bool = False,
211
+ ) -> list[tuple[str, str]]:
212
+ """Run the sync pipeline for one drive and report what could not be checked.
213
+
214
+ Returns the list of ``(clean_name, reason)`` failures so the caller can finish
215
+ its own bookkeeping before deciding on an exit code. The failure block is
216
+ printed here so install/update/sync all surface it identically.
217
+ """
218
+ from visync.download import sync_all_configured_distros
219
+
220
+ _dir, _downloaded, failures = sync_all_configured_distros(
221
+ dry_run=dry_run,
222
+ force=force,
223
+ clean=clean,
224
+ config_path=config,
225
+ only=only,
226
+ drive_override=drive,
227
+ use_buffer=use_buffer,
228
+ no_verify=no_verify,
229
+ reset_visync=reset_visync,
230
+ )
231
+ if failures:
232
+ console.print()
233
+ error(f"Sync finished with {len(failures)} problem(s):")
234
+ failure_table(failures)
235
+ return failures
236
+
237
+
238
+ @app.command()
239
+ @_interruptible
240
+ def install(
241
+ name: str | None = typer.Argument(
242
+ default=None, help="Distro name or keyword to install"
243
+ ),
244
+ config: Path | None = typer.Option(
245
+ None, "--config", "-c", help="Path to config file"
246
+ ),
247
+ drive: str | None = typer.Option(
248
+ None, "--drive", "-d", help="Ventoy drive path(s), comma-separated for multiple"
249
+ ),
250
+ file: Path | None = typer.Option(
251
+ None, "--file", "-i", help="File with one distro name per line"
252
+ ),
253
+ dry_run: bool = typer.Option(
254
+ False,
255
+ "--dry-run",
256
+ "-n",
257
+ help="Show what would be installed without downloading",
258
+ ),
259
+ no_verify: bool = typer.Option(
260
+ False, "--no-verify", help="Skip checksum verification after download"
261
+ ),
262
+ no_staging: bool = typer.Option(
263
+ False,
264
+ "--no-staging",
265
+ "--no-buffer",
266
+ help="Download directly to the Ventoy drive (skip staging buffer)",
267
+ ),
268
+ ) -> None:
269
+ """Download and install distros to the Ventoy drive.
270
+
271
+ Use a distro name directly, or pass a file with one name per line.
272
+ """
273
+ from visync.pm import mark_installed, matching_distros, resolve_distro
274
+
275
+ config_data = load_config(config)
276
+ target_drives = _get_drives(_parse_drives(drive))
277
+
278
+ # Build list of distros to install
279
+ names: list[str] = []
280
+ if file:
281
+ if not file.exists():
282
+ error(f"File not found: {file}")
283
+ raise typer.Exit(1)
284
+ names = [
285
+ line.strip()
286
+ for line in file.read_text().splitlines()
287
+ if line.strip() and not line.strip().startswith("#")
288
+ ]
289
+ elif name:
290
+ names = [name]
291
+ else:
292
+ error("Provide a distro name or --file")
293
+ raise typer.Exit(1)
294
+
295
+ # Resolve all names first
296
+ to_install: list[tuple[str, str]] = [] # (entry_id, clean_name)
297
+ for n in names:
298
+ entry_id = resolve_distro(n, config_data)
299
+ if not entry_id:
300
+ _, partials = matching_distros(n, config_data)
301
+ if partials:
302
+ candidate_names = ", ".join(
303
+ sorted(
304
+ config_data.get("distros", {}).get(p, {}).get("clean_name", p)
305
+ for p in partials
306
+ )
307
+ )
308
+ error(
309
+ f"Ambiguous distro '{n}' — matches: {candidate_names}. Be specific."
310
+ )
311
+ else:
312
+ error(f"Unknown distro: '{n}'")
313
+ continue
314
+ distro_config = config_data.get("distros", {}).get(entry_id, {})
315
+ clean_name = distro_config.get("clean_name", entry_id)
316
+ to_install.append((entry_id, clean_name))
317
+
318
+ if not to_install:
319
+ error("No valid distros to install.")
320
+ raise typer.Exit(1)
321
+
322
+ # Prefer the staging buffer whenever it is usable; per-download disk space
323
+ # checks during the actual download remain the authoritative guard.
324
+ staging_dir = Path.home() / ".cache" / "visync" / "staging"
325
+ use_buffer = True
326
+ if not dry_run:
327
+ try:
328
+ staging_dir.mkdir(parents=True, exist_ok=True)
329
+ except OSError as e:
330
+ warn(f"Staging buffer unavailable: {e} — downloading directly to drive")
331
+ use_buffer = False
332
+
333
+ if no_staging:
334
+ use_buffer = False
335
+
336
+ for ventoy_root in target_drives:
337
+ if len(target_drives) > 1:
338
+ console.print()
339
+ header(f"Drive: {ventoy_root}")
340
+
341
+ existing = find_installed_isos(ventoy_root)
342
+ to_download: list[str] = []
343
+ failures: list[tuple[str, str]] = []
344
+
345
+ for entry_id, clean_name in to_install:
346
+ found = False
347
+ for iso_path in existing:
348
+ vid = get_iso_volume_id(iso_path)
349
+ if vid:
350
+ distro = identify_distro(vid, iso_path.name)
351
+ else:
352
+ distro = identify_distro("", iso_path.name)
353
+ if distro.lower() == clean_name.lower():
354
+ warn(f"{clean_name} is already on the drive: {iso_path.name}")
355
+ if not dry_run:
356
+ mark_installed(
357
+ ventoy_root,
358
+ entry_id,
359
+ version=extract_version_from_filename(iso_path.name) or "",
360
+ )
361
+ found = True
362
+ break
363
+ if not found:
364
+ to_download.append(entry_id)
365
+
366
+ if not to_download:
367
+ output_info("All distros already on drive.")
368
+ continue
369
+
370
+ if dry_run:
371
+ output_info(f"Would download {len(to_download)} distro(s):")
372
+ for entry_id in to_download:
373
+ distro_config = config_data.get("distros", {}).get(entry_id, {})
374
+ console.print(
375
+ f" [cyan]→[/cyan] {_esc(str(distro_config.get('clean_name', entry_id)))}"
376
+ )
377
+ continue
378
+
379
+ output_info(f"Installing {len(to_download)} distro(s)...")
380
+ failures.extend(
381
+ _sync_one_drive(
382
+ drive=ventoy_root,
383
+ only=to_download,
384
+ config=config,
385
+ force=True,
386
+ use_buffer=use_buffer,
387
+ no_verify=no_verify,
388
+ )
389
+ )
390
+
391
+ # Mark installed if file is now on drive
392
+ for entry_id in to_download:
393
+ distro_config = config_data.get("distros", {}).get(entry_id, {})
394
+ clean_name = distro_config.get("clean_name", entry_id)
395
+ fresh = find_installed_isos(ventoy_root)
396
+ for iso_path in fresh:
397
+ vid = get_iso_volume_id(iso_path)
398
+ if vid:
399
+ distro = identify_distro(vid, iso_path.name)
400
+ else:
401
+ distro = identify_distro("", iso_path.name)
402
+ if distro.lower() == clean_name.lower():
403
+ version = extract_version_from_filename(iso_path.name) or ""
404
+ mark_installed(ventoy_root, entry_id, version=version)
405
+ success(f"{clean_name} installed")
406
+ break
407
+ else:
408
+ warn(f"{clean_name} — file not found on drive after download")
409
+
410
+ if failures:
411
+ raise typer.Exit(1)
412
+
413
+
414
+ @app.command()
415
+ def remove(
416
+ name: str = typer.Argument(help="Distro name or keyword to remove"),
417
+ config: Path | None = typer.Option(
418
+ None, "--config", "-c", help="Path to config file"
419
+ ),
420
+ drive: str | None = typer.Option(
421
+ None, "--drive", "-d", help="Ventoy drive path(s), comma-separated for multiple"
422
+ ),
423
+ dry_run: bool = typer.Option(
424
+ False, "--dry-run", "-n", help="Show what would be removed without deleting"
425
+ ),
426
+ yes: bool = typer.Option(
427
+ False,
428
+ "--yes",
429
+ "-y",
430
+ help="Skip the confirmation prompt (same as --yes before the command)",
431
+ ),
432
+ ) -> None:
433
+ """Remove a distro from the Ventoy drive."""
434
+ from visync.finder import remove_iso_metadata
435
+ from visync.pm import mark_removed, matching_distros, resolve_distro
436
+
437
+ config_data = load_config(config)
438
+ target_drives = _get_drives(_parse_drives(drive))
439
+ skip_confirm = _skip_confirmations(yes)
440
+
441
+ entry_id = resolve_distro(name, config_data)
442
+ if not entry_id:
443
+ _, partials = matching_distros(name, config_data)
444
+ if partials:
445
+ candidate_names = ", ".join(
446
+ sorted(
447
+ config_data.get("distros", {}).get(p, {}).get("clean_name", p)
448
+ for p in partials
449
+ )
450
+ )
451
+ error(
452
+ f"Ambiguous distro '{name}' — matches: {candidate_names}. Be specific."
453
+ )
454
+ else:
455
+ error(f"Unknown distro: '{name}'")
456
+ raise typer.Exit(1)
457
+
458
+ distro_config = config_data.get("distros", {}).get(entry_id, {})
459
+ clean_name = distro_config.get("clean_name", entry_id)
460
+
461
+ for ventoy_root in target_drives:
462
+ if len(target_drives) > 1:
463
+ console.print()
464
+ header(f"Drive: {ventoy_root}")
465
+
466
+ # Find matching files on drive before touching anything
467
+ existing = find_installed_isos(ventoy_root)
468
+ matches = []
469
+ for iso_path in existing:
470
+ vid = get_iso_volume_id(iso_path)
471
+ if vid:
472
+ distro = identify_distro(vid, iso_path.name)
473
+ else:
474
+ distro = identify_distro("", iso_path.name)
475
+ if distro.lower() == clean_name.lower():
476
+ matches.append(iso_path)
477
+
478
+ if not matches:
479
+ warn(f"No files found for {clean_name} on the drive.")
480
+ continue
481
+
482
+ if dry_run:
483
+ for iso_path in matches:
484
+ output_info(f"Would remove {iso_path.name}")
485
+ continue
486
+
487
+ if not skip_confirm:
488
+ console.print(
489
+ f" About to delete {len(matches)} file(s) from {_esc(str(ventoy_root))}:"
490
+ )
491
+ for iso_path in matches:
492
+ console.print(f" [red]✗[/red] {_esc(iso_path.name)}")
493
+ try:
494
+ confirmed = typer.confirm("Delete these file(s)?")
495
+ except typer.Abort as exc:
496
+ error("Aborted — nothing deleted.")
497
+ raise typer.Exit(1) from exc
498
+ if not confirmed:
499
+ output_info("Aborted — nothing deleted.")
500
+ continue
501
+
502
+ removed_count = 0
503
+ for iso_path in matches:
504
+ try:
505
+ iso_path.unlink(missing_ok=True)
506
+ remove_iso_metadata(ventoy_root, iso_path.name)
507
+ success(f"Removed {iso_path.name}")
508
+ removed_count += 1
509
+ except OSError as e:
510
+ error(f"Could not remove {iso_path.name}: {e}")
511
+
512
+ if removed_count:
513
+ mark_removed(ventoy_root, entry_id)
514
+ success(f"{clean_name} removed")
515
+
516
+
517
+ @app.command()
518
+ @_interruptible
519
+ def update(
520
+ name: str | None = typer.Argument(
521
+ default=None, help="Distro to update (all if omitted)"
522
+ ),
523
+ config: Path | None = typer.Option(
524
+ None, "--config", "-c", help="Path to config file"
525
+ ),
526
+ drive: str | None = typer.Option(
527
+ None, "--drive", "-d", help="Ventoy drive path(s), comma-separated for multiple"
528
+ ),
529
+ force: bool = typer.Option(False, "--force", "-f", help="Force re-download"),
530
+ clean: bool = typer.Option(False, "--clean", help="Remove old versions"),
531
+ dry_run: bool = typer.Option(
532
+ False, "--dry-run", "-n", help="Show what would be updated without downloading"
533
+ ),
534
+ no_verify: bool = typer.Option(
535
+ False, "--no-verify", help="Skip checksum verification after download"
536
+ ),
537
+ no_staging: bool = typer.Option(
538
+ False,
539
+ "--no-staging",
540
+ "--no-buffer",
541
+ help="Download directly to the Ventoy drive (skip staging buffer)",
542
+ ),
543
+ reset_visync: bool = typer.Option(
544
+ False,
545
+ "--reset-visync",
546
+ help="Allow the watchdog to wipe .visync/ if it exceeds 1 GiB "
547
+ "(destroys installed.json; metadata rebuilds on next sync)",
548
+ ),
549
+ ) -> None:
550
+ """Update installed distros to latest versions."""
551
+ from visync.pm import (
552
+ get_installed_ids,
553
+ mark_installed,
554
+ matching_distros,
555
+ resolve_distro,
556
+ )
557
+
558
+ config_data = load_config(config)
559
+ target_drives = _get_drives(_parse_drives(drive))
560
+
561
+ failures: list[tuple[str, str]] = []
562
+
563
+ for ventoy_root in target_drives:
564
+ if len(target_drives) > 1:
565
+ console.print()
566
+ header(f"Drive: {ventoy_root}")
567
+
568
+ if name:
569
+ entry_id = resolve_distro(name, config_data)
570
+ if not entry_id:
571
+ _, partials = matching_distros(name, config_data)
572
+ if partials:
573
+ candidate_names = ", ".join(
574
+ sorted(
575
+ config_data.get("distros", {})
576
+ .get(p, {})
577
+ .get("clean_name", p)
578
+ for p in partials
579
+ )
580
+ )
581
+ error(
582
+ f"Ambiguous distro '{name}' — matches: {candidate_names}. Be specific."
583
+ )
584
+ else:
585
+ error(f"Unknown distro: '{name}'")
586
+ raise typer.Exit(1)
587
+ only = [entry_id]
588
+ else:
589
+ only = get_installed_ids(ventoy_root)
590
+ if not only:
591
+ output_info("No distros installed. Use 'visync install <name>' first.")
592
+ continue
593
+
594
+ failures.extend(
595
+ _sync_one_drive(
596
+ drive=ventoy_root,
597
+ only=only,
598
+ config=config,
599
+ dry_run=dry_run,
600
+ force=force,
601
+ clean=clean,
602
+ no_verify=no_verify,
603
+ use_buffer=not no_staging,
604
+ reset_visync=reset_visync,
605
+ )
606
+ )
607
+
608
+ if not dry_run:
609
+ existing = find_installed_isos(ventoy_root)
610
+ for eid in only:
611
+ distro_config = config_data.get("distros", {}).get(eid, {})
612
+ clean_name = distro_config.get("clean_name", eid)
613
+ for iso_path in existing:
614
+ vid = get_iso_volume_id(iso_path)
615
+ distro = (
616
+ identify_distro(vid, iso_path.name)
617
+ if vid
618
+ else identify_distro("", iso_path.name)
619
+ )
620
+ if distro.lower() == clean_name.lower():
621
+ version = extract_version_from_filename(iso_path.name) or ""
622
+ mark_installed(ventoy_root, eid, version=version)
623
+ break
624
+
625
+ if failures:
626
+ raise typer.Exit(1)
627
+
628
+
629
+ @app.command()
630
+ def search(
631
+ query: str | None = typer.Argument(
632
+ default=None, help="Search query (lists all if omitted)"
633
+ ),
634
+ config: Path | None = typer.Option(
635
+ None, "--config", "-c", help="Path to config file"
636
+ ),
637
+ drive: str | None = typer.Option(
638
+ None, "--drive", "-d", help="Ventoy drive path(s), comma-separated for multiple"
639
+ ),
640
+ ) -> None:
641
+ """Search available distros."""
642
+ from visync.pm import get_installed_ids, resolve_distro
643
+
644
+ config_data = load_config(config)
645
+ distros = config_data.get("distros", {})
646
+
647
+ if not distros:
648
+ warn("No distros configured.")
649
+ return
650
+
651
+ target_drives = _drives_optional(drive)
652
+
653
+ # Collect installed status across all drives
654
+ installed_by_drive: dict[Path, set[str]] = {}
655
+ for vr in target_drives:
656
+ installed_by_drive[vr] = set(get_installed_ids(vr))
657
+
658
+ if query:
659
+ entry_id = resolve_distro(query, config_data)
660
+ if entry_id:
661
+ s = distros[entry_id]
662
+ console.print()
663
+ console.print(
664
+ f" [bold]{_esc(str(s.get('clean_name', entry_id)))}[/bold] [dim]({_esc(entry_id)})[/dim]"
665
+ )
666
+ console.print(f" strategy: {s.get('strategy', '?')}")
667
+ if s.get("base_url"):
668
+ console.print(f" url: {_esc(str(s['base_url']))}")
669
+ if not target_drives:
670
+ console.print(
671
+ " [dim]no Ventoy drive detected — install status unknown[/dim]"
672
+ )
673
+ for vr in target_drives:
674
+ status = (
675
+ "installed" if entry_id in installed_by_drive[vr] else "available"
676
+ )
677
+ marker = (
678
+ "[green]installed[/green]"
679
+ if status == "installed"
680
+ else "[dim]available[/dim]"
681
+ )
682
+ console.print(f" {_esc(str(vr))}: {marker}")
683
+ else:
684
+ error(f"No match for '{query}'")
685
+ return
686
+
687
+ rows = []
688
+ for entry_id, s in sorted(distros.items()):
689
+ clean = s.get("clean_name", entry_id)
690
+ strategy = s.get("strategy", "?")
691
+ # Show which drives have it installed
692
+ drive_status = []
693
+ for vr in target_drives:
694
+ if entry_id in installed_by_drive[vr]:
695
+ drive_status.append("+")
696
+ else:
697
+ drive_status.append(" ")
698
+ rows.append((drive_status, clean, strategy))
699
+
700
+ console.print()
701
+ console.print(" [bold]Available distros:[/bold]")
702
+ if len(target_drives) > 1:
703
+ # Show drive headers
704
+ drive_labels = [f"D{i + 1}" for i in range(len(target_drives))]
705
+ console.print(
706
+ f" {'':3} {'Name':<25} {'Strategy':<20} {' '.join(drive_labels)}"
707
+ )
708
+ console.print(
709
+ f" {'':3} {'-' * 25} {'-' * 20} {' '.join(['--' for _ in target_drives])}"
710
+ )
711
+ for drive_status, name, strategy in rows:
712
+ markers = " ".join(
713
+ f"[green]{s}[/green]" if s == "+" else f"[dim]{s}[/dim]"
714
+ for s in drive_status
715
+ )
716
+ console.print(f" {'':3} {_esc(name):<25} {_esc(strategy):<20} {markers}")
717
+ else:
718
+ for drive_status, name, strategy in rows:
719
+ if drive_status:
720
+ marker = (
721
+ f"[green]{drive_status[0]}[/green]"
722
+ if drive_status[0] == "+"
723
+ else f"[dim]{drive_status[0]}[/dim]"
724
+ )
725
+ console.print(
726
+ f" {marker} {_esc(name)} [dim]({_esc(strategy)})[/dim]"
727
+ )
728
+ else:
729
+ console.print(f" {_esc(name)} [dim]({_esc(strategy)})[/dim]")
730
+ console.print()
731
+ if target_drives:
732
+ console.print(" [dim]+ = installed[/dim]")
733
+ else:
734
+ console.print(" [dim]no Ventoy drive detected — showing catalogue only[/dim]")
735
+
736
+
737
+ @app.command()
738
+ def info(
739
+ name: str = typer.Argument(help="Distro name or keyword"),
740
+ config: Path | None = typer.Option(
741
+ None, "--config", "-c", help="Path to config file"
742
+ ),
743
+ drive: str | None = typer.Option(
744
+ None, "--drive", "-d", help="Ventoy drive path(s), comma-separated for multiple"
745
+ ),
746
+ ) -> None:
747
+ """Show details about a distro."""
748
+ from visync.pm import get_installed_ids, resolve_distro
749
+
750
+ config_data = load_config(config)
751
+ distros = config_data.get("distros", {})
752
+
753
+ entry_id = resolve_distro(name, config_data)
754
+ if not entry_id:
755
+ error(f"Unknown distro: '{name}'")
756
+ raise typer.Exit(1)
757
+
758
+ s = distros[entry_id]
759
+ target_drives = _drives_optional(drive)
760
+
761
+ console.print()
762
+ console.print(f" [bold]{_esc(str(s.get('clean_name', entry_id)))}[/bold]")
763
+ console.print(f" entry_id: {_esc(entry_id)}")
764
+ console.print(f" strategy: {s.get('strategy', '?')}")
765
+ if s.get("base_url"):
766
+ console.print(f" base_url: {_esc(str(s['base_url']))}")
767
+ if s.get("api_url"):
768
+ console.print(f" api_url: {_esc(str(s['api_url']))}")
769
+ console.print(f" checksums: {s.get('checksum_format', 'none')}")
770
+
771
+ clean_name = s.get("clean_name", entry_id)
772
+ if not target_drives:
773
+ console.print(" [dim]no Ventoy drive detected — nothing to report on[/dim]")
774
+ for vr in target_drives:
775
+ installed = set(get_installed_ids(vr))
776
+ status = (
777
+ "[green]installed[/green]"
778
+ if entry_id in installed
779
+ else "[dim]available[/dim]"
780
+ )
781
+
782
+ existing = find_installed_isos(vr)
783
+ file_info = "[dim]not on drive[/dim]"
784
+ for iso_path in existing:
785
+ vid = get_iso_volume_id(iso_path)
786
+ if vid:
787
+ distro = identify_distro(vid, iso_path.name)
788
+ else:
789
+ distro = identify_distro("", iso_path.name)
790
+ if distro.lower() == clean_name.lower():
791
+ size_gb = iso_path.stat().st_size / (1024**3)
792
+ file_info = f"{iso_path.name} ({size_gb:.1f}G)"
793
+ break
794
+
795
+ if len(target_drives) > 1:
796
+ console.print(f" [bold]{vr}[/bold]")
797
+ console.print(f" status: {status}")
798
+ console.print(f" file: {_esc(file_info)}")
799
+ console.print()
800
+
801
+
802
+ @app.command()
803
+ def autodetect(
804
+ config: Path | None = typer.Option(
805
+ None, "--config", "-c", help="Path to config file"
806
+ ),
807
+ drive: str | None = typer.Option(
808
+ None, "--drive", "-d", help="Ventoy drive path(s), comma-separated for multiple"
809
+ ),
810
+ dry_run: bool = typer.Option(
811
+ False, "--dry-run", "-n", help="Show what would be detected without registering"
812
+ ),
813
+ ) -> None:
814
+ """Auto-detect ISOs on the drive and mark them as installed."""
815
+ from visync.pm import get_installed_ids, mark_installed
816
+
817
+ config_data = load_config(config)
818
+ target_drives = _get_drives(_parse_drives(drive))
819
+ distros = config_data.get("distros", {})
820
+
821
+ for ventoy_root in target_drives:
822
+ if len(target_drives) > 1:
823
+ console.print()
824
+ header(f"Drive: {ventoy_root}")
825
+
826
+ existing = find_installed_isos(ventoy_root)
827
+ # Read installed.json once per drive and keep it up to date as we
828
+ # register. Re-reading it inside the loop re-parsed the file for every
829
+ # ISO on the drive, and the only reason it was correct there was that
830
+ # mark_installed happened to write the new entry back out.
831
+ installed = set(get_installed_ids(ventoy_root))
832
+ found = 0
833
+ for iso_path in existing:
834
+ vid = get_iso_volume_id(iso_path)
835
+ if vid:
836
+ distro = identify_distro(vid, iso_path.name)
837
+ else:
838
+ distro = identify_distro("", iso_path.name)
839
+ if distro in ("Unknown OS", ""):
840
+ continue
841
+
842
+ # Find matching config entry — prefer exact clean_name match, then keyword in filename
843
+ entry_id = None
844
+ file_lower = iso_path.name.lower()
845
+ for eid, s in distros.items():
846
+ clean = s.get("clean_name", "")
847
+ if clean.lower() == distro.lower():
848
+ entry_id = eid
849
+ break
850
+ if not entry_id:
851
+ # Fallback: check if any config keyword appears in the filename
852
+ for eid, s in distros.items():
853
+ keyword = s.get("keyword", "")
854
+ if keyword and keyword_hit(keyword, file_lower):
855
+ entry_id = eid
856
+ break
857
+ if not entry_id or entry_id in installed:
858
+ continue
859
+
860
+ version = extract_version_from_filename(iso_path.name) or ""
861
+ if dry_run:
862
+ output_info(f"Would detect {distro}: {iso_path.name}")
863
+ found += 1
864
+ else:
865
+ mark_installed(ventoy_root, entry_id, version=version)
866
+ installed.add(entry_id)
867
+ success(f"Detected {distro}: {iso_path.name}")
868
+ found += 1
869
+
870
+ if found == 0:
871
+ output_info("No new distros detected (all already registered).")
872
+ else:
873
+ success(
874
+ f"{'Would detect' if dry_run else 'Marked'} {found} distro(s) as installed."
875
+ )
876
+
877
+
878
+ @app.command("list")
879
+ def list_isos(
880
+ drive: str | None = typer.Option(
881
+ None,
882
+ "--drive",
883
+ "-d",
884
+ help="Ventoy drive path(s), comma-separated for multiple",
885
+ ),
886
+ ) -> None:
887
+ """List ISOs on the Ventoy drive with distro, version, and size."""
888
+ # No --config here: this command reads the drive and the metadata sidecars,
889
+ # and identify_distro resolves against the installed catalogue. An accepted
890
+ # --config that changed nothing was worse than none, because a user who set
891
+ # it reasonably expected their file to be used.
892
+ target_drives = _get_drives(_parse_drives(drive))
893
+
894
+ for iso_dir in target_drives:
895
+ if len(target_drives) > 1:
896
+ console.print()
897
+ header(f"Drive: {iso_dir}")
898
+
899
+ iso_paths = find_installed_isos(iso_dir)
900
+ if not iso_paths:
901
+ warn("No ISO files found.")
902
+ continue
903
+
904
+ all_meta = load_all_metadata(iso_dir)
905
+
906
+ rows = []
907
+ total_gb_val = 0.0
908
+ for iso_path in sorted(iso_paths, key=lambda p: p.name):
909
+ meta = all_meta.get(iso_path.name)
910
+ if meta:
911
+ distro = identify_distro(meta.get("variant_stem", ""), iso_path.name)
912
+ version = meta.get("version") or "—"
913
+ else:
914
+ vid = get_iso_volume_id(iso_path)
915
+ distro = identify_distro(vid, iso_path.name)
916
+ version = extract_version_from_filename(iso_path.name) or "—"
917
+ # One stat per file: the total is accumulated here rather than
918
+ # recomputed in a second pass, which used to walk the drive twice.
919
+ size_gb = iso_path.stat().st_size / (1024**3)
920
+ total_gb_val += size_gb
921
+ rows.append((distro, version, f"{size_gb:.1f}G", iso_path.name))
922
+
923
+ iso_table(rows, total_gb_val)
924
+
925
+
926
+ @app.command()
927
+ @_interruptible
928
+ def sync(
929
+ config: Path | None = typer.Option(
930
+ None, "--config", "-c", help="Path to config file"
931
+ ),
932
+ drive: str | None = typer.Option(
933
+ None, "--drive", "-d", help="Ventoy drive path(s), comma-separated for multiple"
934
+ ),
935
+ dry_run: bool = typer.Option(
936
+ False, "--dry-run", "-n", help="Show what would be done without doing it"
937
+ ),
938
+ force: bool = typer.Option(
939
+ False, "--force", "-f", help="Force re-download even if version matches"
940
+ ),
941
+ clean: bool = typer.Option(
942
+ False,
943
+ "--clean",
944
+ help="Remove old versions of the same distro (dry-run by default)",
945
+ ),
946
+ # Named sync_all, not all: `all` is a builtin, and shadowing it inside the
947
+ # function body made `if all:` read like a filter on something else. The
948
+ # user-facing flag is unchanged.
949
+ sync_all: bool = typer.Option(
950
+ False, "--all", "-a", help="Sync all configured distros (not just installed)"
951
+ ),
952
+ no_verify: bool = typer.Option(
953
+ False, "--no-verify", help="Skip checksum verification after download"
954
+ ),
955
+ no_staging: bool = typer.Option(
956
+ False,
957
+ "--no-staging",
958
+ "--no-buffer",
959
+ help="Download directly to the Ventoy drive (skip staging buffer)",
960
+ ),
961
+ reset_visync: bool = typer.Option(
962
+ False,
963
+ "--reset-visync",
964
+ help="Allow the watchdog to wipe .visync/ if it exceeds 1 GiB "
965
+ "(destroys installed.json; metadata rebuilds on next sync)",
966
+ ),
967
+ ) -> None:
968
+ """Sync installed distros to the Ventoy drive."""
969
+ from visync.pm import get_installed_ids
970
+
971
+ target_drives = _get_drives(_parse_drives(drive))
972
+
973
+ failures: list[tuple[str, str]] = []
974
+
975
+ for drive_root in target_drives:
976
+ if len(target_drives) > 1:
977
+ console.print()
978
+ header(f"Drive: {drive_root}")
979
+
980
+ if sync_all:
981
+ only = None # None = sync everything
982
+ else:
983
+ only = get_installed_ids(drive_root)
984
+ if not only:
985
+ output_info(
986
+ "No distros installed. Use 'visync install <name>' or 'visync sync --all'."
987
+ )
988
+ continue
989
+
990
+ failures.extend(
991
+ _sync_one_drive(
992
+ drive=drive_root,
993
+ only=only,
994
+ config=config,
995
+ dry_run=dry_run,
996
+ force=force,
997
+ clean=clean,
998
+ no_verify=no_verify,
999
+ use_buffer=not no_staging,
1000
+ reset_visync=reset_visync,
1001
+ )
1002
+ )
1003
+
1004
+ if failures:
1005
+ raise typer.Exit(1)
1006
+
1007
+
1008
+ @app.command()
1009
+ def verify(
1010
+ config: Path | None = typer.Option(
1011
+ None, "--config", "-c", help="Path to config file"
1012
+ ),
1013
+ drive: str | None = typer.Option(
1014
+ None,
1015
+ "--drive",
1016
+ "-d",
1017
+ help="Ventoy drive path(s), comma-separated for multiple",
1018
+ ),
1019
+ ) -> None:
1020
+ """Verify integrity of ISOs on the Ventoy drive."""
1021
+ from visync.verify import VerifyStatus
1022
+
1023
+ config_data = load_config(config)
1024
+ target_drives = _get_drives(_parse_drives(drive))
1025
+
1026
+ verified = failed = skipped = unavailable = 0
1027
+ any_results = False
1028
+
1029
+ for iso_dir in target_drives:
1030
+ if len(target_drives) > 1:
1031
+ console.print()
1032
+ header(f"Drive: {iso_dir}")
1033
+
1034
+ output_info(f"Verifying ISOs in {iso_dir} ...")
1035
+ results = run_directory_verify(iso_dir, config_data)
1036
+
1037
+ if not results:
1038
+ warn("No ISO files found.")
1039
+ continue
1040
+
1041
+ any_results = True
1042
+ for iso_path, distro, status in results:
1043
+ label = f"{iso_path.name} ({distro})"
1044
+ if status is VerifyStatus.VERIFIED:
1045
+ success(label)
1046
+ verified += 1
1047
+ elif status is VerifyStatus.MISMATCH:
1048
+ error(f"{label} — checksum mismatch")
1049
+ failed += 1
1050
+ elif status is VerifyStatus.UNAVAILABLE:
1051
+ error(f"{label} — checksum could not be obtained (not verified)")
1052
+ unavailable += 1
1053
+ else:
1054
+ output_info(f"{label} — no checksum config")
1055
+ skipped += 1
1056
+
1057
+ if not any_results and len(target_drives) == 1:
1058
+ return
1059
+
1060
+ console.print()
1061
+ summary = f"Done: {verified} verified, {failed} failed"
1062
+ if unavailable:
1063
+ summary += f", {unavailable} unverified (source unreachable)"
1064
+ summary += f", {skipped} skipped (no config)."
1065
+ output_info(summary)
1066
+ if failed or unavailable:
1067
+ raise typer.Exit(1)
1068
+
1069
+
1070
+ @app.command("nuke-metadata")
1071
+ def nuke_metadata(
1072
+ drive: str | None = typer.Option(
1073
+ None, "--drive", "-d", help="Ventoy drive path(s), comma-separated for multiple"
1074
+ ),
1075
+ dry_run: bool = typer.Option(
1076
+ False, "--dry-run", "-n", help="Show what would be deleted without deleting"
1077
+ ),
1078
+ yes: bool = typer.Option(
1079
+ False,
1080
+ "--yes",
1081
+ "-y",
1082
+ help="Skip the confirmation prompt (same as --yes before the command)",
1083
+ ),
1084
+ ) -> None:
1085
+ """Delete all ISO metadata from .visync/metadata/.
1086
+
1087
+ Keeps installed.json and other state. Metadata rebuilds on next sync.
1088
+ Only .json metadata files are eligible for deletion.
1089
+ """
1090
+ from visync.finder import _guard_json_only
1091
+
1092
+ target_drives = _get_drives(_parse_drives(drive))
1093
+ skip_confirm = _skip_confirmations(yes)
1094
+
1095
+ for ventoy_root in target_drives:
1096
+ if len(target_drives) > 1:
1097
+ console.print()
1098
+ header(f"Drive: {ventoy_root}")
1099
+
1100
+ metadata_dir = ventoy_root / ".visync" / "metadata"
1101
+
1102
+ if not metadata_dir.is_dir():
1103
+ output_info("No metadata directory found.")
1104
+ continue
1105
+
1106
+ json_files = []
1107
+ skipped = []
1108
+ for f in sorted(metadata_dir.iterdir()):
1109
+ if f.is_file() and f.suffix == ".json":
1110
+ json_files.append(f)
1111
+ elif f.name != "." and f.is_file():
1112
+ skipped.append(f)
1113
+ total_size = sum(f.stat().st_size for f in json_files)
1114
+
1115
+ if not json_files:
1116
+ output_info("Metadata directory is empty.")
1117
+ continue
1118
+
1119
+ if dry_run:
1120
+ output_info(
1121
+ f"Would delete {len(json_files)} metadata file(s) ({total_size / 1024:.1f} KiB):"
1122
+ )
1123
+ for f in json_files:
1124
+ console.print(f" [cyan]→[/cyan] {_esc(f.name)}")
1125
+ continue
1126
+
1127
+ if not skip_confirm:
1128
+ console.print(
1129
+ f" About to delete {len(json_files)} metadata file(s) from {_esc(str(ventoy_root))}:"
1130
+ )
1131
+ for f in json_files:
1132
+ console.print(f" [red]✗[/red] {_esc(f.name)}")
1133
+ try:
1134
+ confirmed = typer.confirm("Delete these file(s)?")
1135
+ except typer.Abort as exc:
1136
+ error("Aborted — nothing deleted.")
1137
+ raise typer.Exit(1) from exc
1138
+ if not confirmed:
1139
+ output_info("Aborted — nothing deleted.")
1140
+ continue
1141
+
1142
+ deleted = 0
1143
+ for f in json_files:
1144
+ try:
1145
+ _guard_json_only(f)
1146
+ f.unlink()
1147
+ deleted += 1
1148
+ except ValueError as e:
1149
+ warn(str(e))
1150
+ except OSError as e:
1151
+ warn(f"Could not remove {f.name}: {e}")
1152
+ success(f"Deleted {deleted} metadata file(s) ({total_size / 1024:.1f} KiB)")
1153
+
1154
+ for f in skipped:
1155
+ warn(f"Not metadata — left in place: {f.name}")
1156
+ # The directory is not empty when non-metadata files were left in it,
1157
+ # which is not an error — the caller has already reported them.
1158
+ with contextlib.suppress(OSError):
1159
+ metadata_dir.rmdir()
1160
+ output_info("Metadata will rebuild on next sync.")
1161
+
1162
+
1163
+ @app.command()
1164
+ def version() -> None:
1165
+ """Show the version of Visync."""
1166
+ from . import __version__
1167
+
1168
+ typer.echo(f"Visync version: {__version__}")
1169
+
1170
+
1171
+ if __name__ == "__main__":
1172
+ app()