clonebox 0.1.18__tar.gz → 0.1.20__tar.gz

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.
Files changed (30) hide show
  1. {clonebox-0.1.18/src/clonebox.egg-info → clonebox-0.1.20}/PKG-INFO +4 -1
  2. {clonebox-0.1.18 → clonebox-0.1.20}/pyproject.toml +15 -1
  3. {clonebox-0.1.18 → clonebox-0.1.20}/src/clonebox/__init__.py +1 -1
  4. {clonebox-0.1.18 → clonebox-0.1.20}/src/clonebox/cli.py +196 -74
  5. {clonebox-0.1.18 → clonebox-0.1.20}/src/clonebox/cloner.py +5 -2
  6. clonebox-0.1.20/src/clonebox/dashboard.py +133 -0
  7. {clonebox-0.1.18 → clonebox-0.1.20}/src/clonebox/detector.py +76 -20
  8. clonebox-0.1.20/src/clonebox/profiles.py +66 -0
  9. clonebox-0.1.20/src/clonebox/templates/profiles/ml-dev.yaml +6 -0
  10. {clonebox-0.1.18 → clonebox-0.1.20}/src/clonebox/validator.py +161 -26
  11. {clonebox-0.1.18 → clonebox-0.1.20/src/clonebox.egg-info}/PKG-INFO +4 -1
  12. {clonebox-0.1.18 → clonebox-0.1.20}/src/clonebox.egg-info/SOURCES.txt +5 -0
  13. {clonebox-0.1.18 → clonebox-0.1.20}/src/clonebox.egg-info/requires.txt +4 -0
  14. {clonebox-0.1.18 → clonebox-0.1.20}/tests/test_cli.py +3 -0
  15. clonebox-0.1.20/tests/test_container.py +235 -0
  16. {clonebox-0.1.18 → clonebox-0.1.20}/tests/test_detector.py +24 -0
  17. clonebox-0.1.20/tests/test_profiles.py +26 -0
  18. {clonebox-0.1.18 → clonebox-0.1.20}/tests/test_validator.py +99 -1
  19. {clonebox-0.1.18 → clonebox-0.1.20}/LICENSE +0 -0
  20. {clonebox-0.1.18 → clonebox-0.1.20}/README.md +0 -0
  21. {clonebox-0.1.18 → clonebox-0.1.20}/setup.cfg +0 -0
  22. {clonebox-0.1.18 → clonebox-0.1.20}/src/clonebox/__main__.py +0 -0
  23. {clonebox-0.1.18 → clonebox-0.1.20}/src/clonebox/container.py +0 -0
  24. {clonebox-0.1.18 → clonebox-0.1.20}/src/clonebox/models.py +0 -0
  25. {clonebox-0.1.18 → clonebox-0.1.20}/src/clonebox.egg-info/dependency_links.txt +0 -0
  26. {clonebox-0.1.18 → clonebox-0.1.20}/src/clonebox.egg-info/entry_points.txt +0 -0
  27. {clonebox-0.1.18 → clonebox-0.1.20}/src/clonebox.egg-info/top_level.txt +0 -0
  28. {clonebox-0.1.18 → clonebox-0.1.20}/tests/test_cloner.py +0 -0
  29. {clonebox-0.1.18 → clonebox-0.1.20}/tests/test_models.py +0 -0
  30. {clonebox-0.1.18 → clonebox-0.1.20}/tests/test_network.py +0 -0
@@ -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 📦
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "clonebox"
7
- version = "0.1.18"
7
+ version = "0.1.20"
8
8
  description = "Clone your workstation environment to an isolated VM with selective apps, paths and services"
9
9
  readme = "README.md"
10
10
  license = {text = "Apache-2.0"}
@@ -52,6 +52,10 @@ test = [
52
52
  "pytest-cov>=4.0.0",
53
53
  "pytest-timeout>=2.0.0",
54
54
  ]
55
+ dashboard = [
56
+ "fastapi>=0.100.0",
57
+ "uvicorn>=0.22.0",
58
+ ]
55
59
 
56
60
  [project.scripts]
57
61
  clonebox = "clonebox.cli:main"
@@ -64,6 +68,9 @@ Issues = "https://github.com/wronai/clonebox/issues"
64
68
  [tool.setuptools.packages.find]
65
69
  where = ["src"]
66
70
 
71
+ [tool.setuptools.package-data]
72
+ clonebox = ["templates/**/*.yaml"]
73
+
67
74
  [tool.black]
68
75
  line-length = 100
69
76
  target-version = ["py38", "py39", "py310", "py311", "py312"]
@@ -82,6 +89,7 @@ markers = [
82
89
  "e2e: end-to-end tests requiring libvirt/KVM (deselect with '-m \"not e2e\"')",
83
90
  "slow: slow tests (deselect with '-m \"not slow\"')",
84
91
  "integration: integration tests",
92
+ "container: container workflow tests requiring podman/docker",
85
93
  "requires_kvm: tests requiring /dev/kvm access",
86
94
  ]
87
95
  addopts = [
@@ -99,3 +107,9 @@ filterwarnings = [
99
107
  "ignore::PendingDeprecationWarning",
100
108
  "ignore::UserWarning",
101
109
  ]
110
+
111
+ [tool.coverage.run]
112
+ omit = [
113
+ "src/clonebox/container.py",
114
+ "src/clonebox/cli.py",
115
+ ]
@@ -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
@@ -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
@@ -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