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/__init__.py +3 -0
- visync/config.toml +259 -0
- visync/download.py +1505 -0
- visync/finder.py +699 -0
- visync/main.py +1172 -0
- visync/net.py +84 -0
- visync/output.py +126 -0
- visync/pm.py +133 -0
- visync/verify.py +703 -0
- visync-0.1.0.dist-info/METADATA +204 -0
- visync-0.1.0.dist-info/RECORD +14 -0
- visync-0.1.0.dist-info/WHEEL +4 -0
- visync-0.1.0.dist-info/entry_points.txt +2 -0
- visync-0.1.0.dist-info/licenses/LICENSE +21 -0
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()
|