clonebox 0.1.5__py3-none-any.whl → 0.1.7__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.
clonebox/cli.py CHANGED
@@ -6,7 +6,9 @@ CloneBox CLI - Interactive command-line interface for creating VMs.
6
6
  import argparse
7
7
  import json
8
8
  import os
9
+ import re
9
10
  import sys
11
+ from typing import Optional
10
12
  from datetime import datetime
11
13
  from pathlib import Path
12
14
 
@@ -487,6 +489,40 @@ def cmd_list(args):
487
489
 
488
490
 
489
491
  CLONEBOX_CONFIG_FILE = ".clonebox.yaml"
492
+ CLONEBOX_ENV_FILE = ".env"
493
+
494
+
495
+ def load_env_file(env_path: Path) -> dict:
496
+ """Load environment variables from .env file."""
497
+ env_vars = {}
498
+ if not env_path.exists():
499
+ return env_vars
500
+
501
+ with open(env_path) as f:
502
+ for line in f:
503
+ line = line.strip()
504
+ if not line or line.startswith('#'):
505
+ continue
506
+ if '=' in line:
507
+ key, value = line.split('=', 1)
508
+ env_vars[key.strip()] = value.strip()
509
+
510
+ return env_vars
511
+
512
+
513
+ def expand_env_vars(value, env_vars: dict):
514
+ """Expand environment variables in string values like ${VAR_NAME}."""
515
+ if isinstance(value, str):
516
+ # Replace ${VAR_NAME} with value from env_vars or os.environ
517
+ def replacer(match):
518
+ var_name = match.group(1)
519
+ return env_vars.get(var_name, os.environ.get(var_name, match.group(0)))
520
+ return re.sub(r'\$\{([^}]+)\}', replacer, value)
521
+ elif isinstance(value, dict):
522
+ return {k: expand_env_vars(v, env_vars) for k, v in value.items()}
523
+ elif isinstance(value, list):
524
+ return [expand_env_vars(item, env_vars) for item in value]
525
+ return value
490
526
 
491
527
 
492
528
  def deduplicate_list(items: list, key=None) -> list:
@@ -508,6 +544,7 @@ def generate_clonebox_yaml(
508
544
  target_path: str = None,
509
545
  vm_name: str = None,
510
546
  network_mode: str = "auto",
547
+ base_image: Optional[str] = None,
511
548
  ) -> str:
512
549
  """Generate YAML config from system snapshot."""
513
550
  sys_info = detector.get_system_info()
