clonebox 0.1.19__py3-none-any.whl → 0.1.21__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 +116 -1
- clonebox/cloner.py +31 -3
- clonebox/dashboard.py +133 -0
- clonebox/detector.py +45 -20
- clonebox/models.py +6 -2
- clonebox/profiles.py +21 -4
- clonebox/validator.py +127 -32
- {clonebox-0.1.19.dist-info → clonebox-0.1.21.dist-info}/METADATA +195 -6
- clonebox-0.1.21.dist-info/RECORD +17 -0
- clonebox-0.1.19.dist-info/RECORD +0 -16
- {clonebox-0.1.19.dist-info → clonebox-0.1.21.dist-info}/WHEEL +0 -0
- {clonebox-0.1.19.dist-info → clonebox-0.1.21.dist-info}/entry_points.txt +0 -0
- {clonebox-0.1.19.dist-info → clonebox-0.1.21.dist-info}/licenses/LICENSE +0 -0
- {clonebox-0.1.19.dist-info → clonebox-0.1.21.dist-info}/top_level.txt +0 -0
clonebox/cli.py
CHANGED
|
@@ -653,6 +653,7 @@ def interactive_mode():
|
|
|
653
653
|
summary_table.add_row("Name", vm_name)
|
|
654
654
|
summary_table.add_row("RAM", f"{ram_mb} MB")
|
|
655
655
|
summary_table.add_row("vCPUs", str(vcpus))
|
|
656
|
+
summary_table.add_row("Disk", f"{20 if enable_gui else 10} GB")
|
|
656
657
|
summary_table.add_row("Services", ", ".join(selected_services) or "None")
|
|
657
658
|
summary_table.add_row(
|
|
658
659
|
"Packages",
|
|
@@ -684,6 +685,7 @@ def interactive_mode():
|
|
|
684
685
|
name=vm_name,
|
|
685
686
|
ram_mb=ram_mb,
|
|
686
687
|
vcpus=vcpus,
|
|
688
|
+
disk_size_gb=20 if enable_gui else 10,
|
|
687
689
|
gui=enable_gui,
|
|
688
690
|
base_image=base_image if base_image else None,
|
|
689
691
|
paths=paths_mapping,
|
|
@@ -732,6 +734,7 @@ def cmd_create(args):
|
|
|
732
734
|
name=args.name,
|
|
733
735
|
ram_mb=args.ram,
|
|
734
736
|
vcpus=args.vcpus,
|
|
737
|
+
disk_size_gb=getattr(args, "disk_size_gb", 10),
|
|
735
738
|
gui=not args.no_gui,
|
|
736
739
|
base_image=args.base_image,
|
|
737
740
|
paths=config_data.get("paths", {}),
|
|
@@ -919,6 +922,8 @@ def cmd_delete(args):
|
|
|
919
922
|
).ask():
|
|
920
923
|
console.print("[yellow]Cancelled.[/]")
|
|
921
924
|
return
|
|
925
|
+
|
|
926
|
+
|
|
922
927
|
def cmd_list(args):
|
|
923
928
|
"""List all VMs."""
|
|
924
929
|
cloner = SelectiveVMCloner(user_session=getattr(args, "user", False))
|
|
@@ -1027,6 +1032,19 @@ def cmd_container_down(args):
|
|
|
1027
1032
|
cloner.rm(args.name, force=True)
|
|
1028
1033
|
|
|
1029
1034
|
|
|
1035
|
+
def cmd_dashboard(args):
|
|
1036
|
+
"""Run the local CloneBox dashboard."""
|
|
1037
|
+
try:
|
|
1038
|
+
from clonebox.dashboard import run_dashboard
|
|
1039
|
+
except Exception as e:
|
|
1040
|
+
console.print("[red]❌ Dashboard dependencies are not installed.[/]")
|
|
1041
|
+
console.print("[dim]Install with: pip install 'clonebox[dashboard]'[/]")
|
|
1042
|
+
console.print(f"[dim]{e}[/]")
|
|
1043
|
+
return
|
|
1044
|
+
|
|
1045
|
+
run_dashboard(port=getattr(args, "port", 8080))
|
|
1046
|
+
|
|
1047
|
+
|
|
1030
1048
|
def cmd_diagnose(args):
|
|
1031
1049
|
"""Run detailed VM diagnostics (standalone)."""
|
|
1032
1050
|
name = args.name
|
|
@@ -1693,6 +1711,7 @@ def generate_clonebox_yaml(
|
|
|
1693
1711
|
vm_name: str = None,
|
|
1694
1712
|
network_mode: str = "auto",
|
|
1695
1713
|
base_image: Optional[str] = None,
|
|
1714
|
+
disk_size_gb: Optional[int] = None,
|
|
1696
1715
|
) -> str:
|
|
1697
1716
|
"""Generate YAML config from system snapshot."""
|
|
1698
1717
|
sys_info = detector.get_system_info()
|
|
@@ -1772,6 +1791,21 @@ def generate_clonebox_yaml(
|
|
|
1772
1791
|
guest_path = f"/home/ubuntu/{rel_path}"
|
|
1773
1792
|
app_data_mapping[host_path] = guest_path
|
|
1774
1793
|
|
|
1794
|
+
post_commands = []
|
|
1795
|
+
|
|
1796
|
+
chrome_profile = home_dir / ".config" / "google-chrome"
|
|
1797
|
+
if chrome_profile.exists():
|
|
1798
|
+
host_path = str(chrome_profile)
|
|
1799
|
+
if host_path not in paths_mapping and host_path not in app_data_mapping:
|
|
1800
|
+
app_data_mapping[host_path] = "/home/ubuntu/.config/google-chrome"
|
|
1801
|
+
|
|
1802
|
+
post_commands.append(
|
|
1803
|
+
"command -v google-chrome >/dev/null 2>&1 || ("
|
|
1804
|
+
"curl -fsSL -o /tmp/google-chrome.deb https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb && "
|
|
1805
|
+
"apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y /tmp/google-chrome.deb"
|
|
1806
|
+
")"
|
|
1807
|
+
)
|
|
1808
|
+
|
|
1775
1809
|
# Determine VM name
|
|
1776
1810
|
if not vm_name:
|
|
1777
1811
|
if target_path:
|
|
@@ -1783,6 +1817,9 @@ def generate_clonebox_yaml(
|
|
|
1783
1817
|
ram_mb = min(4096, int(sys_info["memory_available_gb"] * 1024 * 0.5))
|
|
1784
1818
|
vcpus = max(2, sys_info["cpu_count"] // 2)
|
|
1785
1819
|
|
|
1820
|
+
if disk_size_gb is None:
|
|
1821
|
+
disk_size_gb = 20
|
|
1822
|
+
|
|
1786
1823
|
# Auto-detect packages from running applications and services
|
|
1787
1824
|
app_packages = detector.suggest_packages_for_apps(snapshot.applications)
|
|
1788
1825
|
service_packages = detector.suggest_packages_for_services(snapshot.running_services)
|
|
@@ -1805,6 +1842,34 @@ def generate_clonebox_yaml(
|
|
|
1805
1842
|
if deduplicate:
|
|
1806
1843
|
all_snap_packages = deduplicate_list(all_snap_packages)
|
|
1807
1844
|
|
|
1845
|
+
if chrome_profile.exists() and "google-chrome" not in [d.get("app", "") for d in app_data_dirs]:
|
|
1846
|
+
if "chromium" not in all_snap_packages:
|
|
1847
|
+
all_snap_packages.append("chromium")
|
|
1848
|
+
|
|
1849
|
+
if "pycharm-community" in all_snap_packages:
|
|
1850
|
+
remapped = {}
|
|
1851
|
+
for host_path, guest_path in app_data_mapping.items():
|
|
1852
|
+
if guest_path == "/home/ubuntu/.config/JetBrains":
|
|
1853
|
+
remapped[host_path] = "/home/ubuntu/snap/pycharm-community/common/.config/JetBrains"
|
|
1854
|
+
elif guest_path == "/home/ubuntu/.local/share/JetBrains":
|
|
1855
|
+
remapped[host_path] = "/home/ubuntu/snap/pycharm-community/common/.local/share/JetBrains"
|
|
1856
|
+
elif guest_path == "/home/ubuntu/.cache/JetBrains":
|
|
1857
|
+
remapped[host_path] = "/home/ubuntu/snap/pycharm-community/common/.cache/JetBrains"
|
|
1858
|
+
else:
|
|
1859
|
+
remapped[host_path] = guest_path
|
|
1860
|
+
app_data_mapping = remapped
|
|
1861
|
+
|
|
1862
|
+
if "firefox" in all_apt_packages:
|
|
1863
|
+
remapped = {}
|
|
1864
|
+
for host_path, guest_path in app_data_mapping.items():
|
|
1865
|
+
if guest_path == "/home/ubuntu/.mozilla/firefox":
|
|
1866
|
+
remapped[host_path] = "/home/ubuntu/snap/firefox/common/.mozilla/firefox"
|
|
1867
|
+
elif guest_path == "/home/ubuntu/.cache/mozilla/firefox":
|
|
1868
|
+
remapped[host_path] = "/home/ubuntu/snap/firefox/common/.cache/mozilla/firefox"
|
|
1869
|
+
else:
|
|
1870
|
+
remapped[host_path] = guest_path
|
|
1871
|
+
app_data_mapping = remapped
|
|
1872
|
+
|
|
1808
1873
|
# Build config
|
|
1809
1874
|
config = {
|
|
1810
1875
|
"version": "1",
|
|
@@ -1813,6 +1878,7 @@ def generate_clonebox_yaml(
|
|
|
1813
1878
|
"name": vm_name,
|
|
1814
1879
|
"ram_mb": ram_mb,
|
|
1815
1880
|
"vcpus": vcpus,
|
|
1881
|
+
"disk_size_gb": disk_size_gb,
|
|
1816
1882
|
"gui": True,
|
|
1817
1883
|
"base_image": base_image,
|
|
1818
1884
|
"network_mode": network_mode,
|
|
@@ -1822,7 +1888,7 @@ def generate_clonebox_yaml(
|
|
|
1822
1888
|
"services": services,
|
|
1823
1889
|
"packages": all_apt_packages,
|
|
1824
1890
|
"snap_packages": all_snap_packages,
|
|
1825
|
-
"post_commands":
|
|
1891
|
+
"post_commands": post_commands,
|
|
1826
1892
|
"paths": paths_mapping,
|
|
1827
1893
|
"app_data_paths": app_data_mapping, # App-specific config/data directories
|
|
1828
1894
|
"detected": {
|
|
@@ -1966,6 +2032,7 @@ def create_vm_from_config(
|
|
|
1966
2032
|
name=config["vm"]["name"],
|
|
1967
2033
|
ram_mb=config["vm"].get("ram_mb", 4096),
|
|
1968
2034
|
vcpus=config["vm"].get("vcpus", 4),
|
|
2035
|
+
disk_size_gb=config["vm"].get("disk_size_gb", 10),
|
|
1969
2036
|
gui=config["vm"].get("gui", True),
|
|
1970
2037
|
base_image=config["vm"].get("base_image"),
|
|
1971
2038
|
paths=all_paths,
|
|
@@ -2045,6 +2112,7 @@ def cmd_clone(args):
|
|
|
2045
2112
|
vm_name=vm_name,
|
|
2046
2113
|
network_mode=args.network,
|
|
2047
2114
|
base_image=getattr(args, "base_image", None),
|
|
2115
|
+
disk_size_gb=getattr(args, "disk_size_gb", None),
|
|
2048
2116
|
)
|
|
2049
2117
|
|
|
2050
2118
|
profile_name = getattr(args, "profile", None)
|
|
@@ -2287,6 +2355,12 @@ def main():
|
|
|
2287
2355
|
)
|
|
2288
2356
|
create_parser.add_argument("--ram", type=int, default=4096, help="RAM in MB")
|
|
2289
2357
|
create_parser.add_argument("--vcpus", type=int, default=4, help="Number of vCPUs")
|
|
2358
|
+
create_parser.add_argument(
|
|
2359
|
+
"--disk-size-gb",
|
|
2360
|
+
type=int,
|
|
2361
|
+
default=10,
|
|
2362
|
+
help="Root disk size in GB (default: 10)",
|
|
2363
|
+
)
|
|
2290
2364
|
create_parser.add_argument("--base-image", help="Path to base qcow2 image")
|
|
2291
2365
|
create_parser.add_argument("--no-gui", action="store_true", help="Disable SPICE graphics")
|
|
2292
2366
|
create_parser.add_argument("--start", "-s", action="store_true", help="Start VM after creation")
|
|
@@ -2368,6 +2442,12 @@ def main():
|
|
|
2368
2442
|
container_sub = container_parser.add_subparsers(dest="container_command", help="Container commands")
|
|
2369
2443
|
|
|
2370
2444
|
container_up = container_sub.add_parser("up", help="Start container")
|
|
2445
|
+
container_up.add_argument(
|
|
2446
|
+
"--engine",
|
|
2447
|
+
choices=["auto", "podman", "docker"],
|
|
2448
|
+
default=argparse.SUPPRESS,
|
|
2449
|
+
help="Container engine: auto (default), podman, docker",
|
|
2450
|
+
)
|
|
2371
2451
|
container_up.add_argument("path", nargs="?", default=".", help="Workspace path")
|
|
2372
2452
|
container_up.add_argument("--name", help="Container name")
|
|
2373
2453
|
container_up.add_argument("--image", default="ubuntu:22.04", help="Container image")
|
|
@@ -2402,23 +2482,52 @@ def main():
|
|
|
2402
2482
|
container_up.set_defaults(func=cmd_container_up)
|
|
2403
2483
|
|
|
2404
2484
|
container_ps = container_sub.add_parser("ps", aliases=["ls"], help="List containers")
|
|
2485
|
+
container_ps.add_argument(
|
|
2486
|
+
"--engine",
|
|
2487
|
+
choices=["auto", "podman", "docker"],
|
|
2488
|
+
default=argparse.SUPPRESS,
|
|
2489
|
+
help="Container engine: auto (default), podman, docker",
|
|
2490
|
+
)
|
|
2405
2491
|
container_ps.add_argument("-a", "--all", action="store_true", help="Show all containers")
|
|
2406
2492
|
container_ps.add_argument("--json", action="store_true", help="Output JSON")
|
|
2407
2493
|
container_ps.set_defaults(func=cmd_container_ps)
|
|
2408
2494
|
|
|
2409
2495
|
container_stop = container_sub.add_parser("stop", help="Stop container")
|
|
2496
|
+
container_stop.add_argument(
|
|
2497
|
+
"--engine",
|
|
2498
|
+
choices=["auto", "podman", "docker"],
|
|
2499
|
+
default=argparse.SUPPRESS,
|
|
2500
|
+
help="Container engine: auto (default), podman, docker",
|
|
2501
|
+
)
|
|
2410
2502
|
container_stop.add_argument("name", help="Container name")
|
|
2411
2503
|
container_stop.set_defaults(func=cmd_container_stop)
|
|
2412
2504
|
|
|
2413
2505
|
container_rm = container_sub.add_parser("rm", help="Remove container")
|
|
2506
|
+
container_rm.add_argument(
|
|
2507
|
+
"--engine",
|
|
2508
|
+
choices=["auto", "podman", "docker"],
|
|
2509
|
+
default=argparse.SUPPRESS,
|
|
2510
|
+
help="Container engine: auto (default), podman, docker",
|
|
2511
|
+
)
|
|
2414
2512
|
container_rm.add_argument("name", help="Container name")
|
|
2415
2513
|
container_rm.add_argument("-f", "--force", action="store_true", help="Force remove")
|
|
2416
2514
|
container_rm.set_defaults(func=cmd_container_rm)
|
|
2417
2515
|
|
|
2418
2516
|
container_down = container_sub.add_parser("down", help="Stop and remove container")
|
|
2517
|
+
container_down.add_argument(
|
|
2518
|
+
"--engine",
|
|
2519
|
+
choices=["auto", "podman", "docker"],
|
|
2520
|
+
default=argparse.SUPPRESS,
|
|
2521
|
+
help="Container engine: auto (default), podman, docker",
|
|
2522
|
+
)
|
|
2419
2523
|
container_down.add_argument("name", help="Container name")
|
|
2420
2524
|
container_down.set_defaults(func=cmd_container_down)
|
|
2421
2525
|
|
|
2526
|
+
# Dashboard command
|
|
2527
|
+
dashboard_parser = subparsers.add_parser("dashboard", help="Run local dashboard")
|
|
2528
|
+
dashboard_parser.add_argument("--port", type=int, default=8080, help="Port to bind (default: 8080)")
|
|
2529
|
+
dashboard_parser.set_defaults(func=cmd_dashboard)
|
|
2530
|
+
|
|
2422
2531
|
# Detect command
|
|
2423
2532
|
detect_parser = subparsers.add_parser("detect", help="Detect system state")
|
|
2424
2533
|
detect_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
@@ -2458,6 +2567,12 @@ def main():
|
|
|
2458
2567
|
"--base-image",
|
|
2459
2568
|
help="Path to a bootable qcow2 image to use as a base disk",
|
|
2460
2569
|
)
|
|
2570
|
+
clone_parser.add_argument(
|
|
2571
|
+
"--disk-size-gb",
|
|
2572
|
+
type=int,
|
|
2573
|
+
default=None,
|
|
2574
|
+
help="Root disk size in GB (default: 20 for generated configs)",
|
|
2575
|
+
)
|
|
2461
2576
|
clone_parser.add_argument(
|
|
2462
2577
|
"--profile",
|
|
2463
2578
|
help="Profile name (loads ~/.clonebox.d/<name>.yaml, .clonebox.d/<name>.yaml, or built-in templates)",
|
clonebox/cloner.py
CHANGED
|
@@ -18,6 +18,16 @@ try:
|
|
|
18
18
|
except ImportError:
|
|
19
19
|
libvirt = None
|
|
20
20
|
|
|
21
|
+
SNAP_INTERFACES = {
|
|
22
|
+
'pycharm-community': ['desktop', 'desktop-legacy', 'x11', 'wayland', 'home', 'network', 'network-bind', 'cups-control', 'removable-media'],
|
|
23
|
+
'chromium': ['desktop', 'desktop-legacy', 'x11', 'wayland', 'home', 'network', 'audio-playback', 'camera'],
|
|
24
|
+
'firefox': ['desktop', 'desktop-legacy', 'x11', 'wayland', 'home', 'network', 'audio-playback', 'removable-media'],
|
|
25
|
+
'code': ['desktop', 'desktop-legacy', 'x11', 'wayland', 'home', 'network', 'ssh-keys'],
|
|
26
|
+
'slack': ['desktop', 'desktop-legacy', 'x11', 'wayland', 'home', 'network', 'audio-playback'],
|
|
27
|
+
'spotify': ['desktop', 'x11', 'wayland', 'home', 'network', 'audio-playback'],
|
|
28
|
+
}
|
|
29
|
+
DEFAULT_SNAP_INTERFACES = ['desktop', 'desktop-legacy', 'x11', 'home', 'network']
|
|
30
|
+
|
|
21
31
|
|
|
22
32
|
@dataclass
|
|
23
33
|
class VMConfig:
|
|
@@ -26,7 +36,7 @@ class VMConfig:
|
|
|
26
36
|
name: str = "clonebox-vm"
|
|
27
37
|
ram_mb: int = 4096
|
|
28
38
|
vcpus: int = 4
|
|
29
|
-
disk_size_gb: int =
|
|
39
|
+
disk_size_gb: int = 20
|
|
30
40
|
gui: bool = True
|
|
31
41
|
base_image: Optional[str] = None
|
|
32
42
|
paths: dict = field(default_factory=dict)
|
|
@@ -680,7 +690,7 @@ fi
|
|
|
680
690
|
|
|
681
691
|
# User-data
|
|
682
692
|
# Add desktop environment if GUI is enabled
|
|
683
|
-
base_packages = ["qemu-guest-agent"]
|
|
693
|
+
base_packages = ["qemu-guest-agent", "cloud-guest-utils"]
|
|
684
694
|
if config.gui:
|
|
685
695
|
base_packages.extend([
|
|
686
696
|
"ubuntu-desktop-minimal",
|
|
@@ -696,6 +706,8 @@ fi
|
|
|
696
706
|
runcmd_lines = []
|
|
697
707
|
|
|
698
708
|
runcmd_lines.append(" - systemctl enable --now qemu-guest-agent || true")
|
|
709
|
+
runcmd_lines.append(" - systemctl enable --now snapd || true")
|
|
710
|
+
runcmd_lines.append(" - timeout 300 snap wait system seed.loaded || true")
|
|
699
711
|
|
|
700
712
|
# Add service enablement
|
|
701
713
|
for svc in config.services:
|
|
@@ -717,6 +729,15 @@ fi
|
|
|
717
729
|
runcmd_lines.append(" - echo 'Installing snap packages...'")
|
|
718
730
|
for snap_pkg in config.snap_packages:
|
|
719
731
|
runcmd_lines.append(f" - snap install {snap_pkg} --classic || snap install {snap_pkg} || true")
|
|
732
|
+
|
|
733
|
+
# Connect snap interfaces for GUI apps (not auto-connected via cloud-init)
|
|
734
|
+
runcmd_lines.append(" - echo 'Connecting snap interfaces...'")
|
|
735
|
+
for snap_pkg in config.snap_packages:
|
|
736
|
+
interfaces = SNAP_INTERFACES.get(snap_pkg, DEFAULT_SNAP_INTERFACES)
|
|
737
|
+
for iface in interfaces:
|
|
738
|
+
runcmd_lines.append(f" - snap connect {snap_pkg}:{iface} :{iface} 2>/dev/null || true")
|
|
739
|
+
|
|
740
|
+
runcmd_lines.append(" - systemctl restart snapd || true")
|
|
720
741
|
|
|
721
742
|
# Add GUI setup if enabled - runs AFTER package installation completes
|
|
722
743
|
if config.gui:
|
|
@@ -760,7 +781,7 @@ users:
|
|
|
760
781
|
sudo: ALL=(ALL) NOPASSWD:ALL
|
|
761
782
|
shell: /bin/bash
|
|
762
783
|
lock_passwd: false
|
|
763
|
-
groups: sudo,adm,dialout,cdrom,floppy,audio,dip,video,plugdev,netdev
|
|
784
|
+
groups: sudo,adm,dialout,cdrom,floppy,audio,dip,video,plugdev,netdev,docker
|
|
764
785
|
plain_text_passwd: {config.password}
|
|
765
786
|
|
|
766
787
|
# Allow password authentication
|
|
@@ -768,6 +789,13 @@ ssh_pwauth: true
|
|
|
768
789
|
chpasswd:
|
|
769
790
|
expire: false
|
|
770
791
|
|
|
792
|
+
# Make sure root partition + filesystem grows to fill the qcow2 disk size
|
|
793
|
+
growpart:
|
|
794
|
+
mode: auto
|
|
795
|
+
devices: ["/"]
|
|
796
|
+
ignore_growroot_disabled: false
|
|
797
|
+
resize_rootfs: true
|
|
798
|
+
|
|
771
799
|
# Update package cache and upgrade
|
|
772
800
|
package_update: true
|
|
773
801
|
package_upgrade: false
|
clonebox/dashboard.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Any, List
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
8
|
+
|
|
9
|
+
app = FastAPI(title="CloneBox Dashboard")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _run_clonebox(args: List[str]) -> subprocess.CompletedProcess:
|
|
13
|
+
return subprocess.run(
|
|
14
|
+
[sys.executable, "-m", "clonebox"] + args,
|
|
15
|
+
capture_output=True,
|
|
16
|
+
text=True,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _render_table(title: str, headers: List[str], rows: List[List[str]]) -> str:
|
|
21
|
+
head_html = "".join(f"<th>{h}</th>" for h in headers)
|
|
22
|
+
body_html = "".join(
|
|
23
|
+
"<tr>" + "".join(f"<td>{c}</td>" for c in row) + "</tr>" for row in rows
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
f"<h2>{title}</h2>"
|
|
28
|
+
"<table>"
|
|
29
|
+
f"<thead><tr>{head_html}</tr></thead>"
|
|
30
|
+
f"<tbody>{body_html}</tbody>"
|
|
31
|
+
"</table>"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.get("/", response_class=HTMLResponse)
|
|
36
|
+
async def dashboard() -> str:
|
|
37
|
+
return """
|
|
38
|
+
<!DOCTYPE html>
|
|
39
|
+
<html>
|
|
40
|
+
<head>
|
|
41
|
+
<title>CloneBox Dashboard</title>
|
|
42
|
+
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
|
43
|
+
<style>
|
|
44
|
+
body { font-family: system-ui, -apple-system, sans-serif; margin: 20px; }
|
|
45
|
+
table { border-collapse: collapse; width: 100%; margin-bottom: 24px; }
|
|
46
|
+
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
|
47
|
+
th { background: #f6f6f6; }
|
|
48
|
+
code { background: #f6f6f6; padding: 2px 4px; border-radius: 4px; }
|
|
49
|
+
</style>
|
|
50
|
+
</head>
|
|
51
|
+
<body>
|
|
52
|
+
<h1>CloneBox Dashboard</h1>
|
|
53
|
+
<p>Auto-refresh every 5s. JSON endpoints: <code>/api/vms.json</code>, <code>/api/containers.json</code></p>
|
|
54
|
+
|
|
55
|
+
<div id="vms" hx-get="/api/vms" hx-trigger="load, every 5s">Loading VMs...</div>
|
|
56
|
+
<div id="containers" hx-get="/api/containers" hx-trigger="load, every 5s">Loading containers...</div>
|
|
57
|
+
</body>
|
|
58
|
+
</html>
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.get("/api/vms", response_class=HTMLResponse)
|
|
63
|
+
async def api_vms() -> str:
|
|
64
|
+
proc = _run_clonebox(["list", "--json"])
|
|
65
|
+
if proc.returncode != 0:
|
|
66
|
+
return f"<pre>clonebox list failed:\n{proc.stderr}</pre>"
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
items: List[dict[str, Any]] = json.loads(proc.stdout or "[]")
|
|
70
|
+
except json.JSONDecodeError:
|
|
71
|
+
return f"<pre>Invalid JSON from clonebox list:\n{proc.stdout}</pre>"
|
|
72
|
+
|
|
73
|
+
if not items:
|
|
74
|
+
return "<h2>VMs</h2><p><em>No VMs found.</em></p>"
|
|
75
|
+
|
|
76
|
+
rows = [[str(i.get("name", "")), str(i.get("state", "")), str(i.get("uuid", ""))] for i in items]
|
|
77
|
+
return _render_table("VMs", ["Name", "State", "UUID"], rows)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.get("/api/containers", response_class=HTMLResponse)
|
|
81
|
+
async def api_containers() -> str:
|
|
82
|
+
proc = _run_clonebox(["container", "ps", "--json", "-a"])
|
|
83
|
+
if proc.returncode != 0:
|
|
84
|
+
return f"<pre>clonebox container ps failed:\n{proc.stderr}</pre>"
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
items: List[dict[str, Any]] = json.loads(proc.stdout or "[]")
|
|
88
|
+
except json.JSONDecodeError:
|
|
89
|
+
return f"<pre>Invalid JSON from clonebox container ps:\n{proc.stdout}</pre>"
|
|
90
|
+
|
|
91
|
+
if not items:
|
|
92
|
+
return "<h2>Containers</h2><p><em>No containers found.</em></p>"
|
|
93
|
+
|
|
94
|
+
rows = [
|
|
95
|
+
[
|
|
96
|
+
str(i.get("name", "")),
|
|
97
|
+
str(i.get("image", "")),
|
|
98
|
+
str(i.get("status", "")),
|
|
99
|
+
str(i.get("ports", "")),
|
|
100
|
+
]
|
|
101
|
+
for i in items
|
|
102
|
+
]
|
|
103
|
+
return _render_table("Containers", ["Name", "Image", "Status", "Ports"], rows)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@app.get("/api/vms.json")
|
|
107
|
+
async def api_vms_json() -> JSONResponse:
|
|
108
|
+
proc = _run_clonebox(["list", "--json"])
|
|
109
|
+
if proc.returncode != 0:
|
|
110
|
+
return JSONResponse({"error": proc.stderr, "stdout": proc.stdout}, status_code=500)
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
return JSONResponse(json.loads(proc.stdout or "[]"))
|
|
114
|
+
except json.JSONDecodeError:
|
|
115
|
+
return JSONResponse({"error": "invalid_json", "stdout": proc.stdout}, status_code=500)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@app.get("/api/containers.json")
|
|
119
|
+
async def api_containers_json() -> JSONResponse:
|
|
120
|
+
proc = _run_clonebox(["container", "ps", "--json", "-a"])
|
|
121
|
+
if proc.returncode != 0:
|
|
122
|
+
return JSONResponse({"error": proc.stderr, "stdout": proc.stdout}, status_code=500)
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
return JSONResponse(json.loads(proc.stdout or "[]"))
|
|
126
|
+
except json.JSONDecodeError:
|
|
127
|
+
return JSONResponse({"error": "invalid_json", "stdout": proc.stdout}, status_code=500)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def run_dashboard(port: int = 8080) -> None:
|
|
131
|
+
import uvicorn
|
|
132
|
+
|
|
133
|
+
uvicorn.run(app, host="127.0.0.1", port=port)
|
clonebox/detector.py
CHANGED
|
@@ -275,12 +275,24 @@ class SystemDetector:
|
|
|
275
275
|
# Browsers - profiles, extensions, bookmarks, passwords
|
|
276
276
|
"chrome": [".config/google-chrome", ".config/chromium"],
|
|
277
277
|
"chromium": [".config/chromium"],
|
|
278
|
-
"firefox": [
|
|
278
|
+
"firefox": [
|
|
279
|
+
"snap/firefox/common/.mozilla/firefox",
|
|
280
|
+
"snap/firefox/common/.cache/mozilla/firefox",
|
|
281
|
+
".mozilla/firefox",
|
|
282
|
+
".cache/mozilla/firefox",
|
|
283
|
+
],
|
|
279
284
|
|
|
280
285
|
# IDEs and editors - settings, extensions, projects history
|
|
281
286
|
"code": [".config/Code", ".vscode", ".vscode-server"],
|
|
282
287
|
"vscode": [".config/Code", ".vscode", ".vscode-server"],
|
|
283
|
-
"pycharm": [
|
|
288
|
+
"pycharm": [
|
|
289
|
+
"snap/pycharm-community/common/.config/JetBrains",
|
|
290
|
+
"snap/pycharm-community/common/.local/share/JetBrains",
|
|
291
|
+
"snap/pycharm-community/common/.cache/JetBrains",
|
|
292
|
+
".config/JetBrains",
|
|
293
|
+
".local/share/JetBrains",
|
|
294
|
+
".cache/JetBrains",
|
|
295
|
+
],
|
|
284
296
|
"idea": [".config/JetBrains", ".local/share/JetBrains"],
|
|
285
297
|
"webstorm": [".config/JetBrains", ".local/share/JetBrains"],
|
|
286
298
|
"goland": [".config/JetBrains", ".local/share/JetBrains"],
|
|
@@ -354,27 +366,40 @@ class SystemDetector:
|
|
|
354
366
|
app_data_paths = []
|
|
355
367
|
seen_paths = set()
|
|
356
368
|
|
|
369
|
+
matched_patterns = set()
|
|
370
|
+
|
|
357
371
|
for app in applications:
|
|
358
372
|
app_name = app.name.lower()
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
for pattern, dirs in self.APP_DATA_DIRS.items():
|
|
373
|
+
|
|
374
|
+
for pattern in self.APP_DATA_DIRS:
|
|
362
375
|
if pattern in app_name:
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
376
|
+
matched_patterns.add(pattern)
|
|
377
|
+
|
|
378
|
+
for pattern in ("firefox", "chrome", "chromium", "pycharm"):
|
|
379
|
+
matched_patterns.add(pattern)
|
|
380
|
+
|
|
381
|
+
for pattern in sorted(matched_patterns):
|
|
382
|
+
dirs = self.APP_DATA_DIRS.get(pattern, [])
|
|
383
|
+
if not dirs:
|
|
384
|
+
continue
|
|
385
|
+
|
|
386
|
+
snap_dirs = [d for d in dirs if d.startswith("snap/")]
|
|
387
|
+
preferred_dirs = snap_dirs if any((self.home / d).exists() for d in snap_dirs) else dirs
|
|
388
|
+
|
|
389
|
+
for dir_name in preferred_dirs:
|
|
390
|
+
full_path = self.home / dir_name
|
|
391
|
+
if full_path.exists() and str(full_path) not in seen_paths:
|
|
392
|
+
seen_paths.add(str(full_path))
|
|
393
|
+
try:
|
|
394
|
+
size = self._get_dir_size(full_path, max_depth=2)
|
|
395
|
+
except Exception:
|
|
396
|
+
size = 0
|
|
397
|
+
app_data_paths.append({
|
|
398
|
+
"path": str(full_path),
|
|
399
|
+
"app": pattern,
|
|
400
|
+
"type": "app_data",
|
|
401
|
+
"size_mb": round(size / 1024 / 1024, 1),
|
|
402
|
+
})
|
|
378
403
|
|
|
379
404
|
return app_data_paths
|
|
380
405
|
|
clonebox/models.py
CHANGED
|
@@ -16,7 +16,7 @@ class VMSettings(BaseModel):
|
|
|
16
16
|
name: str = Field(default="clonebox-vm", description="VM name")
|
|
17
17
|
ram_mb: int = Field(default=4096, ge=512, le=131072, description="RAM in MB")
|
|
18
18
|
vcpus: int = Field(default=4, ge=1, le=128, description="Number of vCPUs")
|
|
19
|
-
disk_size_gb: int = Field(default=
|
|
19
|
+
disk_size_gb: int = Field(default=20, ge=1, le=2048, description="Disk size in GB")
|
|
20
20
|
gui: bool = Field(default=True, description="Enable SPICE graphics")
|
|
21
21
|
base_image: Optional[str] = Field(default=None, description="Path to base qcow2 image")
|
|
22
22
|
network_mode: str = Field(default="auto", description="Network mode: auto|default|user")
|
|
@@ -107,6 +107,10 @@ class CloneBoxConfig(BaseModel):
|
|
|
107
107
|
"""Convert to legacy VMConfig dataclass for compatibility."""
|
|
108
108
|
from clonebox.cloner import VMConfig as VMConfigDataclass
|
|
109
109
|
|
|
110
|
+
# Merge paths and app_data_paths
|
|
111
|
+
all_paths = dict(self.paths)
|
|
112
|
+
all_paths.update(self.app_data_paths)
|
|
113
|
+
|
|
110
114
|
return VMConfigDataclass(
|
|
111
115
|
name=self.vm.name,
|
|
112
116
|
ram_mb=self.vm.ram_mb,
|
|
@@ -114,7 +118,7 @@ class CloneBoxConfig(BaseModel):
|
|
|
114
118
|
disk_size_gb=self.vm.disk_size_gb,
|
|
115
119
|
gui=self.vm.gui,
|
|
116
120
|
base_image=self.vm.base_image,
|
|
117
|
-
paths=
|
|
121
|
+
paths=all_paths,
|
|
118
122
|
packages=self.packages,
|
|
119
123
|
snap_packages=self.snap_packages,
|
|
120
124
|
services=self.services,
|
clonebox/profiles.py
CHANGED
|
@@ -7,11 +7,17 @@ import yaml
|
|
|
7
7
|
|
|
8
8
|
def load_profile(profile_name: str, search_paths: list[Path]) -> Optional[Dict[str, Any]]:
|
|
9
9
|
"""Load profile YAML from ~/.clonebox.d/, .clonebox.d/, templates/profiles/"""
|
|
10
|
+
search_paths = search_paths or []
|
|
11
|
+
|
|
10
12
|
profile_paths = [
|
|
11
13
|
Path.home() / ".clonebox.d" / f"{profile_name}.yaml",
|
|
12
14
|
Path.cwd() / ".clonebox.d" / f"{profile_name}.yaml",
|
|
13
15
|
]
|
|
14
16
|
|
|
17
|
+
for base in search_paths:
|
|
18
|
+
profile_paths.insert(0, base / "templates" / "profiles" / f"{profile_name}.yaml")
|
|
19
|
+
profile_paths.insert(0, base / f"{profile_name}.yaml")
|
|
20
|
+
|
|
15
21
|
for profile_path in profile_paths:
|
|
16
22
|
if profile_path.exists():
|
|
17
23
|
return yaml.safe_load(profile_path.read_text())
|
|
@@ -37,13 +43,24 @@ def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any
|
|
|
37
43
|
return merged
|
|
38
44
|
|
|
39
45
|
|
|
40
|
-
def merge_with_profile(
|
|
46
|
+
def merge_with_profile(
|
|
47
|
+
base_config: Dict[str, Any],
|
|
48
|
+
profile_name: Optional[str] = None,
|
|
49
|
+
*,
|
|
50
|
+
profile: Optional[Dict[str, Any]] = None,
|
|
51
|
+
search_paths: Optional[list[Path]] = None,
|
|
52
|
+
) -> Dict[str, Any]:
|
|
41
53
|
"""Merge profile OVER base config (profile wins)."""
|
|
54
|
+
if profile is not None:
|
|
55
|
+
if not isinstance(profile, dict):
|
|
56
|
+
return base_config
|
|
57
|
+
return _deep_merge(base_config, profile)
|
|
58
|
+
|
|
42
59
|
if not profile_name:
|
|
43
60
|
return base_config
|
|
44
61
|
|
|
45
|
-
|
|
46
|
-
if not
|
|
62
|
+
loaded = load_profile(profile_name, search_paths or [])
|
|
63
|
+
if not loaded or not isinstance(loaded, dict):
|
|
47
64
|
return base_config
|
|
48
65
|
|
|
49
|
-
return _deep_merge(base_config,
|
|
66
|
+
return _deep_merge(base_config, loaded)
|
clonebox/validator.py
CHANGED
|
@@ -24,6 +24,7 @@ class VMValidator:
|
|
|
24
24
|
"packages": {"passed": 0, "failed": 0, "total": 0, "details": []},
|
|
25
25
|
"snap_packages": {"passed": 0, "failed": 0, "total": 0, "details": []},
|
|
26
26
|
"services": {"passed": 0, "failed": 0, "total": 0, "details": []},
|
|
27
|
+
"apps": {"passed": 0, "failed": 0, "total": 0, "details": []},
|
|
27
28
|
"overall": "unknown"
|
|
28
29
|
}
|
|
29
30
|
|
|
@@ -254,68 +255,155 @@ class VMValidator:
|
|
|
254
255
|
if not services:
|
|
255
256
|
self.console.print("[dim]No services configured[/]")
|
|
256
257
|
return self.results["services"]
|
|
257
|
-
|
|
258
|
-
# Initialize skipped counter
|
|
258
|
+
|
|
259
259
|
if "skipped" not in self.results["services"]:
|
|
260
260
|
self.results["services"]["skipped"] = 0
|
|
261
|
-
|
|
261
|
+
|
|
262
262
|
svc_table = Table(title="Service Validation", border_style="cyan")
|
|
263
263
|
svc_table.add_column("Service", style="bold")
|
|
264
264
|
svc_table.add_column("Enabled", justify="center")
|
|
265
265
|
svc_table.add_column("Running", justify="center")
|
|
266
266
|
svc_table.add_column("Note", style="dim")
|
|
267
|
-
|
|
267
|
+
|
|
268
268
|
for service in services:
|
|
269
|
-
# Check if service should be skipped (host-specific)
|
|
270
269
|
if service in self.VM_EXCLUDED_SERVICES:
|
|
271
270
|
svc_table.add_row(service, "[dim]—[/]", "[dim]—[/]", "host-only")
|
|
272
271
|
self.results["services"]["skipped"] += 1
|
|
273
|
-
self.results["services"]["details"].append(
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
272
|
+
self.results["services"]["details"].append(
|
|
273
|
+
{
|
|
274
|
+
"service": service,
|
|
275
|
+
"enabled": None,
|
|
276
|
+
"running": None,
|
|
277
|
+
"skipped": True,
|
|
278
|
+
"reason": "host-specific service",
|
|
279
|
+
}
|
|
280
|
+
)
|
|
280
281
|
continue
|
|
281
|
-
|
|
282
|
+
|
|
282
283
|
self.results["services"]["total"] += 1
|
|
283
|
-
|
|
284
|
-
# Check if enabled
|
|
284
|
+
|
|
285
285
|
enabled_cmd = f"systemctl is-enabled {service} 2>/dev/null"
|
|
286
286
|
enabled_status = self._exec_in_vm(enabled_cmd)
|
|
287
287
|
is_enabled = enabled_status == "enabled"
|
|
288
|
-
|
|
289
|
-
# Check if running
|
|
288
|
+
|
|
290
289
|
running_cmd = f"systemctl is-active {service} 2>/dev/null"
|
|
291
290
|
running_status = self._exec_in_vm(running_cmd)
|
|
292
291
|
is_running = running_status == "active"
|
|
293
|
-
|
|
292
|
+
|
|
294
293
|
enabled_icon = "[green]✅[/]" if is_enabled else "[yellow]⚠️[/]"
|
|
295
294
|
running_icon = "[green]✅[/]" if is_running else "[red]❌[/]"
|
|
296
|
-
|
|
295
|
+
|
|
297
296
|
svc_table.add_row(service, enabled_icon, running_icon, "")
|
|
298
|
-
|
|
297
|
+
|
|
299
298
|
if is_enabled and is_running:
|
|
300
299
|
self.results["services"]["passed"] += 1
|
|
301
300
|
else:
|
|
302
301
|
self.results["services"]["failed"] += 1
|
|
303
|
-
|
|
304
|
-
self.results["services"]["details"].append(
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
302
|
+
|
|
303
|
+
self.results["services"]["details"].append(
|
|
304
|
+
{
|
|
305
|
+
"service": service,
|
|
306
|
+
"enabled": is_enabled,
|
|
307
|
+
"running": is_running,
|
|
308
|
+
"skipped": False,
|
|
309
|
+
}
|
|
310
|
+
)
|
|
311
|
+
|
|
311
312
|
self.console.print(svc_table)
|
|
312
313
|
skipped = self.results["services"].get("skipped", 0)
|
|
313
314
|
msg = f"{self.results['services']['passed']}/{self.results['services']['total']} services active"
|
|
314
315
|
if skipped > 0:
|
|
315
316
|
msg += f" ({skipped} host-only skipped)"
|
|
316
317
|
self.console.print(f"[dim]{msg}[/]")
|
|
317
|
-
|
|
318
|
+
|
|
318
319
|
return self.results["services"]
|
|
320
|
+
|
|
321
|
+
def validate_apps(self) -> Dict:
|
|
322
|
+
packages = self.config.get("packages", [])
|
|
323
|
+
snap_packages = self.config.get("snap_packages", [])
|
|
324
|
+
app_data_paths = self.config.get("app_data_paths", {})
|
|
325
|
+
|
|
326
|
+
expected = []
|
|
327
|
+
|
|
328
|
+
if "firefox" in packages:
|
|
329
|
+
expected.append("firefox")
|
|
330
|
+
if "pycharm-community" in snap_packages:
|
|
331
|
+
expected.append("pycharm-community")
|
|
332
|
+
|
|
333
|
+
for _, guest_path in app_data_paths.items():
|
|
334
|
+
if guest_path == "/home/ubuntu/.config/google-chrome":
|
|
335
|
+
expected.append("google-chrome")
|
|
336
|
+
break
|
|
337
|
+
|
|
338
|
+
expected = sorted(set(expected))
|
|
339
|
+
if not expected:
|
|
340
|
+
return self.results["apps"]
|
|
341
|
+
|
|
342
|
+
self.console.print("\n[bold]🧩 Validating Apps...[/]")
|
|
343
|
+
table = Table(title="App Validation", border_style="cyan")
|
|
344
|
+
table.add_column("App", style="bold")
|
|
345
|
+
table.add_column("Installed", justify="center")
|
|
346
|
+
table.add_column("Profile", justify="center")
|
|
347
|
+
|
|
348
|
+
def _check_dir_nonempty(path: str) -> bool:
|
|
349
|
+
out = self._exec_in_vm(
|
|
350
|
+
f"test -d {path} && [ $(ls -A {path} 2>/dev/null | wc -l) -gt 0 ] && echo yes || echo no",
|
|
351
|
+
timeout=10,
|
|
352
|
+
)
|
|
353
|
+
return out == "yes"
|
|
354
|
+
|
|
355
|
+
for app in expected:
|
|
356
|
+
self.results["apps"]["total"] += 1
|
|
357
|
+
installed = False
|
|
358
|
+
profile_ok = False
|
|
359
|
+
|
|
360
|
+
if app == "firefox":
|
|
361
|
+
installed = (
|
|
362
|
+
self._exec_in_vm("command -v firefox >/dev/null 2>&1 && echo yes || echo no")
|
|
363
|
+
== "yes"
|
|
364
|
+
)
|
|
365
|
+
if _check_dir_nonempty("/home/ubuntu/snap/firefox/common/.mozilla/firefox"):
|
|
366
|
+
profile_ok = True
|
|
367
|
+
elif _check_dir_nonempty("/home/ubuntu/.mozilla/firefox"):
|
|
368
|
+
profile_ok = True
|
|
369
|
+
|
|
370
|
+
elif app == "pycharm-community":
|
|
371
|
+
installed = (
|
|
372
|
+
self._exec_in_vm(
|
|
373
|
+
"snap list pycharm-community >/dev/null 2>&1 && echo yes || echo no"
|
|
374
|
+
)
|
|
375
|
+
== "yes"
|
|
376
|
+
)
|
|
377
|
+
profile_ok = _check_dir_nonempty(
|
|
378
|
+
"/home/ubuntu/snap/pycharm-community/common/.config/JetBrains"
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
elif app == "google-chrome":
|
|
382
|
+
installed = (
|
|
383
|
+
self._exec_in_vm(
|
|
384
|
+
"(command -v google-chrome >/dev/null 2>&1 || command -v google-chrome-stable >/dev/null 2>&1) && echo yes || echo no"
|
|
385
|
+
)
|
|
386
|
+
== "yes"
|
|
387
|
+
)
|
|
388
|
+
profile_ok = _check_dir_nonempty("/home/ubuntu/.config/google-chrome")
|
|
389
|
+
|
|
390
|
+
table.add_row(
|
|
391
|
+
app,
|
|
392
|
+
"[green]✅[/]" if installed else "[red]❌[/]",
|
|
393
|
+
"[green]✅[/]" if profile_ok else "[red]❌[/]",
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
if installed and profile_ok:
|
|
397
|
+
self.results["apps"]["passed"] += 1
|
|
398
|
+
else:
|
|
399
|
+
self.results["apps"]["failed"] += 1
|
|
400
|
+
|
|
401
|
+
self.results["apps"]["details"].append(
|
|
402
|
+
{"app": app, "installed": installed, "profile": profile_ok}
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
self.console.print(table)
|
|
406
|
+
return self.results["apps"]
|
|
319
407
|
|
|
320
408
|
def validate_all(self) -> Dict:
|
|
321
409
|
"""Run all validations and return comprehensive results."""
|
|
@@ -344,27 +432,31 @@ class VMValidator:
|
|
|
344
432
|
self.validate_packages()
|
|
345
433
|
self.validate_snap_packages()
|
|
346
434
|
self.validate_services()
|
|
435
|
+
self.validate_apps()
|
|
347
436
|
|
|
348
437
|
# Calculate overall status
|
|
349
438
|
total_checks = (
|
|
350
439
|
self.results["mounts"]["total"] +
|
|
351
440
|
self.results["packages"]["total"] +
|
|
352
441
|
self.results["snap_packages"]["total"] +
|
|
353
|
-
self.results["services"]["total"]
|
|
442
|
+
self.results["services"]["total"] +
|
|
443
|
+
self.results["apps"]["total"]
|
|
354
444
|
)
|
|
355
445
|
|
|
356
446
|
total_passed = (
|
|
357
447
|
self.results["mounts"]["passed"] +
|
|
358
448
|
self.results["packages"]["passed"] +
|
|
359
449
|
self.results["snap_packages"]["passed"] +
|
|
360
|
-
self.results["services"]["passed"]
|
|
450
|
+
self.results["services"]["passed"] +
|
|
451
|
+
self.results["apps"]["passed"]
|
|
361
452
|
)
|
|
362
453
|
|
|
363
454
|
total_failed = (
|
|
364
455
|
self.results["mounts"]["failed"] +
|
|
365
456
|
self.results["packages"]["failed"] +
|
|
366
457
|
self.results["snap_packages"]["failed"] +
|
|
367
|
-
self.results["services"]["failed"]
|
|
458
|
+
self.results["services"]["failed"] +
|
|
459
|
+
self.results["apps"]["failed"]
|
|
368
460
|
)
|
|
369
461
|
|
|
370
462
|
# Get skipped services count
|
|
@@ -392,6 +484,9 @@ class VMValidator:
|
|
|
392
484
|
str(self.results["services"]["failed"]),
|
|
393
485
|
str(skipped_services),
|
|
394
486
|
str(self.results["services"]["total"]))
|
|
487
|
+
summary_table.add_row("Apps", str(self.results["apps"]["passed"]),
|
|
488
|
+
str(self.results["apps"]["failed"]), "—",
|
|
489
|
+
str(self.results["apps"]["total"]))
|
|
395
490
|
summary_table.add_row("[bold]TOTAL", f"[bold green]{total_passed}",
|
|
396
491
|
f"[bold red]{total_failed}",
|
|
397
492
|
f"[dim]{skipped_services}[/]",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: clonebox
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.21
|
|
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
|
|
@@ -85,6 +85,22 @@ CloneBox lets you create isolated virtual machines with only the applications, d
|
|
|
85
85
|
- 🧪 **Configuration testing** - Validate VM settings and functionality
|
|
86
86
|
- 📁 **App data sync** - Include browser profiles, IDE settings, and app configs
|
|
87
87
|
|
|
88
|
+
## Use Cases
|
|
89
|
+
|
|
90
|
+
CloneBox excels in scenarios where developers need:
|
|
91
|
+
- Isolated sandbox environments for testing AI agents, edge computing simulations, or integration workflows without risking host system stability
|
|
92
|
+
- Reproducible development setups that can be quickly spun up with identical configurations across different machines
|
|
93
|
+
- Safe experimentation with system-level changes that can be discarded by simply deleting the VM
|
|
94
|
+
- Quick onboarding for new team members who need a fully configured development environment
|
|
95
|
+
|
|
96
|
+
## What's Next
|
|
97
|
+
|
|
98
|
+
Project roadmap includes:
|
|
99
|
+
- Container runtime integration (Podman/Docker lightweight mode)
|
|
100
|
+
- Local dashboard for VM and container management
|
|
101
|
+
- Profile system for reusable configuration presets
|
|
102
|
+
- Proxmox export capabilities for production migration
|
|
103
|
+
|
|
88
104
|
|
|
89
105
|
|
|
90
106
|
|
|
@@ -95,6 +111,8 @@ Kluczowe komendy:
|
|
|
95
111
|
- `clonebox` – interaktywny wizard (detect + create + start)
|
|
96
112
|
- `clonebox detect` – skanuje usługi/apps/ścieżki
|
|
97
113
|
- `clonebox clone . --user --run` – szybki klon bieżącego katalogu z użytkownikiem i autostartem
|
|
114
|
+
- `clonebox container up|ps|stop|rm` – lekki runtime kontenerowy (podman/docker)
|
|
115
|
+
- `clonebox dashboard` – lokalny dashboard (VM + containers)
|
|
98
116
|
|
|
99
117
|
### Dlaczego wirtualne klony workstation mają sens?
|
|
100
118
|
|
|
@@ -167,6 +185,11 @@ pip install -e .
|
|
|
167
185
|
# Or directly
|
|
168
186
|
pip install clonebox
|
|
169
187
|
```
|
|
188
|
+
|
|
189
|
+
Dashboard ma opcjonalne zależności:
|
|
190
|
+
```bash
|
|
191
|
+
pip install "clonebox[dashboard]"
|
|
192
|
+
```
|
|
170
193
|
lub
|
|
171
194
|
```bash
|
|
172
195
|
# Aktywuj venv
|
|
@@ -247,7 +270,34 @@ Simply run `clonebox` to start the interactive wizard:
|
|
|
247
270
|
|
|
248
271
|
```bash
|
|
249
272
|
clonebox
|
|
250
|
-
|
|
273
|
+
|
|
274
|
+
clonebox clone . --user --run --replace --base-image ~/ubuntu-22.04-cloud.qcow2 --disk-size-gb 30
|
|
275
|
+
|
|
276
|
+
clonebox test . --user --validate
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### Profiles (Reusable presets)
|
|
280
|
+
|
|
281
|
+
Profiles pozwalają trzymać gotowe presety dla VM/container (np. `ml-dev`, `web-dev`) i nakładać je na bazową konfigurację.
|
|
282
|
+
|
|
283
|
+
```bash
|
|
284
|
+
# Przykład: uruchom kontener z profilem
|
|
285
|
+
clonebox container up . --profile ml-dev --engine podman
|
|
286
|
+
|
|
287
|
+
# Przykład: generuj VM config z profilem
|
|
288
|
+
clonebox clone . --profile ml-dev --user --run
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Domyślne lokalizacje profili:
|
|
292
|
+
- `~/.clonebox.d/<name>.yaml`
|
|
293
|
+
- `./.clonebox.d/<name>.yaml`
|
|
294
|
+
- wbudowane: `src/clonebox/templates/profiles/<name>.yaml`
|
|
295
|
+
|
|
296
|
+
### Dashboard
|
|
297
|
+
|
|
298
|
+
```bash
|
|
299
|
+
clonebox dashboard --port 8080
|
|
300
|
+
# http://127.0.0.1:8080
|
|
251
301
|
```
|
|
252
302
|
|
|
253
303
|
The wizard will:
|
|
@@ -268,7 +318,10 @@ clonebox create --name my-dev-vm --config '{
|
|
|
268
318
|
},
|
|
269
319
|
"packages": ["python3", "nodejs", "docker.io"],
|
|
270
320
|
"services": ["docker"]
|
|
271
|
-
}' --ram 4096 --vcpus 4 --start
|
|
321
|
+
}' --ram 4096 --vcpus 4 --disk-size-gb 20 --start
|
|
322
|
+
|
|
323
|
+
# Create VM with larger root disk
|
|
324
|
+
clonebox create --name my-dev-vm --disk-size-gb 30 --config '{"paths": {}, "packages": [], "services": []}'
|
|
272
325
|
|
|
273
326
|
# List VMs
|
|
274
327
|
clonebox list
|
|
@@ -325,10 +378,45 @@ clonebox clone . --user --run
|
|
|
325
378
|
|
|
326
379
|
# Access in VM:
|
|
327
380
|
ls ~/.config/google-chrome # Chrome profile
|
|
328
|
-
|
|
329
|
-
|
|
381
|
+
|
|
382
|
+
# Firefox profile (Ubuntu często używa snap):
|
|
383
|
+
ls ~/snap/firefox/common/.mozilla/firefox
|
|
384
|
+
ls ~/.mozilla/firefox
|
|
385
|
+
|
|
386
|
+
# PyCharm profile (snap):
|
|
387
|
+
ls ~/snap/pycharm-community/common/.config/JetBrains
|
|
388
|
+
ls ~/.config/JetBrains
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
### Container workflow (podman/docker)
|
|
392
|
+
|
|
393
|
+
```bash
|
|
394
|
+
# Start a dev container (auto-detect engine if not specified)
|
|
395
|
+
clonebox container up . --engine podman --detach
|
|
396
|
+
|
|
397
|
+
# List running containers
|
|
398
|
+
clonebox container ps
|
|
399
|
+
|
|
400
|
+
# Stop/remove
|
|
401
|
+
clonebox container stop <name>
|
|
402
|
+
clonebox container rm <name>
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
### Full validation (VM)
|
|
406
|
+
|
|
407
|
+
`clonebox test` weryfikuje, że VM faktycznie ma zamontowane ścieżki i spełnia wymagania z `.clonebox.yaml`.
|
|
408
|
+
|
|
409
|
+
```bash
|
|
410
|
+
clonebox test . --user --validate
|
|
330
411
|
```
|
|
331
412
|
|
|
413
|
+
Walidowane kategorie:
|
|
414
|
+
- **Mounts** (9p)
|
|
415
|
+
- **Packages** (apt)
|
|
416
|
+
- **Snap packages**
|
|
417
|
+
- **Services** (enabled + running)
|
|
418
|
+
- **Apps** (instalacja + dostępność profilu: Firefox/PyCharm/Chrome)
|
|
419
|
+
|
|
332
420
|
### Testing and Validating VM Configuration
|
|
333
421
|
|
|
334
422
|
```bash
|
|
@@ -557,6 +645,9 @@ The fastest way to clone your current working directory:
|
|
|
557
645
|
# Base OS image is automatically downloaded to ~/Downloads on first run
|
|
558
646
|
clonebox clone .
|
|
559
647
|
|
|
648
|
+
# Increase VM disk size (recommended for GUI + large tooling)
|
|
649
|
+
clonebox clone . --user --disk-size-gb 30
|
|
650
|
+
|
|
560
651
|
# Clone specific path
|
|
561
652
|
clonebox clone ~/projects/my-app
|
|
562
653
|
|
|
@@ -687,8 +778,10 @@ clonebox clone . --network auto
|
|
|
687
778
|
| `clonebox clone . --replace` | Replace existing VM (stop, delete, recreate) |
|
|
688
779
|
| `clonebox clone . --user` | Clone in user session (no root) |
|
|
689
780
|
| `clonebox clone . --base-image <path>` | Use custom base image |
|
|
781
|
+
| `clonebox clone . --disk-size-gb <gb>` | Set root disk size in GB (generated configs default to 20GB) |
|
|
690
782
|
| `clonebox clone . --network user` | Use user-mode networking (slirp) |
|
|
691
783
|
| `clonebox clone . --network auto` | Auto-detect network mode (default) |
|
|
784
|
+
| `clonebox create --config <json> --disk-size-gb <gb>` | Create VM from JSON config with specified disk size |
|
|
692
785
|
| `clonebox start .` | Start VM from `.clonebox.yaml` in current dir |
|
|
693
786
|
| `clonebox start . --viewer` | Start VM and open GUI window |
|
|
694
787
|
| `clonebox start <name>` | Start existing VM by name |
|
|
@@ -701,6 +794,11 @@ clonebox clone . --network auto
|
|
|
701
794
|
| `clonebox detect --yaml` | Output as YAML config |
|
|
702
795
|
| `clonebox detect --yaml --dedupe` | YAML with duplicates removed |
|
|
703
796
|
| `clonebox detect --json` | Output as JSON |
|
|
797
|
+
| `clonebox container up .` | Start a dev container for given path |
|
|
798
|
+
| `clonebox container ps` | List containers |
|
|
799
|
+
| `clonebox container stop <name>` | Stop a container |
|
|
800
|
+
| `clonebox container rm <name>` | Remove a container |
|
|
801
|
+
| `clonebox dashboard` | Run local dashboard (VM + containers) |
|
|
704
802
|
| `clonebox status . --user` | Check VM health, cloud-init, IP, and mount status |
|
|
705
803
|
| `clonebox status . --user --health` | Check VM status and run full health check |
|
|
706
804
|
| `clonebox test . --user` | Test VM configuration (basic checks) |
|
|
@@ -721,6 +819,97 @@ clonebox clone . --network auto
|
|
|
721
819
|
|
|
722
820
|
## Troubleshooting
|
|
723
821
|
|
|
822
|
+
### Critical: Insufficient Disk Space
|
|
823
|
+
|
|
824
|
+
If you install a full desktop environment and large development tools (e.g. `ubuntu-desktop-minimal`, `docker.io`, large snaps like `pycharm-community`/`chromium`), you may hit low disk space warnings inside the VM.
|
|
825
|
+
|
|
826
|
+
Recommended fix:
|
|
827
|
+
- Set a larger root disk in `.clonebox.yaml`:
|
|
828
|
+
|
|
829
|
+
```yaml
|
|
830
|
+
vm:
|
|
831
|
+
disk_size_gb: 30
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
You can also set it during config generation:
|
|
835
|
+
```bash
|
|
836
|
+
clonebox clone . --user --disk-size-gb 30
|
|
837
|
+
```
|
|
838
|
+
|
|
839
|
+
Notes:
|
|
840
|
+
- New configs generated by `clonebox clone` default to `disk_size_gb: 20`.
|
|
841
|
+
- You can override this by setting `vm.disk_size_gb` in `.clonebox.yaml`.
|
|
842
|
+
|
|
843
|
+
Workaround for an existing VM (host-side resize + guest filesystem grow):
|
|
844
|
+
```bash
|
|
845
|
+
clonebox stop . --user
|
|
846
|
+
qemu-img resize ~/.local/share/libvirt/images/<vm-name>/root.qcow2 +10G
|
|
847
|
+
clonebox start . --user
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
Inside the VM:
|
|
851
|
+
```bash
|
|
852
|
+
sudo growpart /dev/vda 1
|
|
853
|
+
sudo resize2fs /dev/vda1
|
|
854
|
+
df -h /
|
|
855
|
+
```
|
|
856
|
+
|
|
857
|
+
### Known Issue: IBus Preferences crash
|
|
858
|
+
|
|
859
|
+
During validation you may occasionally see a crash dialog from **IBus Preferences** in the Ubuntu desktop environment.
|
|
860
|
+
This is an upstream issue related to the input method daemon (`ibus-daemon`) and obsolete system packages (e.g. `libglib2.0`, `libssl3`, `libxml2`, `openssl`).
|
|
861
|
+
It does **not** affect CloneBox functionality and the VM operates normally.
|
|
862
|
+
|
|
863
|
+
Workaround:
|
|
864
|
+
- Dismiss the crash dialog
|
|
865
|
+
- Or run `sudo apt upgrade` inside the VM to update system packages
|
|
866
|
+
|
|
867
|
+
### Snap Apps Not Launching (PyCharm, Chromium, Firefox)
|
|
868
|
+
|
|
869
|
+
If snap-installed applications (e.g., PyCharm, Chromium) are installed but don't launch when clicked, the issue is usually **disconnected snap interfaces**. This happens because snap interfaces are not auto-connected when installing via cloud-init.
|
|
870
|
+
|
|
871
|
+
**New VMs created with updated CloneBox automatically connect snap interfaces**, but for older VMs or manual installs:
|
|
872
|
+
|
|
873
|
+
```bash
|
|
874
|
+
# Check snap interface connections
|
|
875
|
+
snap connections pycharm-community
|
|
876
|
+
|
|
877
|
+
# If you see "-" instead of ":desktop", interfaces are NOT connected
|
|
878
|
+
|
|
879
|
+
# Connect required interfaces
|
|
880
|
+
sudo snap connect pycharm-community:desktop :desktop
|
|
881
|
+
sudo snap connect pycharm-community:desktop-legacy :desktop-legacy
|
|
882
|
+
sudo snap connect pycharm-community:x11 :x11
|
|
883
|
+
sudo snap connect pycharm-community:wayland :wayland
|
|
884
|
+
sudo snap connect pycharm-community:home :home
|
|
885
|
+
sudo snap connect pycharm-community:network :network
|
|
886
|
+
|
|
887
|
+
# Restart snap daemon and try again
|
|
888
|
+
sudo systemctl restart snapd
|
|
889
|
+
snap run pycharm-community
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
**For Chromium/Firefox:**
|
|
893
|
+
```bash
|
|
894
|
+
sudo snap connect chromium:desktop :desktop
|
|
895
|
+
sudo snap connect chromium:x11 :x11
|
|
896
|
+
sudo snap connect firefox:desktop :desktop
|
|
897
|
+
sudo snap connect firefox:x11 :x11
|
|
898
|
+
```
|
|
899
|
+
|
|
900
|
+
**Debug launch:**
|
|
901
|
+
```bash
|
|
902
|
+
PYCHARM_DEBUG=true snap run pycharm-community 2>&1 | tee /tmp/pycharm-debug.log
|
|
903
|
+
```
|
|
904
|
+
|
|
905
|
+
**Nuclear option (reinstall):**
|
|
906
|
+
```bash
|
|
907
|
+
snap remove pycharm-community
|
|
908
|
+
rm -rf ~/snap/pycharm-community
|
|
909
|
+
sudo snap install pycharm-community --classic
|
|
910
|
+
sudo snap connect pycharm-community:desktop :desktop
|
|
911
|
+
```
|
|
912
|
+
|
|
724
913
|
### Network Issues
|
|
725
914
|
|
|
726
915
|
If you encounter "Network not found" or "network 'default' is not active" errors:
|
|
@@ -1069,4 +1258,4 @@ qm set 9000 --boot c --bootdisk scsi0
|
|
|
1069
1258
|
|
|
1070
1259
|
## License
|
|
1071
1260
|
|
|
1072
|
-
|
|
1261
|
+
Apache License - see [LICENSE](LICENSE) file.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
clonebox/__init__.py,sha256=CyfHVVq6KqBr4CNERBpXk_O6Q5B35q03YpdQbokVvvI,408
|
|
2
|
+
clonebox/__main__.py,sha256=Fcoyzwwyz5-eC_sBlQk5a5RbKx8uodQz5sKJ190U0NU,135
|
|
3
|
+
clonebox/cli.py,sha256=ItXPNJmQeWbv-Bf9IxYi4e6AsX04pJwRk8fBXP0MglA,104376
|
|
4
|
+
clonebox/cloner.py,sha256=jR-pIBewc8zNE_h_sTcID1uZYKeQjNc6VInhE3aikfQ,34156
|
|
5
|
+
clonebox/container.py,sha256=tiYK1ZB-DhdD6A2FuMA0h_sRNkUI7KfYcJ0tFOcdyeM,6105
|
|
6
|
+
clonebox/dashboard.py,sha256=RhSPvR6kWglqXeLkCWesBZQid7wv2WpJa6w78mXbPjY,4268
|
|
7
|
+
clonebox/detector.py,sha256=aS_QlbG93-DE3hsjRt88E7O-PGC2TUBgUbP9wqT9g60,23221
|
|
8
|
+
clonebox/models.py,sha256=yBRUlJejpeJHZjvCYMGq1nXPFcmhLFxN-LqkEyveWsA,7913
|
|
9
|
+
clonebox/profiles.py,sha256=VaKVuxCrgyMxx-8_WOTcw7E8irwGxUPhZHVY6RxYYiE,2034
|
|
10
|
+
clonebox/validator.py,sha256=LnQSZEdJXFGcJrTPxzS2cQUmAXucGeHDKwxrX632h_s,21188
|
|
11
|
+
clonebox/templates/profiles/ml-dev.yaml,sha256=MT7Wu3xGBnYIsO5mzZ2GDI4AAEFGOroIx0eU3XjNARg,140
|
|
12
|
+
clonebox-0.1.21.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
13
|
+
clonebox-0.1.21.dist-info/METADATA,sha256=xvEQjhq84KYnf5oveMK8GRcfZXoBB54sZ9CrcRfTRpg,41534
|
|
14
|
+
clonebox-0.1.21.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
15
|
+
clonebox-0.1.21.dist-info/entry_points.txt,sha256=FES95Vi3btfViLEEoHdb8nikNxTqzaooi9ehZw9ZfWI,47
|
|
16
|
+
clonebox-0.1.21.dist-info/top_level.txt,sha256=LdMo2cvCrEcRGH2M8JgQNVsCoszLV0xug6kx1JnaRjo,9
|
|
17
|
+
clonebox-0.1.21.dist-info/RECORD,,
|
clonebox-0.1.19.dist-info/RECORD
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
clonebox/__init__.py,sha256=CyfHVVq6KqBr4CNERBpXk_O6Q5B35q03YpdQbokVvvI,408
|
|
2
|
-
clonebox/__main__.py,sha256=Fcoyzwwyz5-eC_sBlQk5a5RbKx8uodQz5sKJ190U0NU,135
|
|
3
|
-
clonebox/cli.py,sha256=WnAlgR3h0gIMwkc0uHAr44E4eS25GTBsNbKbuumrUug,99790
|
|
4
|
-
clonebox/cloner.py,sha256=OXytYy4K4sr-ytP55HZK9frt1vU5Kf5EOCIKYhpdJtQ,32516
|
|
5
|
-
clonebox/container.py,sha256=tiYK1ZB-DhdD6A2FuMA0h_sRNkUI7KfYcJ0tFOcdyeM,6105
|
|
6
|
-
clonebox/detector.py,sha256=FM8QJ6RTO0jXyUJIcAJivLwUh82xm287ayFD_VIuvgs,22521
|
|
7
|
-
clonebox/models.py,sha256=Uxz9eHov2epJpNYbl0ejaOX91iMSjqdHskGdC8-smVk,7789
|
|
8
|
-
clonebox/profiles.py,sha256=ZyuPct9Jg5m2ImnSEbHvBMp3x7Ufj2420D84rXt2T1Y,1537
|
|
9
|
-
clonebox/validator.py,sha256=qIkFMkWmpx4k2-vPkLigZ6pK5wtl748CmpuNLB6yOIM,17645
|
|
10
|
-
clonebox/templates/profiles/ml-dev.yaml,sha256=MT7Wu3xGBnYIsO5mzZ2GDI4AAEFGOroIx0eU3XjNARg,140
|
|
11
|
-
clonebox-0.1.19.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
12
|
-
clonebox-0.1.19.dist-info/METADATA,sha256=HXLOv0aaY7OlXGJKtk8LSzlbqeQwgHp9Bax29KzQLV4,35353
|
|
13
|
-
clonebox-0.1.19.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
14
|
-
clonebox-0.1.19.dist-info/entry_points.txt,sha256=FES95Vi3btfViLEEoHdb8nikNxTqzaooi9ehZw9ZfWI,47
|
|
15
|
-
clonebox-0.1.19.dist-info/top_level.txt,sha256=LdMo2cvCrEcRGH2M8JgQNVsCoszLV0xug6kx1JnaRjo,9
|
|
16
|
-
clonebox-0.1.19.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|