clonebox 0.1.18__py3-none-any.whl → 0.1.20__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/__init__.py CHANGED
@@ -5,7 +5,7 @@ Selectively clone applications, paths and services to a new virtual machine
5
5
  with bind mounts instead of full disk cloning.
6
6
  """
7
7
 
8
- __version__ = "0.1.13"
8
+ __version__ = "0.1.18"
9
9
  __author__ = "CloneBox Team"
10
10
 
11
11
  from clonebox.cloner import SelectiveVMCloner
clonebox/cli.py CHANGED
@@ -22,7 +22,10 @@ from rich.table import Table
22
22
 
23
23
  from clonebox import __version__
24
24
  from clonebox.cloner import SelectiveVMCloner, VMConfig
25
+ from clonebox.container import ContainerCloner
25
26
  from clonebox.detector import SystemDetector
27
+ from clonebox.models import ContainerConfig
28
+ from clonebox.profiles import merge_with_profile
26
29
 
27
30
  # Custom questionary style
28
31
  custom_style = Style(
@@ -234,9 +237,22 @@ def run_vm_diagnostics(
234
237
  if domifaddr.stdout.strip():
235
238
  console.print(f"[dim]{domifaddr.stdout.strip()}[/]")
236
239
  else:
237
- console.print("[yellow]⚠️ No interface address detected yet[/]")
240
+ console.print("[yellow]⚠️ No interface address detected via virsh domifaddr[/]")
238
241
  if verbose and domifaddr.stderr.strip():
239
242
  console.print(f"[dim]{domifaddr.stderr.strip()}[/]")
243
+ # Fallback: try to get IP via QEMU Guest Agent (useful for slirp/user networking)
244
+ if guest_agent_ready:
245
+ try:
246
+ ip_out = _qga_exec(vm_name, conn_uri, "ip -4 -o addr show scope global | awk '{print $4}'", timeout=5)
247
+ if ip_out and ip_out.strip():
248
+ console.print(f"[green]IP (via QGA): {ip_out.strip()}[/]")
249
+ result["network"]["qga_ip"] = ip_out.strip()
250
+ else:
251
+ console.print("[dim]IP: not available via QGA[/]")
252
+ except Exception as e:
253
+ console.print(f"[dim]IP: QGA query failed ({e})[/]")
254
+ else:
255
+ console.print("[dim]IP: QEMU Guest Agent not connected[/]")
240
256
  except Exception as e:
241
257
  result["network"] = {"error": str(e)}
242
258
  console.print(f"[yellow]⚠️ Cannot get IP: {e}[/]")
@@ -703,7 +719,6 @@ def interactive_mode():
703
719
  console.print("\n[bold]Inside the VM, mount shared folders with:[/]")
704
720
  for idx, (host, guest) in enumerate(paths_mapping.items()):
705
721
  console.print(f" [cyan]sudo mount -t 9p -o trans=virtio mount{idx} {guest}[/]")
706
-
707
722
  except Exception as e:
708
723
  console.print(f"\n[red]❌ Error: {e}[/]")
709
724
  raise
@@ -905,15 +920,16 @@ def cmd_delete(args):
905
920
  console.print("[yellow]Cancelled.[/]")
906
921
  return
907
922
 
908
- cloner = SelectiveVMCloner(user_session=getattr(args, "user", False))
909
- cloner.delete_vm(name, delete_storage=not args.keep_storage, console=console)
910
-
911
923
 
912
924
  def cmd_list(args):
913
925
  """List all VMs."""
914
926
  cloner = SelectiveVMCloner(user_session=getattr(args, "user", False))
915
927
  vms = cloner.list_vms()
916
928
 
929
+ if getattr(args, "json", False):
930
+ print(json.dumps(vms, indent=2))
931
+ return
932
+
917
933
  if not vms:
918
934
  console.print("[dim]No VMs found.[/]")
919
935
  return
@@ -932,14 +948,6 @@ def cmd_list(args):
932
948
 
933
949
  def cmd_container_up(args):
934
950
  """Start a container sandbox."""
935
- try:
936
- from clonebox.container import ContainerCloner
937
- from clonebox.models import ContainerConfig
938
- except ModuleNotFoundError as e:
939
- raise ModuleNotFoundError(
940
- "Container features require extra dependencies (e.g. pydantic). Install them to use 'clonebox container'."
941
- ) from e
942
-
943
951
  mounts = {}
944
952
  for m in getattr(args, "mount", []) or []:
945
953
  if ":" not in m:
@@ -959,21 +967,21 @@ def cmd_container_up(args):
959
967
  if getattr(args, "name", None):
960
968
  cfg_kwargs["name"] = args.name
961
969
 
970
+ profile_name = getattr(args, "profile", None)
971
+ if profile_name:
972
+ merged = merge_with_profile({"container": cfg_kwargs}, profile_name)
973
+ if isinstance(merged, dict) and isinstance(merged.get("container"), dict):
974
+ cfg_kwargs = merged["container"]
975
+
962
976
  cfg = ContainerConfig(**cfg_kwargs)
963
977
 
964
978
  cloner = ContainerCloner(engine=cfg.engine)
965
- cloner.up(cfg, detach=getattr(args, "detach", False))
979
+ detach = getattr(args, "detach", False)
980
+ cloner.up(cfg, detach=detach, remove=not detach)
966
981
 
967
982
 
968
983
  def cmd_container_ps(args):
969
984
  """List containers."""
970
- try:
971
- from clonebox.container import ContainerCloner
972
- except ModuleNotFoundError as e:
973
- raise ModuleNotFoundError(
974
- "Container features require extra dependencies (e.g. pydantic). Install them to use 'clonebox container'."
975
- ) from e
976
-
977
985
  cloner = ContainerCloner(engine=getattr(args, "engine", "auto"))
978
986
  items = cloner.ps(all=getattr(args, "all", False))
979
987
 
@@ -1004,44 +1012,36 @@ def cmd_container_ps(args):
1004
1012
 
1005
1013
  def cmd_container_stop(args):
1006
1014
  """Stop a container."""
1007
- try:
1008
- from clonebox.container import ContainerCloner
1009
- except ModuleNotFoundError as e:
1010
- raise ModuleNotFoundError(
1011
- "Container features require extra dependencies (e.g. pydantic). Install them to use 'clonebox container'."
1012
- ) from e
1013
-
1014
1015
  cloner = ContainerCloner(engine=getattr(args, "engine", "auto"))
1015
1016
  cloner.stop(args.name)
1016
1017
 
1017
1018
 
1018
1019
  def cmd_container_rm(args):
1019
1020
  """Remove a container."""
1020
- try:
1021
- from clonebox.container import ContainerCloner
1022
- except ModuleNotFoundError as e:
1023
- raise ModuleNotFoundError(
1024
- "Container features require extra dependencies (e.g. pydantic). Install them to use 'clonebox container'."
1025
- ) from e
1026
-
1027
1021
  cloner = ContainerCloner(engine=getattr(args, "engine", "auto"))
1028
1022
  cloner.rm(args.name, force=getattr(args, "force", False))
1029
1023
 
1030
1024
 
1031
1025
  def cmd_container_down(args):
1032
1026
  """Stop and remove a container."""
1033
- try:
1034
- from clonebox.container import ContainerCloner
1035
- except ModuleNotFoundError as e:
1036
- raise ModuleNotFoundError(
1037
- "Container features require extra dependencies (e.g. pydantic). Install them to use 'clonebox container'."
1038
- ) from e
1039
-
1040
1027
  cloner = ContainerCloner(engine=getattr(args, "engine", "auto"))
1041
1028
  cloner.stop(args.name)
1042
1029
  cloner.rm(args.name, force=True)
1043
1030
 
1044
1031
 
1032
+ def cmd_dashboard(args):
1033
+ """Run the local CloneBox dashboard."""
1034
+ try:
1035
+ from clonebox.dashboard import run_dashboard
1036
+ except Exception as e:
1037
+ console.print("[red]❌ Dashboard dependencies are not installed.[/]")
1038
+ console.print("[dim]Install with: pip install 'clonebox[dashboard]'[/]")
1039
+ console.print(f"[dim]{e}[/]")
1040
+ return
1041
+
1042
+ run_dashboard(port=getattr(args, "port", 8080))
1043
+
1044
+
1045
1045
  def cmd_diagnose(args):
1046
1046
  """Run detailed VM diagnostics (standalone)."""
1047
1047
  name = args.name
@@ -1521,7 +1521,23 @@ def cmd_test(args):
1521
1521
  if '192.168' in line or '10.0' in line:
1522
1522
  console.print(f" IP: {line.split()[-1]}")
1523
1523
  else:
1524
- console.print("[yellow]⚠️ No IP address detected[/]")
1524
+ console.print("[yellow]⚠️ No IP address detected via virsh domifaddr[/]")
1525
+ # Fallback: try to get IP via QEMU Guest Agent (useful for slirp/user networking)
1526
+ try:
1527
+ from .cli import _qga_ping, _qga_exec
1528
+ except ImportError:
1529
+ from clonebox.cli import _qga_ping, _qga_exec
1530
+ if _qga_ping(vm_name, conn_uri):
1531
+ try:
1532
+ ip_out = _qga_exec(vm_name, conn_uri, "ip -4 -o addr show scope global | awk '{print $4}'", timeout=5)
1533
+ if ip_out and ip_out.strip():
1534
+ console.print(f"[green]✅ VM has network access (IP via QGA: {ip_out.strip()})[/]")
1535
+ else:
1536
+ console.print("[yellow]⚠️ IP not available via QGA[/]")
1537
+ except Exception as e:
1538
+ console.print(f"[yellow]⚠️ Could not get IP via QGA ({e})[/]")
1539
+ else:
1540
+ console.print("[dim]IP: QEMU Guest Agent not connected[/]")
1525
1541
  except:
1526
1542
  console.print("[yellow]⚠️ Could not check network[/]")
1527
1543
  else:
@@ -1582,34 +1598,13 @@ def cmd_test(args):
1582
1598
  if all_paths:
1583
1599
  for idx, (host_path, guest_path) in enumerate(all_paths.items()):
1584
1600
  try:
1585
- result = subprocess.run(
1586
- ["virsh", "--connect", conn_uri, "qemu-agent-command", vm_name,
1587
- f'{{"execute":"guest-exec","arguments":{{"path":"test","arg":["-d","{guest_path}"],"capture-output":true}}}}'],
1588
- capture_output=True, text=True, timeout=10
1589
- )
1590
- if result.returncode == 0:
1591
- try:
1592
- response = json.loads(result.stdout)
1593
- if "return" in response:
1594
- pid = response["return"]["pid"]
1595
- result2 = subprocess.run(
1596
- ["virsh", "--connect", conn_uri, "qemu-agent-command", vm_name,
1597
- f'{{"execute":"guest-exec-status","arguments":{"pid":{pid}}}}'],
1598
- capture_output=True, text=True, timeout=10
1599
- )
1600
- if result2.returncode == 0:
1601
- resp2 = json.loads(result2.stdout)
1602
- if "return" in resp2 and resp2["return"]["exited"]:
1603
- exit_code = resp2["return"]["exitcode"]
1604
- if exit_code == 0:
1605
- console.print(f"[green]✅ {guest_path}[/]")
1606
- else:
1607
- console.print(f"[red]❌ {guest_path} (not accessible)[/]")
1608
- continue
1609
- except:
1610
- pass
1611
- console.print(f"[yellow]⚠️ {guest_path} (unknown)[/]")
1612
- except:
1601
+ # Use the same QGA helper as diagnose/status
1602
+ is_accessible = _qga_exec(vm_name, conn_uri, f"test -d {guest_path} && echo yes || echo no", timeout=5)
1603
+ if is_accessible == "yes":
1604
+ console.print(f"[green]✅ {guest_path}[/]")
1605
+ else:
1606
+ console.print(f"[red]❌ {guest_path} (not accessible)[/]")
1607
+ except Exception:
1613
1608
  console.print(f"[yellow]⚠️ {guest_path} (could not check)[/]")
1614
1609
  else:
1615
1610
  console.print("[dim]No mount points configured[/]")
@@ -1717,8 +1712,18 @@ def generate_clonebox_yaml(
1717
1712
  """Generate YAML config from system snapshot."""
1718
1713
  sys_info = detector.get_system_info()
1719
1714
 
1720
- # Collect services
1721
- services = [s.name for s in snapshot.running_services]
1715
+ # Services that should NOT be cloned to VM (host-specific)
1716
+ VM_EXCLUDED_SERVICES = {
1717
+ "libvirtd", "virtlogd", "libvirt-guests", "qemu-guest-agent",
1718
+ "bluetooth", "bluez", "upower", "thermald", "tlp", "power-profiles-daemon",
1719
+ "gdm", "gdm3", "sddm", "lightdm",
1720
+ "snap.cups.cups-browsed", "snap.cups.cupsd",
1721
+ "ModemManager", "wpa_supplicant",
1722
+ "accounts-daemon", "colord", "switcheroo-control",
1723
+ }
1724
+
1725
+ # Collect services (excluding host-specific ones)
1726
+ services = [s.name for s in snapshot.running_services if s.name not in VM_EXCLUDED_SERVICES]
1722
1727
  if deduplicate:
1723
1728
  services = deduplicate_list(services)
1724
1729
 
@@ -1782,6 +1787,21 @@ def generate_clonebox_yaml(
1782
1787
  guest_path = f"/home/ubuntu/{rel_path}"
1783
1788
  app_data_mapping[host_path] = guest_path
1784
1789
 
1790
+ post_commands = []
1791
+
1792
+ chrome_profile = home_dir / ".config" / "google-chrome"
1793
+ if chrome_profile.exists():
1794
+ host_path = str(chrome_profile)
1795
+ if host_path not in paths_mapping and host_path not in app_data_mapping:
1796
+ app_data_mapping[host_path] = "/home/ubuntu/.config/google-chrome"
1797
+
1798
+ post_commands.append(
1799
+ "command -v google-chrome >/dev/null 2>&1 || ("
1800
+ "curl -fsSL -o /tmp/google-chrome.deb https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb && "
1801
+ "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y /tmp/google-chrome.deb"
1802
+ ")"
1803
+ )
1804
+
1785
1805
  # Determine VM name
1786
1806
  if not vm_name:
1787
1807
  if target_path:
@@ -1815,6 +1835,34 @@ def generate_clonebox_yaml(
1815
1835
  if deduplicate:
1816
1836
  all_snap_packages = deduplicate_list(all_snap_packages)
1817
1837
 
1838
+ if chrome_profile.exists() and "google-chrome" not in [d.get("app", "") for d in app_data_dirs]:
1839
+ if "chromium" not in all_snap_packages:
1840
+ all_snap_packages.append("chromium")
1841
+
1842
+ if "pycharm-community" in all_snap_packages:
1843
+ remapped = {}
1844
+ for host_path, guest_path in app_data_mapping.items():
1845
+ if guest_path == "/home/ubuntu/.config/JetBrains":
1846
+ remapped[host_path] = "/home/ubuntu/snap/pycharm-community/common/.config/JetBrains"
1847
+ elif guest_path == "/home/ubuntu/.local/share/JetBrains":
1848
+ remapped[host_path] = "/home/ubuntu/snap/pycharm-community/common/.local/share/JetBrains"
1849
+ elif guest_path == "/home/ubuntu/.cache/JetBrains":
1850
+ remapped[host_path] = "/home/ubuntu/snap/pycharm-community/common/.cache/JetBrains"
1851
+ else:
1852
+ remapped[host_path] = guest_path
1853
+ app_data_mapping = remapped
1854
+
1855
+ if "firefox" in all_apt_packages:
1856
+ remapped = {}
1857
+ for host_path, guest_path in app_data_mapping.items():
1858
+ if guest_path == "/home/ubuntu/.mozilla/firefox":
1859
+ remapped[host_path] = "/home/ubuntu/snap/firefox/common/.mozilla/firefox"
1860
+ elif guest_path == "/home/ubuntu/.cache/mozilla/firefox":
1861
+ remapped[host_path] = "/home/ubuntu/snap/firefox/common/.cache/mozilla/firefox"
1862
+ else:
1863
+ remapped[host_path] = guest_path
1864
+ app_data_mapping = remapped
1865
+
1818
1866
  # Build config
1819
1867
  config = {
1820
1868
  "version": "1",
@@ -1832,7 +1880,7 @@ def generate_clonebox_yaml(
1832
1880
  "services": services,
1833
1881
  "packages": all_apt_packages,
1834
1882
  "snap_packages": all_snap_packages,
1835
- "post_commands": [], # User can add custom commands to run after setup
1883
+ "post_commands": post_commands,
1836
1884
  "paths": paths_mapping,
1837
1885
  "app_data_paths": app_data_mapping, # App-specific config/data directories
1838
1886
  "detected": {
@@ -2057,6 +2105,32 @@ def cmd_clone(args):
2057
2105
  base_image=getattr(args, "base_image", None),
2058
2106
  )
2059
2107
 
2108
+ profile_name = getattr(args, "profile", None)
2109
+ if profile_name:
2110
+ merged_config = merge_with_profile(yaml.safe_load(yaml_content), profile_name)
2111
+ if isinstance(merged_config, dict):
2112
+ vm_section = merged_config.get("vm")
2113
+ if isinstance(vm_section, dict):
2114
+ vm_packages = vm_section.pop("packages", None)
2115
+ if isinstance(vm_packages, list):
2116
+ packages = merged_config.get("packages")
2117
+ if not isinstance(packages, list):
2118
+ packages = []
2119
+ for p in vm_packages:
2120
+ if p not in packages:
2121
+ packages.append(p)
2122
+ merged_config["packages"] = packages
2123
+
2124
+ if "container" in merged_config:
2125
+ merged_config.pop("container", None)
2126
+
2127
+ yaml_content = yaml.dump(
2128
+ merged_config,
2129
+ default_flow_style=False,
2130
+ allow_unicode=True,
2131
+ sort_keys=False,
2132
+ )
2133
+
2060
2134
  # Dry run - show what would be created and exit
2061
2135
  if dry_run:
2062
2136
  config = yaml.safe_load(yaml_content)
@@ -2337,6 +2411,7 @@ def main():
2337
2411
  action="store_true",
2338
2412
  help="Use user session (qemu:///session) - no root required",
2339
2413
  )
2414
+ list_parser.add_argument("--json", action="store_true", help="Output JSON")
2340
2415
  list_parser.set_defaults(func=cmd_list)
2341
2416
 
2342
2417
  # Container command
@@ -2347,13 +2422,24 @@ def main():
2347
2422
  default="auto",
2348
2423
  help="Container engine: auto (default), podman, docker",
2349
2424
  )
2425
+ container_parser.set_defaults(func=lambda args, p=container_parser: p.print_help())
2350
2426
  container_sub = container_parser.add_subparsers(dest="container_command", help="Container commands")
2351
2427
 
2352
2428
  container_up = container_sub.add_parser("up", help="Start container")
2429
+ container_up.add_argument(
2430
+ "--engine",
2431
+ choices=["auto", "podman", "docker"],
2432
+ default=argparse.SUPPRESS,
2433
+ help="Container engine: auto (default), podman, docker",
2434
+ )
2353
2435
  container_up.add_argument("path", nargs="?", default=".", help="Workspace path")
2354
2436
  container_up.add_argument("--name", help="Container name")
2355
2437
  container_up.add_argument("--image", default="ubuntu:22.04", help="Container image")
2356
2438
  container_up.add_argument("--detach", action="store_true", help="Run container in background")
2439
+ container_up.add_argument(
2440
+ "--profile",
2441
+ help="Profile name (loads ~/.clonebox.d/<name>.yaml, .clonebox.d/<name>.yaml, or built-in templates)",
2442
+ )
2357
2443
  container_up.add_argument(
2358
2444
  "--mount",
2359
2445
  action="append",
@@ -2380,23 +2466,52 @@ def main():
2380
2466
  container_up.set_defaults(func=cmd_container_up)
2381
2467
 
2382
2468
  container_ps = container_sub.add_parser("ps", aliases=["ls"], help="List containers")
2469
+ container_ps.add_argument(
2470
+ "--engine",
2471
+ choices=["auto", "podman", "docker"],
2472
+ default=argparse.SUPPRESS,
2473
+ help="Container engine: auto (default), podman, docker",
2474
+ )
2383
2475
  container_ps.add_argument("-a", "--all", action="store_true", help="Show all containers")
2384
2476
  container_ps.add_argument("--json", action="store_true", help="Output JSON")
2385
2477
  container_ps.set_defaults(func=cmd_container_ps)
2386
2478
 
2387
2479
  container_stop = container_sub.add_parser("stop", help="Stop container")
2480
+ container_stop.add_argument(
2481
+ "--engine",
2482
+ choices=["auto", "podman", "docker"],
2483
+ default=argparse.SUPPRESS,
2484
+ help="Container engine: auto (default), podman, docker",
2485
+ )
2388
2486
  container_stop.add_argument("name", help="Container name")
2389
2487
  container_stop.set_defaults(func=cmd_container_stop)
2390
2488
 
2391
2489
  container_rm = container_sub.add_parser("rm", help="Remove container")
2490
+ container_rm.add_argument(
2491
+ "--engine",
2492
+ choices=["auto", "podman", "docker"],
2493
+ default=argparse.SUPPRESS,
2494
+ help="Container engine: auto (default), podman, docker",
2495
+ )
2392
2496
  container_rm.add_argument("name", help="Container name")
2393
2497
  container_rm.add_argument("-f", "--force", action="store_true", help="Force remove")
2394
2498
  container_rm.set_defaults(func=cmd_container_rm)
2395
2499
 
2396
2500
  container_down = container_sub.add_parser("down", help="Stop and remove container")
2501
+ container_down.add_argument(
2502
+ "--engine",
2503
+ choices=["auto", "podman", "docker"],
2504
+ default=argparse.SUPPRESS,
2505
+ help="Container engine: auto (default), podman, docker",
2506
+ )
2397
2507
  container_down.add_argument("name", help="Container name")
2398
2508
  container_down.set_defaults(func=cmd_container_down)
2399
2509
 
2510
+ # Dashboard command
2511
+ dashboard_parser = subparsers.add_parser("dashboard", help="Run local dashboard")
2512
+ dashboard_parser.add_argument("--port", type=int, default=8080, help="Port to bind (default: 8080)")
2513
+ dashboard_parser.set_defaults(func=cmd_dashboard)
2514
+
2400
2515
  # Detect command
2401
2516
  detect_parser = subparsers.add_parser("detect", help="Detect system state")
2402
2517
  detect_parser.add_argument("--json", action="store_true", help="Output as JSON")
@@ -2436,6 +2551,10 @@ def main():
2436
2551
  "--base-image",
2437
2552
  help="Path to a bootable qcow2 image to use as a base disk",
2438
2553
  )
2554
+ clone_parser.add_argument(
2555
+ "--profile",
2556
+ help="Profile name (loads ~/.clonebox.d/<name>.yaml, .clonebox.d/<name>.yaml, or built-in templates)",
2557
+ )
2439
2558
  clone_parser.add_argument(
2440
2559
  "--replace",
2441
2560
  action="store_true",
@@ -2462,6 +2581,9 @@ def main():
2462
2581
  status_parser.add_argument(
2463
2582
  "--health", "-H", action="store_true", help="Run full health check"
2464
2583
  )
2584
+ status_parser.add_argument(
2585
+ "--verbose", "-v", action="store_true", help="Show detailed diagnostics (QGA, stderr, etc.)"
2586
+ )
2465
2587
  status_parser.set_defaults(func=cmd_status)
2466
2588
 
2467
2589
  # Diagnose command - detailed diagnostics from workstation
clonebox/cloner.py CHANGED
@@ -663,7 +663,8 @@ fi
663
663
  # Generate mount commands and fstab entries for 9p filesystems
664
664
  mount_commands = []
665
665
  fstab_entries = []
666
- for idx, (host_path, guest_path) in enumerate(config.paths.items()):
666
+ all_paths = dict(config.paths) if config.paths else {}
667
+ for idx, (host_path, guest_path) in enumerate(all_paths.items()):
667
668
  if Path(host_path).exists():
668
669
  tag = f"mount{idx}"
669
670
  # Use uid=1000,gid=1000 to give ubuntu user access to mounts
@@ -695,6 +696,8 @@ fi
695
696
  runcmd_lines = []
696
697
 
697
698
  runcmd_lines.append(" - systemctl enable --now qemu-guest-agent || true")
699
+ runcmd_lines.append(" - systemctl enable --now snapd || true")
700
+ runcmd_lines.append(" - timeout 300 snap wait system seed.loaded || true")
698
701
 
699
702
  # Add service enablement
700
703
  for svc in config.services:
@@ -759,7 +762,7 @@ users:
759
762
  sudo: ALL=(ALL) NOPASSWD:ALL
760
763
  shell: /bin/bash
761
764
  lock_passwd: false
762
- groups: sudo,adm,dialout,cdrom,floppy,audio,dip,video,plugdev,netdev
765
+ groups: sudo,adm,dialout,cdrom,floppy,audio,dip,video,plugdev,netdev,docker
763
766
  plain_text_passwd: {config.password}
764
767
 
765
768
  # Allow password authentication
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
@@ -64,6 +64,37 @@ class SystemSnapshot:
64
64
  class SystemDetector:
65
65
  """Detects running services, applications and important paths on the system."""
66
66
 
67
+ # Services that should NOT be cloned to VM (host-specific, hardware-dependent, or hypervisor services)
68
+ VM_EXCLUDED_SERVICES = {
69
+ # Hypervisor/virtualization - no nested virt needed
70
+ "libvirtd",
71
+ "virtlogd",
72
+ "libvirt-guests",
73
+ "qemu-guest-agent", # Host-side, VM has its own
74
+ # Hardware-specific
75
+ "bluetooth",
76
+ "bluez",
77
+ "upower",
78
+ "thermald",
79
+ "tlp",
80
+ "power-profiles-daemon",
81
+ # Display manager (VM has its own)
82
+ "gdm",
83
+ "gdm3",
84
+ "sddm",
85
+ "lightdm",
86
+ # Snap-based duplicates (prefer APT versions)
87
+ "snap.cups.cups-browsed",
88
+ "snap.cups.cupsd",
89
+ # Network hardware
90
+ "ModemManager",
91
+ "wpa_supplicant",
92
+ # Host-specific desktop
93
+ "accounts-daemon",
94
+ "colord",
95
+ "switcheroo-control",
96
+ }
97
+
67
98
  # Common development/server services to look for
68
99
  INTERESTING_SERVICES = [
69
100
  "docker",
@@ -244,12 +275,24 @@ class SystemDetector:
244
275
  # Browsers - profiles, extensions, bookmarks, passwords
245
276
  "chrome": [".config/google-chrome", ".config/chromium"],
246
277
  "chromium": [".config/chromium"],
247
- "firefox": [".mozilla/firefox", ".cache/mozilla/firefox"],
278
+ "firefox": [
279
+ "snap/firefox/common/.mozilla/firefox",
280
+ "snap/firefox/common/.cache/mozilla/firefox",
281
+ ".mozilla/firefox",
282
+ ".cache/mozilla/firefox",
283
+ ],
248
284
 
249
285
  # IDEs and editors - settings, extensions, projects history
250
286
  "code": [".config/Code", ".vscode", ".vscode-server"],
251
287
  "vscode": [".config/Code", ".vscode", ".vscode-server"],
252
- "pycharm": [".config/JetBrains", ".local/share/JetBrains", ".cache/JetBrains"],
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
+ ],
253
296
  "idea": [".config/JetBrains", ".local/share/JetBrains"],
254
297
  "webstorm": [".config/JetBrains", ".local/share/JetBrains"],
255
298
  "goland": [".config/JetBrains", ".local/share/JetBrains"],
@@ -323,27 +366,40 @@ class SystemDetector:
323
366
  app_data_paths = []
324
367
  seen_paths = set()
325
368
 
369
+ matched_patterns = set()
370
+
326
371
  for app in applications:
327
372
  app_name = app.name.lower()
328
-
329
- # Check each known app pattern
330
- for pattern, dirs in self.APP_DATA_DIRS.items():
373
+
374
+ for pattern in self.APP_DATA_DIRS:
331
375
  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
- })
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
+ })
347
403
 
348
404
  return app_data_paths
349
405
 
clonebox/profiles.py ADDED
@@ -0,0 +1,66 @@
1
+ import pkgutil
2
+ from pathlib import Path
3
+ from typing import Any, Dict, Optional
4
+
5
+ import yaml
6
+
7
+
8
+ def load_profile(profile_name: str, search_paths: list[Path]) -> Optional[Dict[str, Any]]:
9
+ """Load profile YAML from ~/.clonebox.d/, .clonebox.d/, templates/profiles/"""
10
+ search_paths = search_paths or []
11
+
12
+ profile_paths = [
13
+ Path.home() / ".clonebox.d" / f"{profile_name}.yaml",
14
+ Path.cwd() / ".clonebox.d" / f"{profile_name}.yaml",
15
+ ]
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
+
21
+ for profile_path in profile_paths:
22
+ if profile_path.exists():
23
+ return yaml.safe_load(profile_path.read_text())
24
+
25
+ data = pkgutil.get_data("clonebox", f"templates/profiles/{profile_name}.yaml")
26
+ if data is not None:
27
+ return yaml.safe_load(data.decode())
28
+
29
+ return None
30
+
31
+
32
+ def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
33
+ merged: Dict[str, Any] = dict(base)
34
+ for key, value in override.items():
35
+ if (
36
+ key in merged
37
+ and isinstance(merged[key], dict)
38
+ and isinstance(value, dict)
39
+ ):
40
+ merged[key] = _deep_merge(merged[key], value)
41
+ else:
42
+ merged[key] = value
43
+ return merged
44
+
45
+
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]:
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
+
59
+ if not profile_name:
60
+ return base_config
61
+
62
+ loaded = load_profile(profile_name, search_paths or [])
63
+ if not loaded or not isinstance(loaded, dict):
64
+ return base_config
65
+
66
+ return _deep_merge(base_config, loaded)
@@ -0,0 +1,6 @@
1
+ container:
2
+ image: python:3.11-slim
3
+ packages: ["pip", "jupyterlab"]
4
+ ports: ["8888:8888"]
5
+ vm:
6
+ packages: ["python3-pip", "jupyterlab"]
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
 
@@ -236,6 +237,16 @@ class VMValidator:
236
237
 
237
238
  return self.results["snap_packages"]
238
239
 
240
+ # Services that should NOT be validated in VM (host-specific)
241
+ VM_EXCLUDED_SERVICES = {
242
+ "libvirtd", "virtlogd", "libvirt-guests", "qemu-guest-agent",
243
+ "bluetooth", "bluez", "upower", "thermald", "tlp", "power-profiles-daemon",
244
+ "gdm", "gdm3", "sddm", "lightdm",
245
+ "snap.cups.cups-browsed", "snap.cups.cupsd",
246
+ "ModemManager", "wpa_supplicant",
247
+ "accounts-daemon", "colord", "switcheroo-control",
248
+ }
249
+
239
250
  def validate_services(self) -> Dict:
240
251
  """Validate services are enabled and running."""
241
252
  self.console.print("\n[bold]⚙️ Validating Services...[/]")
@@ -244,45 +255,155 @@ class VMValidator:
244
255
  if not services:
245
256
  self.console.print("[dim]No services configured[/]")
246
257
  return self.results["services"]
247
-
258
+
259
+ if "skipped" not in self.results["services"]:
260
+ self.results["services"]["skipped"] = 0
261
+
248
262
  svc_table = Table(title="Service Validation", border_style="cyan")
249
263
  svc_table.add_column("Service", style="bold")
250
264
  svc_table.add_column("Enabled", justify="center")
251
265
  svc_table.add_column("Running", justify="center")
252
-
266
+ svc_table.add_column("Note", style="dim")
267
+
253
268
  for service in services:
269
+ if service in self.VM_EXCLUDED_SERVICES:
270
+ svc_table.add_row(service, "[dim]—[/]", "[dim]—[/]", "host-only")
271
+ self.results["services"]["skipped"] += 1
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
+ )
281
+ continue
282
+
254
283
  self.results["services"]["total"] += 1
255
-
256
- # Check if enabled
284
+
257
285
  enabled_cmd = f"systemctl is-enabled {service} 2>/dev/null"
258
286
  enabled_status = self._exec_in_vm(enabled_cmd)
259
287
  is_enabled = enabled_status == "enabled"
260
-
261
- # Check if running
288
+
262
289
  running_cmd = f"systemctl is-active {service} 2>/dev/null"
263
290
  running_status = self._exec_in_vm(running_cmd)
264
291
  is_running = running_status == "active"
265
-
292
+
266
293
  enabled_icon = "[green]✅[/]" if is_enabled else "[yellow]⚠️[/]"
267
294
  running_icon = "[green]✅[/]" if is_running else "[red]❌[/]"
268
-
269
- svc_table.add_row(service, enabled_icon, running_icon)
270
-
295
+
296
+ svc_table.add_row(service, enabled_icon, running_icon, "")
297
+
271
298
  if is_enabled and is_running:
272
299
  self.results["services"]["passed"] += 1
273
300
  else:
274
301
  self.results["services"]["failed"] += 1
275
-
276
- self.results["services"]["details"].append({
277
- "service": service,
278
- "enabled": is_enabled,
279
- "running": is_running
280
- })
281
-
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
+
282
312
  self.console.print(svc_table)
283
- self.console.print(f"[dim]{self.results['services']['passed']}/{self.results['services']['total']} services active[/]")
284
-
313
+ skipped = self.results["services"].get("skipped", 0)
314
+ msg = f"{self.results['services']['passed']}/{self.results['services']['total']} services active"
315
+ if skipped > 0:
316
+ msg += f" ({skipped} host-only skipped)"
317
+ self.console.print(f"[dim]{msg}[/]")
318
+
285
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"]
286
407
 
287
408
  def validate_all(self) -> Dict:
288
409
  """Run all validations and return comprehensive results."""
@@ -311,51 +432,65 @@ class VMValidator:
311
432
  self.validate_packages()
312
433
  self.validate_snap_packages()
313
434
  self.validate_services()
435
+ self.validate_apps()
314
436
 
315
437
  # Calculate overall status
316
438
  total_checks = (
317
439
  self.results["mounts"]["total"] +
318
440
  self.results["packages"]["total"] +
319
441
  self.results["snap_packages"]["total"] +
320
- self.results["services"]["total"]
442
+ self.results["services"]["total"] +
443
+ self.results["apps"]["total"]
321
444
  )
322
445
 
323
446
  total_passed = (
324
447
  self.results["mounts"]["passed"] +
325
448
  self.results["packages"]["passed"] +
326
449
  self.results["snap_packages"]["passed"] +
327
- self.results["services"]["passed"]
450
+ self.results["services"]["passed"] +
451
+ self.results["apps"]["passed"]
328
452
  )
329
453
 
330
454
  total_failed = (
331
455
  self.results["mounts"]["failed"] +
332
456
  self.results["packages"]["failed"] +
333
457
  self.results["snap_packages"]["failed"] +
334
- self.results["services"]["failed"]
458
+ self.results["services"]["failed"] +
459
+ self.results["apps"]["failed"]
335
460
  )
336
461
 
462
+ # Get skipped services count
463
+ skipped_services = self.results["services"].get("skipped", 0)
464
+
337
465
  # Print summary
338
466
  self.console.print("\n[bold]📊 Validation Summary[/]")
339
467
  summary_table = Table(border_style="cyan")
340
468
  summary_table.add_column("Category", style="bold")
341
469
  summary_table.add_column("Passed", justify="right", style="green")
342
470
  summary_table.add_column("Failed", justify="right", style="red")
471
+ summary_table.add_column("Skipped", justify="right", style="dim")
343
472
  summary_table.add_column("Total", justify="right")
344
473
 
345
474
  summary_table.add_row("Mounts", str(self.results["mounts"]["passed"]),
346
- str(self.results["mounts"]["failed"]),
475
+ str(self.results["mounts"]["failed"]), "—",
347
476
  str(self.results["mounts"]["total"]))
348
477
  summary_table.add_row("APT Packages", str(self.results["packages"]["passed"]),
349
- str(self.results["packages"]["failed"]),
478
+ str(self.results["packages"]["failed"]), "—",
350
479
  str(self.results["packages"]["total"]))
351
480
  summary_table.add_row("Snap Packages", str(self.results["snap_packages"]["passed"]),
352
- str(self.results["snap_packages"]["failed"]),
481
+ str(self.results["snap_packages"]["failed"]), "—",
353
482
  str(self.results["snap_packages"]["total"]))
354
483
  summary_table.add_row("Services", str(self.results["services"]["passed"]),
355
484
  str(self.results["services"]["failed"]),
485
+ str(skipped_services),
356
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"]))
357
490
  summary_table.add_row("[bold]TOTAL", f"[bold green]{total_passed}",
358
- f"[bold red]{total_failed}", f"[bold]{total_checks}")
491
+ f"[bold red]{total_failed}",
492
+ f"[dim]{skipped_services}[/]",
493
+ f"[bold]{total_checks}")
359
494
 
360
495
  self.console.print(summary_table)
361
496
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clonebox
3
- Version: 0.1.18
3
+ Version: 0.1.20
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
@@ -41,6 +41,9 @@ Provides-Extra: test
41
41
  Requires-Dist: pytest>=7.0.0; extra == "test"
42
42
  Requires-Dist: pytest-cov>=4.0.0; extra == "test"
43
43
  Requires-Dist: pytest-timeout>=2.0.0; extra == "test"
44
+ Provides-Extra: dashboard
45
+ Requires-Dist: fastapi>=0.100.0; extra == "dashboard"
46
+ Requires-Dist: uvicorn>=0.22.0; extra == "dashboard"
44
47
  Dynamic: license-file
45
48
 
46
49
  # CloneBox 📦
@@ -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=08k0XvVdcExl6M64jqt7I_E04QVES3opXPexX9bYJec,103621
4
+ clonebox/cloner.py,sha256=_dJrcg4FoPzAU49J-duJdp37GF-5hE3R_ipbwaw-kaQ,32679
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=Uxz9eHov2epJpNYbl0ejaOX91iMSjqdHskGdC8-smVk,7789
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.20.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
13
+ clonebox-0.1.20.dist-info/METADATA,sha256=qOJSrz8MfPrZe3zO0Q16AfcdD_ybXx-YuVAs0_sij2g,35353
14
+ clonebox-0.1.20.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
15
+ clonebox-0.1.20.dist-info/entry_points.txt,sha256=FES95Vi3btfViLEEoHdb8nikNxTqzaooi9ehZw9ZfWI,47
16
+ clonebox-0.1.20.dist-info/top_level.txt,sha256=LdMo2cvCrEcRGH2M8JgQNVsCoszLV0xug6kx1JnaRjo,9
17
+ clonebox-0.1.20.dist-info/RECORD,,
@@ -1,14 +0,0 @@
1
- clonebox/__init__.py,sha256=C1J7Uwrp8H9Zopo5JgrQYzXg-PWls1JdqmE_0Qp1Tro,408
2
- clonebox/__main__.py,sha256=Fcoyzwwyz5-eC_sBlQk5a5RbKx8uodQz5sKJ190U0NU,135
3
- clonebox/cli.py,sha256=0Yjkq8EC5v8e402ouvPt3F3-8QSdKuMYZmOrIa8YARc,98076
4
- clonebox/cloner.py,sha256=tgN51yeNGesolO1wfuVh-CAGkAZew7oMoCYYz_bXgBk,32456
5
- clonebox/container.py,sha256=tiYK1ZB-DhdD6A2FuMA0h_sRNkUI7KfYcJ0tFOcdyeM,6105
6
- clonebox/detector.py,sha256=4fu04Ty6KC82WkcJZ5UL5TqXpWYE7Kb7R0uJ-9dtbCk,21635
7
- clonebox/models.py,sha256=Uxz9eHov2epJpNYbl0ejaOX91iMSjqdHskGdC8-smVk,7789
8
- clonebox/validator.py,sha256=8HV3ahfiLkFDOH4UOmZr7-fGfhKep1Jlw1joJeWSaQE,15858
9
- clonebox-0.1.18.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
10
- clonebox-0.1.18.dist-info/METADATA,sha256=zdZz4r2QGeCLnFq757o0dR8T9OiLqYneys1r4LFcq58,35220
11
- clonebox-0.1.18.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
12
- clonebox-0.1.18.dist-info/entry_points.txt,sha256=FES95Vi3btfViLEEoHdb8nikNxTqzaooi9ehZw9ZfWI,47
13
- clonebox-0.1.18.dist-info/top_level.txt,sha256=LdMo2cvCrEcRGH2M8JgQNVsCoszLV0xug6kx1JnaRjo,9
14
- clonebox-0.1.18.dist-info/RECORD,,