@@ -555,6 +592,16 @@ def generate_clonebox_yaml(
555
592
  paths_mapping[host_path] = f"/mnt/workdir{idx}"
556
593
  idx += 1
557
594
 
595
+ # Add default user folders (Downloads, Documents)
596
+ home_dir = Path.home()
597
+ default_folders = [
598
+ (home_dir / "Downloads", "/home/ubuntu/Downloads"),
599
+ (home_dir / "Documents", "/home/ubuntu/Documents"),
600
+ ]
601
+ for host_folder, guest_folder in default_folders:
602
+ if host_folder.exists() and str(host_folder) not in paths_mapping:
603
+ paths_mapping[str(host_folder)] = guest_folder
604
+
558
605
  # Determine VM name
559
606
  if not vm_name:
560
607
  if target_path:
@@ -566,6 +613,28 @@ def generate_clonebox_yaml(
566
613
  ram_mb = min(4096, int(sys_info["memory_available_gb"] * 1024 * 0.5))
567
614
  vcpus = max(2, sys_info["cpu_count"] // 2)
568
615
 
616
+ # Auto-detect packages from running applications and services
617
+ app_packages = detector.suggest_packages_for_apps(snapshot.applications)
618
+ service_packages = detector.suggest_packages_for_services(snapshot.running_services)
619
+
620
+ # Combine with base packages (apt only)
621
+ base_packages = [
622
+ "build-essential",
623
+ "git",
624
+ "curl",
625
+ "vim",
626
+ ]
627
+
628
+ # Merge apt packages and deduplicate
629
+ all_apt_packages = base_packages + app_packages["apt"] + service_packages["apt"]
630
+ if deduplicate:
631
+ all_apt_packages = deduplicate_list(all_apt_packages)
632
+
633
+ # Merge snap packages and deduplicate
634
+ all_snap_packages = app_packages["snap"] + service_packages["snap"]
635
+ if deduplicate:
636
+ all_snap_packages = deduplicate_list(all_snap_packages)
637
+
569
638
  # Build config
570
639
  config = {
571
640
  "version": "1",
@@ -575,18 +644,15 @@ def generate_clonebox_yaml(
575
644
  "ram_mb": ram_mb,
576
645
  "vcpus": vcpus,
577
646
  "gui": True,
578
- "base_image": None,
647
+ "base_image": base_image,
579
648
  "network_mode": network_mode,
649
+ "username": "ubuntu",
650
+ "password": "${VM_PASSWORD}",
580
651
  },
581
652
  "services": services,
582
- "packages": [
583
- "build-essential",
584
- "git",
585
- "curl",
586
- "vim",
587
- "python3",
588
- "python3-pip",
589
- ],
653
+ "packages": all_apt_packages,
654
+ "snap_packages": all_snap_packages,
655
+ "post_commands": [], # User can add custom commands to run after setup
590
656
  "paths": paths_mapping,
591
657
  "detected": {
592
658
  "running_apps": [
@@ -605,17 +671,115 @@ def generate_clonebox_yaml(
605
671
 
606
672
 
607
673
  def load_clonebox_config(path: Path) -> dict:
608
- """Load .clonebox.yaml config file."""
674
+ """Load .clonebox.yaml config file and expand environment variables from .env."""
609
675
  config_file = path / CLONEBOX_CONFIG_FILE if path.is_dir() else path
610
676
 
611
677
  if not config_file.exists():
612
678
  raise FileNotFoundError(f"Config file not found: {config_file}")
613
679
 
614
- with open(config_file) as f:
615
- return yaml.safe_load(f)
616
-
680
+ # Load .env file from same directory
681
+ config_dir = config_file.parent
682
+ env_file = config_dir / CLONEBOX_ENV_FILE
683
+ env_vars = load_env_file(env_file)
617
684
 
618
- def create_vm_from_config(config: dict, start: bool = False, user_session: bool = False) -> str:
685
+ # Load YAML config
686
+ with open(config_file) as f:
687
+ config = yaml.safe_load(f)
688
+
689
+ # Expand environment variables in config
690
+ config = expand_env_vars(config, env_vars)
691
+
692
+ return config
693
+
694
+
695
+ def monitor_cloud_init_status(vm_name: str, user_session: bool = False, timeout: int = 600):
696
+ """Monitor cloud-init status in VM and show progress."""
697
+ import subprocess
698
+ import time
699
+
700
+ conn_uri = "qemu:///session" if user_session else "qemu:///system"
701
+ start_time = time.time()
702
+ shutdown_count = 0 # Count consecutive shutdown detections
703
+ restart_detected = False
704
+
705
+ with Progress(
706
+ SpinnerColumn(),
707
+ TextColumn("[progress.description]{task.description}"),
708
+ console=console,
709
+ ) as progress:
710
+ task = progress.add_task("[cyan]Starting VM and initializing...", total=None)
711
+
712
+ while time.time() - start_time < timeout:
713
+ try:
714
+ elapsed = int(time.time() - start_time)
715
+ minutes = elapsed // 60
716
+ seconds = elapsed % 60
717
+
718
+ # Check VM state
719
+ result = subprocess.run(
720
+ ["virsh", "--connect", conn_uri, "domstate", vm_name],
721
+ capture_output=True,
722
+ text=True,
723
+ timeout=5
724
+ )
725
+
726
+ vm_state = result.stdout.strip().lower()
727
+
728
+ if "shut off" in vm_state or "shutting down" in vm_state:
729
+ # VM is shutting down - count consecutive detections
730
+ shutdown_count += 1
731
+ if shutdown_count >= 3 and not restart_detected:
732
+ # Confirmed shutdown after 3 consecutive checks
733
+ restart_detected = True
734
+ progress.update(task, description="[yellow]⟳ VM restarting after package installation...")
735
+ time.sleep(3)
736
+ continue
737
+ else:
738
+ # VM is running - reset shutdown counter
739
+ if shutdown_count > 0 and shutdown_count < 3:
740
+ # Was a brief glitch, not a real shutdown
741
+ shutdown_count = 0
742
+
743
+ if restart_detected and "running" in vm_state and shutdown_count >= 3:
744
+ # VM restarted successfully - GUI should be ready
745
+ progress.update(task, description=f"[green]✓ GUI ready! Total time: {minutes}m {seconds}s")
746
+ time.sleep(2)
747
+ break
748
+
749
+ # Estimate remaining time
750
+ if elapsed < 60:
751
+ remaining = "~9-10 minutes"
752
+ elif elapsed < 180:
753
+ remaining = f"~{8 - minutes} minutes"
754
+ elif elapsed < 300:
755
+ remaining = f"~{6 - minutes} minutes"
756
+ else:
757
+ remaining = "finishing soon"
758
+
759
+ if restart_detected:
760
+ progress.update(task, description=f"[cyan]Starting GUI... ({minutes}m {seconds}s, {remaining})")
761
+ else:
762
+ progress.update(task, description=f"[cyan]Installing desktop packages... ({minutes}m {seconds}s, {remaining})")
763
+
764
+ except (subprocess.TimeoutExpired, Exception) as e:
765
+ elapsed = int(time.time() - start_time)
766
+ minutes = elapsed // 60
767
+ seconds = elapsed % 60
768
+ progress.update(task, description=f"[cyan]Configuring VM... ({minutes}m {seconds}s)")
769
+
770
+ time.sleep(3)
771
+
772
+ # Final status
773
+ if time.time() - start_time >= timeout:
774
+ progress.update(task, description="[yellow]⚠ Monitoring timeout - VM continues in background")
775
+
776
+
777
+ def create_vm_from_config(
778
+ config: dict,
779
+ start: bool = False,
780
+ user_session: bool = False,
781
+ replace: bool = False,
782
+ ) -> str:
619
783
  """Create VM from YAML config dict."""
620
784
  vm_config = VMConfig(
621
785
  name=config["vm"]["name"],
@@ -625,9 +789,13 @@ def create_vm_from_config(config: dict, start: bool = False, user_session: bool
625
789
  base_image=config["vm"].get("base_image"),
626
790
  paths=config.get("paths", {}),
627
791
  packages=config.get("packages", []),
792
+ snap_packages=config.get("snap_packages", []),
628
793
  services=config.get("services", []),
794
+ post_commands=config.get("post_commands", []),
629
795
  user_session=user_session,
630
796
  network_mode=config["vm"].get("network_mode", "auto"),
797
+ username=config["vm"].get("username", "ubuntu"),
798
+ password=config["vm"].get("password", "ubuntu"),
631
799
  )
632
800
 
633
801
  cloner = SelectiveVMCloner(user_session=user_session)
@@ -643,10 +811,20 @@ def create_vm_from_config(config: dict, start: bool = False, user_session: bool
643
811
 
644
812
  console.print(f"[dim]Session: {checks['session_type']}, Storage: {checks['images_dir']}[/]")
645
813
 
646
- vm_uuid = cloner.create_vm(vm_config, console=console)
814
+ vm_uuid = cloner.create_vm(vm_config, console=console, replace=replace)
647
815
 
648
816
  if start:
649
817
  cloner.start_vm(vm_config.name, open_viewer=vm_config.gui, console=console)
818
+
819
+ # Monitor cloud-init progress if GUI is enabled
820
+ if vm_config.gui:
821
+ console.print("\n[bold cyan]📊 Monitoring setup progress...[/]")
822
+ try:
823
+ monitor_cloud_init_status(vm_config.name, user_session=user_session)
824
+ except KeyboardInterrupt:
825
+ console.print("\n[yellow]Monitoring stopped. VM continues setup in background.[/]")
826
+ except Exception as e:
827
+ console.print(f"\n[dim]Note: Could not monitor status ({e}). VM continues setup in background.[/]")
650
828
 
651
829
  return vm_uuid
652
830
 
@@ -681,6 +859,7 @@ def cmd_clone(args):
681
859
  target_path=str(target_path),
682
860
  vm_name=vm_name,
683
861
  network_mode=args.network,
862
+ base_image=getattr(args, "base_image", None),
684
863
  )
685
864
 
686
865
  # Save config file
@@ -712,7 +891,8 @@ def cmd_clone(args):
712
891
  ).ask()
713
892
 
714
893
  if create_now:
715
- config = yaml.safe_load(yaml_content)
894
+ # Load config with environment variable expansion
895
+ config = load_clonebox_config(config_file.parent)
716
896
  user_session = getattr(args, "user", False)
717
897
 
718
898
  console.print("\n[bold cyan]🔧 Creating VM...[/]\n")
@@ -720,10 +900,26 @@ def cmd_clone(args):
720
900
  console.print("[cyan]Using user session (qemu:///session) - no root required[/]")
721
901
 
722
902
  try:
723
- vm_uuid = create_vm_from_config(config, start=True, user_session=user_session)
903
+ vm_uuid = create_vm_from_config(
904
+ config,
905
+ start=True,
906
+ user_session=user_session,
907
+ replace=getattr(args, "replace", False),
908
+ )
724
909
  console.print(f"\n[bold green]🎉 VM '{config['vm']['name']}' is running![/]")
725
910
  console.print(f"[dim]UUID: {vm_uuid}[/]")
726
911
 
912
+ # Show GUI startup info if GUI is enabled
913
+ if config.get("vm", {}).get("gui", False):
914
+ username = config['vm'].get('username', 'ubuntu')
915
+ password = config['vm'].get('password', 'ubuntu')
916
+ console.print("\n[bold yellow]⏰ GUI Setup Process:[/]")
917
+ console.print(" [yellow]•[/] Installing desktop environment (~5-10 minutes)")
918
+ console.print(" [yellow]•[/] Automatic restart after installation")
919
+ console.print(" [yellow]•[/] GUI login screen will appear")
920
+ console.print(f" [yellow]•[/] Login: [cyan]{username}[/] / [cyan]{'*' * len(password)}[/] (from .env)")
921
+ console.print("\n[dim]💡 Progress will be monitored automatically below[/]")
922
+
727
923
  # Show mount instructions
728
924
  if config.get("paths"):
729
925
  console.print("\n[bold]Inside VM, mount paths with:[/]")
@@ -939,6 +1135,15 @@ def main():
939
1135
  default="auto",
940
1136
  help="Network mode: auto (default), default (libvirt network), user (slirp)",
941
1137
  )
1138
+ clone_parser.add_argument(
1139
+ "--base-image",
1140
+ help="Path to a bootable qcow2 image to use as a base disk",
1141
+ )
1142
+ clone_parser.add_argument(
1143
+ "--replace",
1144
+ action="store_true",
1145
+ help="If VM already exists, stop+undefine it and recreate (also deletes its storage)",
1146
+ )
942
1147
  clone_parser.set_defaults(func=cmd_clone)
943
1148
 
944
1149
  args = parser.parse_args()
clonebox/cloner.py CHANGED
@@ -5,6 +5,8 @@ SelectiveVMCloner - Creates isolated VMs with only selected apps/paths/services.
5
5
 
6
6
  import os
7
7
  import subprocess
8
+ import tempfile
9
+ import urllib.request
8
10
  import uuid
9
11
  import xml.etree.ElementTree as ET
10
12
  from dataclasses import dataclass, field
@@ -29,9 +31,13 @@ class VMConfig:
29
31
  base_image: Optional[str] = None
30
32
  paths: dict = field(default_factory=dict)
31
33
  packages: list = field(default_factory=list)
34
+ snap_packages: list = field(default_factory=list) # Snap packages to install
32
35
  services: list = field(default_factory=list)
36
+ post_commands: list = field(default_factory=list) # Commands to run after setup
33
37
  user_session: bool = False # Use qemu:///session instead of qemu:///system
34
38
  network_mode: str = "auto" # auto|default|user
39
+ username: str = "ubuntu" # VM default username
40
+ password: str = "ubuntu" # VM default password
35
41
 
36
42
  def to_dict(self) -> dict:
37
43
  return {
@@ -51,6 +57,11 @@ class SelectiveVMCloner:
51
57
  SYSTEM_IMAGES_DIR = Path("/var/lib/libvirt/images")
52
58
  USER_IMAGES_DIR = Path.home() / ".local/share/libvirt/images"
53
59
 
60
+ DEFAULT_BASE_IMAGE_URL = (
61
+ "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img"
62
+ )
63
+ DEFAULT_BASE_IMAGE_FILENAME = "clonebox-ubuntu-jammy-amd64.qcow2"
64
+
54
65
  def __init__(self, conn_uri: str = None, user_session: bool = False):
55
66
  self.user_session = user_session
56
67
  if conn_uri:
@@ -91,6 +102,57 @@ class SelectiveVMCloner:
91
102
  return self.USER_IMAGES_DIR
92
103
  return self.SYSTEM_IMAGES_DIR
93
104
 
105
+ def _get_downloads_dir(self) -> Path:
106
+ return Path.home() / "Downloads"
107
+
108
+ def _ensure_default_base_image(self, console=None) -> Path:
109
+ def log(msg):
110
+ if console:
111
+ console.print(msg)
112
+ else:
113
+ print(msg)
114
+
115
+ downloads_dir = self._get_downloads_dir()
116
+ downloads_dir.mkdir(parents=True, exist_ok=True)
117
+ cached_path = downloads_dir / self.DEFAULT_BASE_IMAGE_FILENAME
118
+
119
+ if cached_path.exists() and cached_path.stat().st_size > 0:
120
+ return cached_path
121
+
122
+ log(
123
+ "[cyan]⬇️ Downloading base image (first run only). This will be cached in ~/Downloads...[/]"
124
+ )
125
+
126
+ try:
127
+ with tempfile.NamedTemporaryFile(
128
+ prefix=f"{self.DEFAULT_BASE_IMAGE_FILENAME}.",
129
+ dir=str(downloads_dir),
130
+ delete=False,
131
+ ) as tmp:
132
+ tmp_path = Path(tmp.name)
133
+
134
+ try:
135
+ urllib.request.urlretrieve(self.DEFAULT_BASE_IMAGE_URL, tmp_path)
136
+ tmp_path.replace(cached_path)
137
+ finally:
138
+ if tmp_path.exists() and tmp_path != cached_path:
139
+ try:
140
+ tmp_path.unlink()
141
+ except Exception:
142
+ pass
143
+ except Exception as e:
144
+ raise RuntimeError(
145
+ "Failed to download a default base image.\n\n"
146
+ "🔧 Solutions:\n"
147
+ " 1. Provide a base image explicitly:\n"
148
+ " clonebox clone . --base-image /path/to/image.qcow2\n"
149
+ " 2. Download it manually and reuse it:\n"
150
+ f" wget -O {cached_path} {self.DEFAULT_BASE_IMAGE_URL}\n\n"
151
+ f"Original error: {e}"
152
+ ) from e
153
+
154
+ return cached_path
155
+
94
156
  def _default_network_active(self) -> bool:
95
157
  """Check if libvirt default network is active."""
96
158
  try:
@@ -175,7 +237,7 @@ class SelectiveVMCloner:
175
237
 
176
238
  return checks
177
239
 
178
- def create_vm(self, config: VMConfig, console=None) -> str:
240
+ def create_vm(self, config: VMConfig, console=None, replace: bool = False) -> str:
179
241
  """
180
242
  Create a VM with only selected applications/paths.
181
243
 
@@ -193,6 +255,38 @@ class SelectiveVMCloner:
193
255
  else:
194
256
  print(msg)
195
257
 
258
+ # If VM already exists, optionally replace it
259
+ existing_vm = None
260
+ try:
261
+ candidate_vm = self.conn.lookupByName(config.name)
262
+ if candidate_vm is not None:
263
+ # libvirt returns a domain object whose .name() should match the requested name.
264
+ # In tests, an unconfigured MagicMock may be returned here; avoid treating that as
265
+ # a real existing domain unless we can confirm the name matches.
266
+ try:
267
+ if hasattr(candidate_vm, "name") and callable(candidate_vm.name):
268
+ if candidate_vm.name() == config.name:
269
+ existing_vm = candidate_vm
270
+ else:
271
+ existing_vm = candidate_vm
272
+ except Exception:
273
+ existing_vm = candidate_vm
274
+ except Exception:
275
+ existing_vm = None
276
+
277
+ if existing_vm is not None:
278
+ if not replace:
279
+ raise RuntimeError(
280
+ f"VM '{config.name}' already exists.\n\n"
281
+ f"🔧 Solutions:\n"
282
+ f" 1. Reuse existing VM: clonebox start {config.name}\n"
283
+ f" 2. Replace it: clonebox clone . --name {config.name} --replace\n"
284
+ f" 3. Delete it: clonebox delete {config.name}\n"
285
+ )
286
+
287
+ log(f"[yellow]⚠️ VM '{config.name}' already exists - replacing...[/]")
288
+ self.delete_vm(config.name, delete_storage=True, console=console, ignore_not_found=True)
289
+
196
290
  # Determine images directory
197
291
  images_dir = self.get_images_dir()
198
292
  vm_dir = images_dir / config.name
@@ -216,6 +310,9 @@ class SelectiveVMCloner:
216
310
  # Create root disk
217
311
  root_disk = vm_dir / "root.qcow2"
218
312
 
313
+ if not config.base_image:
314
+ config.base_image = str(self._ensure_default_base_image(console=console))
315
+
219
316
  if config.base_image and Path(config.base_image).exists():
220
317
  # Use backing file for faster creation
221
318
  log(f"[cyan]📀 Creating disk with backing file: {config.base_image}[/]")
@@ -258,7 +355,14 @@ class SelectiveVMCloner:
258
355
 
259
356
  # Define and create VM
260
357
  log(f"[cyan]🔧 Defining VM '{config.name}'...[/]")
261
- vm = self.conn.defineXML(vm_xml)
358
+ try:
359
+ vm = self.conn.defineXML(vm_xml)
360
+ except Exception as e:
361
+ raise RuntimeError(
362
+ f"Failed to define VM '{config.name}'.\n"
363
+ f"Error: {e}\n\n"
364
+ f"If the VM already exists, try: clonebox clone . --name {config.name} --replace\n"
365
+ ) from e
262
366
 
263
367
  log(f"[green]✅ VM '{config.name}' created successfully![/]")
264
368
  log(f"[dim] UUID: {vm.UUIDString()}[/]")
@@ -393,27 +497,95 @@ class SelectiveVMCloner:
393
497
  )
394
498
 
395
499
  # User-data
500
+ # Add desktop environment if GUI is enabled
501
+ base_packages = []
502
+ if config.gui:
503
+ base_packages.extend([
504
+ "ubuntu-desktop-minimal",
505
+ "firefox",
506
+ ])
507
+
508
+ all_packages = base_packages + list(config.packages)
396
509
  packages_yaml = (
397
- "\n".join(f" - {pkg}" for pkg in config.packages) if config.packages else ""
510
+ "\n".join(f" - {pkg}" for pkg in all_packages) if all_packages else ""
398
511
  )
399
- services_enable = (
400
- "\n".join(f" - systemctl enable --now {svc}" for svc in config.services)
401
- if config.services
402
- else ""
403
- )
404
- mounts_yaml = "\n".join(mount_commands) if mount_commands else ""
512
+
513
+ # Build runcmd - services, mounts, snaps, post_commands
514
+ runcmd_lines = []
515
+
516
+ # Add service enablement
517
+ for svc in config.services:
518
+ runcmd_lines.append(f" - systemctl enable --now {svc} || true")
519
+
520
+ # Add mounts
521
+ for cmd in mount_commands:
522
+ runcmd_lines.append(cmd)
523
+
524
+ # Install snap packages
525
+ if config.snap_packages:
526
+ runcmd_lines.append(" - echo 'Installing snap packages...'")
527
+ for snap_pkg in config.snap_packages:
528
+ runcmd_lines.append(f" - snap install {snap_pkg} --classic || snap install {snap_pkg} || true")
529
+
530
+ # Add GUI setup if enabled - runs AFTER package installation completes
531
+ if config.gui:
532
+ runcmd_lines.extend([
533
+ " - systemctl set-default graphical.target",
534
+ " - systemctl enable gdm3 || systemctl enable gdm || true",
535
+ ])
536
+
537
+ # Run user-defined post commands
538
+ if config.post_commands:
539
+ runcmd_lines.append(" - echo 'Running post-setup commands...'")
540
+ for cmd in config.post_commands:
541
+ runcmd_lines.append(f" - {cmd}")
542
+
543
+ # Validation - check installed packages and log results
544
+ runcmd_lines.append(" - echo '=== CloneBox Setup Validation ===' >> /var/log/clonebox-setup.log")
545
+ runcmd_lines.append(" - dpkg -l | grep -E 'ii' | wc -l >> /var/log/clonebox-setup.log")
546
+ runcmd_lines.append(" - snap list >> /var/log/clonebox-setup.log 2>/dev/null || true")
547
+ runcmd_lines.append(" - echo 'CloneBox VM ready!' > /var/log/clonebox-ready")
548
+ runcmd_lines.append(" - echo 'Setup completed at:' $(date) >> /var/log/clonebox-setup.log")
549
+
550
+ # Add reboot command at the end if GUI is enabled
551
+ if config.gui:
552
+ runcmd_lines.append(" - shutdown -r +1 'Rebooting to start GUI' || reboot")
553
+
554
+ runcmd_yaml = "\n".join(runcmd_lines) if runcmd_lines else ""
555
+
556
+ # Remove power_state - using shutdown -r instead
557
+ power_state_yaml = ""
405
558
 
406
559
  user_data = f"""#cloud-config
407
560
  hostname: {config.name}
408
561
  manage_etc_hosts: true
409
562
 
563
+ # Default user
564
+ users:
565
+ - name: {config.username}
566
+ sudo: ALL=(ALL) NOPASSWD:ALL
567
+ shell: /bin/bash
568
+ lock_passwd: false
569
+ groups: sudo,adm,dialout,cdrom,floppy,audio,dip,video,plugdev,netdev
570
+ plain_text_passwd: {config.password}
571
+
572
+ # Allow password authentication
573
+ ssh_pwauth: true
574
+ chpasswd:
575
+ expire: false
576
+
577
+ # Update package cache and upgrade
578
+ package_update: true
579
+ package_upgrade: false
580
+
581
+ # Install packages (cloud-init waits for completion before runcmd)
410
582
  packages:
411
583
  {packages_yaml}
412
584
 
585
+ # Run after packages are installed
413
586
  runcmd:
414
- {services_enable}
415
- {mounts_yaml}
416
- - echo "CloneBox VM ready!" > /var/log/clonebox-ready
587
+ {runcmd_yaml}
588
+ {power_state_yaml}
417
589
 
418
590
  final_message: "CloneBox VM is ready after $UPTIME seconds"
419
591
  """
@@ -451,6 +623,8 @@ final_message: "CloneBox VM is ready after $UPTIME seconds"
451
623
  try:
452
624
  vm = self.conn.lookupByName(vm_name)
453
625
  except libvirt.libvirtError:
626
+ if ignore_not_found:
627
+ return False
454
628
  log(f"[red]❌ VM '{vm_name}' not found[/]")
455
629
  return False
456
630
 
@@ -500,7 +674,13 @@ final_message: "CloneBox VM is ready after $UPTIME seconds"
500
674
  log("[green]✅ VM stopped![/]")
501
675
  return True
502
676
 
503
- def delete_vm(self, vm_name: str, delete_storage: bool = True, console=None) -> bool:
677
+ def delete_vm(
678
+ self,
679
+ vm_name: str,
680
+ delete_storage: bool = True,
681
+ console=None,
682
+ ignore_not_found: bool = False,
683
+ ) -> bool:
504
684
  """Delete a VM and optionally its storage."""
505
685
 
506
686
  def log(msg):
@@ -525,7 +705,7 @@ final_message: "CloneBox VM is ready after $UPTIME seconds"
525
705
 
526
706
  # Delete storage
527
707
  if delete_storage:
528
- vm_dir = Path(f"/var/lib/libvirt/images/{vm_name}")
708
+ vm_dir = self.get_images_dir() / vm_name
529
709
  if vm_dir.exists():
530
710
  import shutil
531
711
 
clonebox/detector.py CHANGED
@@ -144,12 +144,209 @@ class SystemDetector:
144
144
  "esbuild",
145
145
  "tmux",
146
146
  "screen",
147
+ # IDEs and desktop apps
148
+ "pycharm",
149
+ "idea",
150
+ "webstorm",
151
+ "phpstorm",
152
+ "goland",
153
+ "clion",
154
+ "rider",
155
+ "datagrip",
156
+ "sublime",
157
+ "atom",
158
+ "slack",
159
+ "discord",
160
+ "telegram",
161
+ "spotify",
162
+ "vlc",
163
+ "gimp",
164
+ "inkscape",
165
+ "blender",
166
+ "obs",
167
+ "postman",
168
+ "insomnia",
169
+ "dbeaver",
147
170
  ]
148
171
 
172
+ # Map process/service names to Ubuntu packages or snap packages
173
+ # Format: "process_name": ("package_name", "install_type") where install_type is "apt" or "snap"
174
+ APP_TO_PACKAGE_MAP = {
175
+ "python": ("python3", "apt"),
176
+ "python3": ("python3", "apt"),
177
+ "pip": ("python3-pip", "apt"),
178
+ "node": ("nodejs", "apt"),
179
+ "npm": ("npm", "apt"),
180
+ "yarn": ("yarnpkg", "apt"),
181
+ "docker": ("docker.io", "apt"),
182
+ "dockerd": ("docker.io", "apt"),
183
+ "docker-compose": ("docker-compose", "apt"),
184
+ "podman": ("podman", "apt"),
185
+ "nginx": ("nginx", "apt"),
186
+ "apache2": ("apache2", "apt"),
187
+ "httpd": ("apache2", "apt"),
188
+ "postgres": ("postgresql", "apt"),
189
+ "postgresql": ("postgresql", "apt"),
190
+ "mysql": ("mysql-server", "apt"),
191
+ "mysqld": ("mysql-server", "apt"),
192
+ "mongod": ("mongodb", "apt"),
193
+ "mongodb": ("mongodb", "apt"),
194
+ "redis-server": ("redis-server", "apt"),
195
+ "redis": ("redis-server", "apt"),
196
+ "vim": ("vim", "apt"),
197
+ "nvim": ("neovim", "apt"),
198
+ "emacs": ("emacs", "apt"),
199
+ "firefox": ("firefox", "apt"),
200
+ "chromium": ("chromium-browser", "apt"),
201
+ "jupyter": ("jupyter-notebook", "apt"),
202
+ "jupyter-lab": ("jupyterlab", "apt"),
203
+ "gunicorn": ("gunicorn", "apt"),
204
+ "uvicorn": ("uvicorn", "apt"),
205
+ "tmux": ("tmux", "apt"),
206
+ "screen": ("screen", "apt"),
207
+ "git": ("git", "apt"),
208
+ "curl": ("curl", "apt"),
209
+ "wget": ("wget", "apt"),
210
+ "ssh": ("openssh-client", "apt"),
211
+ "sshd": ("openssh-server", "apt"),
212
+ "go": ("golang", "apt"),
213
+ "cargo": ("cargo", "apt"),
214
+ "rustc": ("rustc", "apt"),
215
+ "java": ("default-jdk", "apt"),
216
+ "gradle": ("gradle", "apt"),
217
+ "mvn": ("maven", "apt"),
218
+ # Popular desktop apps (snap packages)
219
+ "chrome": ("chromium", "snap"),
220
+ "google-chrome": ("chromium", "snap"),
221
+ "pycharm": ("pycharm-community", "snap"),
222
+ "idea": ("intellij-idea-community", "snap"),
223
+ "code": ("code", "snap"),
224
+ "vscode": ("code", "snap"),
225
+ "slack": ("slack", "snap"),
226
+ "discord": ("discord", "snap"),
227
+ "spotify": ("spotify", "snap"),
228
+ "vlc": ("vlc", "apt"),
229
+ "gimp": ("gimp", "apt"),
230
+ "inkscape": ("inkscape", "apt"),
231
+ "blender": ("blender", "apt"),
232
+ "obs": ("obs-studio", "apt"),
233
+ "telegram": ("telegram-desktop", "snap"),
234
+ "postman": ("postman", "snap"),
235
+ "insomnia": ("insomnia", "snap"),
236
+ "dbeaver": ("dbeaver-ce", "snap"),
237
+ "sublime": ("sublime-text", "snap"),
238
+ "atom": ("atom", "snap"),
239
+ }
240
+
241
+ # Map applications to their config/data directories for complete cloning
242
+ # These directories contain user settings, extensions, profiles, credentials
243
+ APP_DATA_DIRS = {
244
+ # Browsers - profiles, extensions, bookmarks, passwords
245
+ "chrome": [".config/google-chrome", ".config/chromium"],
246
+ "chromium": [".config/chromium"],
247
+ "firefox": [".mozilla/firefox", ".cache/mozilla/firefox"],
248
+
249
+ # IDEs and editors - settings, extensions, projects history
250
+ "code": [".config/Code", ".vscode", ".vscode-server"],
251
+ "vscode": [".config/Code", ".vscode", ".vscode-server"],
252
+ "pycharm": [".config/JetBrains", ".local/share/JetBrains", ".cache/JetBrains"],
253
+ "idea": [".config/JetBrains", ".local/share/JetBrains"],
254
+ "webstorm": [".config/JetBrains", ".local/share/JetBrains"],
255
+ "goland": [".config/JetBrains", ".local/share/JetBrains"],
256
+ "sublime": [".config/sublime-text", ".config/sublime-text-3"],
257
+ "atom": [".atom"],
258
+ "vim": [".vim", ".vimrc", ".config/nvim"],
259
+ "nvim": [".config/nvim", ".local/share/nvim"],
260
+ "emacs": [".emacs.d", ".emacs"],
261
+ "cursor": [".config/Cursor", ".cursor"],
262
+
263
+ # Development tools
264
+ "docker": [".docker"],
265
+ "git": [".gitconfig", ".git-credentials", ".config/git"],
266
+ "npm": [".npm", ".npmrc"],
267
+ "yarn": [".yarn", ".yarnrc"],
268
+ "pip": [".pip", ".config/pip"],
269
+ "cargo": [".cargo"],
270
+ "rustup": [".rustup"],
271
+ "go": [".go", "go"],
272
+ "gradle": [".gradle"],
273
+ "maven": [".m2"],
274
+
275
+ # Python environments
276
+ "python": [".pyenv", ".virtualenvs", ".local/share/virtualenvs"],
277
+ "python3": [".pyenv", ".virtualenvs", ".local/share/virtualenvs"],
278
+ "conda": [".conda", "anaconda3", "miniconda3"],
279
+
280
+ # Node.js
281
+ "node": [".nvm", ".node", ".npm"],
282
+
283
+ # Databases
284
+ "postgres": [".pgpass", ".psqlrc", ".psql_history"],
285
+ "mysql": [".my.cnf", ".mysql_history"],
286
+ "mongodb": [".mongorc.js", ".dbshell"],
287
+ "redis": [".rediscli_history"],
288
+
289
+ # Communication apps
290
+ "slack": [".config/Slack"],
291
+ "discord": [".config/discord"],
292
+ "telegram": [".local/share/TelegramDesktop"],
293
+ "teams": [".config/Microsoft/Microsoft Teams"],
294
+
295
+ # Other tools
296
+ "postman": [".config/Postman"],
297
+ "insomnia": [".config/Insomnia"],
298
+ "dbeaver": [".local/share/DBeaverData"],
299
+ "ssh": [".ssh"],
300
+ "gpg": [".gnupg"],
301
+ "aws": [".aws"],
302
+ "gcloud": [".config/gcloud"],
303
+ "kubectl": [".kube"],
304
+ "terraform": [".terraform.d"],
305
+ "ansible": [".ansible"],
306
+
307
+ # General app data
308
+ "spotify": [".config/spotify"],
309
+ "vlc": [".config/vlc"],
310
+ "gimp": [".config/GIMP", ".gimp-2.10"],
311
+ "obs": [".config/obs-studio"],
312
+ }
313
+
149
314
  def __init__(self):
150
315
  self.user = pwd.getpwuid(os.getuid()).pw_name
151
316
  self.home = Path.home()
152
317
 
318
+ def detect_app_data_dirs(self, applications: list) -> list:
319
+ """Detect config/data directories for running applications.
320
+
321
+ Returns list of paths that contain user data needed by running apps.
322
+ """
323
+ app_data_paths = []
324
+ seen_paths = set()
325
+
326
+ for app in applications:
327
+ app_name = app.name.lower()
328
+
329
+ # Check each known app pattern
330
+ for pattern, dirs in self.APP_DATA_DIRS.items():
331
+ if pattern in app_name:
332
+ for dir_name in dirs:
333
+ full_path = self.home / dir_name
334
+ if full_path.exists() and str(full_path) not in seen_paths:
335
+ seen_paths.add(str(full_path))
336
+ # Calculate size
337
+ try:
338
+ size = self._get_dir_size(full_path, max_depth=2)
339
+ except:
340
+ size = 0
341
+ app_data_paths.append({
342
+ "path": str(full_path),
343
+ "app": app.name,
344
+ "type": "app_data",
345
+ "size_mb": round(size / 1024 / 1024, 1)
346
+ })
347
+
348
+ return app_data_paths
349
+
153
350
  def detect_all(self) -> SystemSnapshot:
154
351
  """Detect all services, applications and paths."""
155
352
  return SystemSnapshot(
@@ -390,6 +587,54 @@ class SystemDetector:
390
587
  pass
391
588
  return containers
392
589
 
590
+ def suggest_packages_for_apps(self, applications: list) -> dict:
591
+ """Suggest packages based on detected applications.
592
+
593
+ Returns:
594
+ dict with 'apt' and 'snap' keys containing lists of packages
595
+ """
596
+ apt_packages = set()
597
+ snap_packages = set()
598
+
599
+ for app in applications:
600
+ app_name = app.name.lower()
601
+ for key, (package, install_type) in self.APP_TO_PACKAGE_MAP.items():
602
+ if key in app_name:
603
+ if install_type == "snap":
604
+ snap_packages.add(package)
605
+ else:
606
+ apt_packages.add(package)
607
+ break
608
+
609
+ return {
610
+ "apt": sorted(list(apt_packages)),
611
+ "snap": sorted(list(snap_packages))
612
+ }
613
+
614
+ def suggest_packages_for_services(self, services: list) -> dict:
615
+ """Suggest packages based on detected services.
616
+
617
+ Returns:
618
+ dict with 'apt' and 'snap' keys containing lists of packages
619
+ """
620
+ apt_packages = set()
621
+ snap_packages = set()
622
+
623
+ for service in services:
624
+ service_name = service.name.lower()
625
+ for key, (package, install_type) in self.APP_TO_PACKAGE_MAP.items():
626
+ if key in service_name:
627
+ if install_type == "snap":
628
+ snap_packages.add(package)
629
+ else:
630
+ apt_packages.add(package)
631
+ break
632
+
633
+ return {
634
+ "apt": sorted(list(apt_packages)),
635
+ "snap": sorted(list(snap_packages))
636
+ }
637
+
393
638
  def get_system_info(self) -> dict:
394
639
  """Get basic system information."""
395
640
  return {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clonebox
3
- Version: 0.1.5
3
+ Version: 0.1.7
4
4
  Summary: Clone your workstation environment to an isolated VM with selective apps, paths and services
5
5
  Author: CloneBox Team
6
6
  License: Apache-2.0
@@ -38,6 +38,7 @@ Requires-Dist: ruff>=0.1.0; extra == "dev"
38
38
  Dynamic: license-file
39
39
 
40
40
  # CloneBox 📦
41
+ ![img.png](img.png)
41
42
 
42
43
  ```commandline
43
44
  ╔═══════════════════════════════════════════════════════╗
@@ -62,6 +63,7 @@ CloneBox lets you create isolated virtual machines with only the applications, d
62
63
  - ☁️ **Cloud-init** - Automatic package installation and service setup
63
64
  - 🖥️ **GUI support** - SPICE graphics with virt-viewer integration
64
65
  - ⚡ **Fast creation** - No full disk cloning, VMs are ready in seconds
66
+ - 📥 **Auto-download** - Automatically downloads and caches Ubuntu cloud images (stored in ~/Downloads)
65
67
 
66
68
  ## Installation
67
69
 
@@ -139,6 +141,8 @@ Simply run `clonebox` to start the interactive wizard:
139
141
 
140
142
  ```bash
141
143
  clonebox
144
+ clonebox clone . --user --run --replace --base-image ~/ubuntu-22.04-cloud.qcow2
145
+
142
146
  ```
143
147
 
144
148
  The wizard will:
@@ -259,6 +263,7 @@ The fastest way to clone your current working directory:
259
263
 
260
264
  ```bash
261
265
  # Clone current directory - generates .clonebox.yaml and asks to create VM
266
+ # Base OS image is automatically downloaded to ~/Downloads on first run
262
267
  clonebox clone .
263
268
 
264
269
  # Clone specific path
@@ -269,6 +274,15 @@ clonebox clone ~/projects/my-app --name my-dev-vm --run
269
274
 
270
275
  # Clone and edit config before creating
271
276
  clonebox clone . --edit
277
+
278
+ # Replace existing VM (stops, deletes, and recreates)
279
+ clonebox clone . --replace
280
+
281
+ # Use custom base image instead of auto-download
282
+ clonebox clone . --base-image ~/ubuntu-22.04-cloud.qcow2
283
+
284
+ # User session mode (no root required)
285
+ clonebox clone . --user
272
286
  ```
273
287
 
274
288
  Later, start the VM from any directory with `.clonebox.yaml`:
@@ -291,6 +305,63 @@ clonebox detect --yaml --dedupe
291
305
  clonebox detect --yaml --dedupe -o my-config.yaml
292
306
  ```
293
307
 
308
+ ### Base Images
309
+
310
+ CloneBox automatically downloads a bootable Ubuntu cloud image on first run:
311
+
312
+ ```bash
313
+ # Auto-download (default) - downloads Ubuntu 22.04 to ~/Downloads on first run
314
+ clonebox clone .
315
+
316
+ # Use custom base image
317
+ clonebox clone . --base-image ~/my-custom-image.qcow2
318
+
319
+ # Manual download (optional - clonebox does this automatically)
320
+ wget -O ~/Downloads/clonebox-ubuntu-jammy-amd64.qcow2 \
321
+ https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img
322
+ ```
323
+
324
+ **Base image behavior:**
325
+ - If no `--base-image` is specified, Ubuntu 22.04 cloud image is auto-downloaded
326
+ - Downloaded images are cached in `~/Downloads/clonebox-ubuntu-jammy-amd64.qcow2`
327
+ - Subsequent VMs reuse the cached image (no re-download)
328
+ - Each VM gets its own disk using the base image as a backing file (copy-on-write)
329
+
330
+ ### VM Login Credentials
331
+
332
+ VM credentials are managed through `.env` file for security:
333
+
334
+ **Setup:**
335
+ 1. Copy `.env.example` to `.env`:
336
+ ```bash
337
+ cp .env.example .env
338
+ ```
339
+
340
+ 2. Edit `.env` and set your password:
341
+ ```bash
342
+ # .env file
343
+ VM_PASSWORD=your_secure_password
344
+ VM_USERNAME=ubuntu
345
+ ```
346
+
347
+ 3. The `.clonebox.yaml` file references the password from `.env`:
348
+ ```yaml
349
+ vm:
350
+ username: ubuntu
351
+ password: ${VM_PASSWORD} # Loaded from .env
352
+ ```
353
+
354
+ **Default credentials (if .env not configured):**
355
+ - **Username:** `ubuntu`
356
+ - **Password:** `ubuntu`
357
+
358
+ **Security notes:**
359
+ - `.env` is automatically gitignored (never committed)
360
+ - Username is stored in YAML (not sensitive)
361
+ - Password is stored in `.env` (sensitive, not committed)
362
+ - Change password after first login: `passwd`
363
+ - User has passwordless sudo access
364
+
294
365
  ### User Session & Networking
295
366
 
296
367
  CloneBox supports creating VMs in user session (no root required) with automatic network fallback:
@@ -322,7 +393,9 @@ clonebox clone . --network auto
322
393
  | `clonebox clone <path>` | Generate `.clonebox.yaml` from path + running processes |
323
394
  | `clonebox clone . --run` | Clone and immediately start VM |
324
395
  | `clonebox clone . --edit` | Clone, edit config, then create |
396
+ | `clonebox clone . --replace` | Replace existing VM (stop, delete, recreate) |
325
397
  | `clonebox clone . --user` | Clone in user session (no root) |
398
+ | `clonebox clone . --base-image <path>` | Use custom base image |
326
399
  | `clonebox clone . --network user` | Use user-mode networking (slirp) |
327
400
  | `clonebox clone . --network auto` | Auto-detect network mode (default) |
328
401
  | `clonebox start .` | Start VM from `.clonebox.yaml` in current dir |
@@ -377,18 +450,21 @@ sudo usermod -aG kvm $USER
377
450
 
378
451
  ### VM Already Exists
379
452
 
380
- If you get "domain already exists" error:
453
+ If you get "VM already exists" error:
381
454
 
382
455
  ```bash
383
- # List VMs
384
- clonebox list
456
+ # Option 1: Use --replace flag to automatically replace it
457
+ clonebox clone . --replace
385
458
 
386
- # Stop and delete the existing VM
459
+ # Option 2: Delete manually first
387
460
  clonebox delete <vm-name>
388
461
 
389
- # Or use virsh directly
462
+ # Option 3: Use virsh directly
390
463
  virsh --connect qemu:///session destroy <vm-name>
391
464
  virsh --connect qemu:///session undefine <vm-name>
465
+
466
+ # Option 4: Start the existing VM instead
467
+ clonebox start <vm-name>
392
468
  ```
393
469
 
394
470
  ### virt-viewer not found
@@ -0,0 +1,11 @@
1
+ clonebox/__init__.py,sha256=IOk7G0DiSQ33EGbFC0xbnnFB9aou_6yuyFxvycQEvA0,407
2
+ clonebox/__main__.py,sha256=Fcoyzwwyz5-eC_sBlQk5a5RbKx8uodQz5sKJ190U0NU,135
3
+ clonebox/cli.py,sha256=ngC6Pwbfr1brFkf_0ODlFzDikojTOkA3Yw-3iY4vrmY,41441
4
+ clonebox/cloner.py,sha256=eDIxORCtnqG9mFKJl4OmW9F6tkEJp7dENYX-2x1Favg,26453
5
+ clonebox/detector.py,sha256=4fu04Ty6KC82WkcJZ5UL5TqXpWYE7Kb7R0uJ-9dtbCk,21635
6
+ clonebox-0.1.7.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
7
+ clonebox-0.1.7.dist-info/METADATA,sha256=wsaV7GyZ6zfLCxLuNoSopr4XUHdyTlrgxxJMiHuFnKU,15582
8
+ clonebox-0.1.7.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
9
+ clonebox-0.1.7.dist-info/entry_points.txt,sha256=FES95Vi3btfViLEEoHdb8nikNxTqzaooi9ehZw9ZfWI,47
10
+ clonebox-0.1.7.dist-info/top_level.txt,sha256=LdMo2cvCrEcRGH2M8JgQNVsCoszLV0xug6kx1JnaRjo,9
11
+ clonebox-0.1.7.dist-info/RECORD,,
@@ -1,11 +0,0 @@
1
- clonebox/__init__.py,sha256=IOk7G0DiSQ33EGbFC0xbnnFB9aou_6yuyFxvycQEvA0,407
2
- clonebox/__main__.py,sha256=Fcoyzwwyz5-eC_sBlQk5a5RbKx8uodQz5sKJ190U0NU,135
3
- clonebox/cli.py,sha256=TROvvv2BtUqKp5osXVUBvmPAdYTeTf0M-e69YaYQp78,32700
4
- clonebox/cloner.py,sha256=Uoh9mCUX-3p2tFL_3qlf2R2232JCXO5YhWrgKTpEr0s,19369
5
- clonebox/detector.py,sha256=jkzENmi4720n5e04k6gM7MNvXbQdYX-z1_O3Id0WK9w,12505
6
- clonebox-0.1.5.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
7
- clonebox-0.1.5.dist-info/METADATA,sha256=WWS_bye_xkDuWeJSfOMk7BDaDcL1rfE4RtZiKNlO1nA,13126
8
- clonebox-0.1.5.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
9
- clonebox-0.1.5.dist-info/entry_points.txt,sha256=FES95Vi3btfViLEEoHdb8nikNxTqzaooi9ehZw9ZfWI,47
10
- clonebox-0.1.5.dist-info/top_level.txt,sha256=LdMo2cvCrEcRGH2M8JgQNVsCoszLV0xug6kx1JnaRjo,9
11
- clonebox-0.1.5.dist-info/RECORD,,