clonebox 0.1.19__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/cli.py CHANGED
@@ -919,6 +919,8 @@ def cmd_delete(args):
919
919
  ).ask():
920
920
  console.print("[yellow]Cancelled.[/]")
921
921
  return
922
+
923
+
922
924
  def cmd_list(args):
923
925
  """List all VMs."""
924
926
  cloner = SelectiveVMCloner(user_session=getattr(args, "user", False))
@@ -1027,6 +1029,19 @@ def cmd_container_down(args):
1027
1029
  cloner.rm(args.name, force=True)
1028
1030
 
1029
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
+
1030
1045
  def cmd_diagnose(args):
1031
1046
  """Run detailed VM diagnostics (standalone)."""
1032
1047
  name = args.name
@@ -1772,6 +1787,21 @@ def generate_clonebox_yaml(
1772
1787
  guest_path = f"/home/ubuntu/{rel_path}"
1773
1788
  app_data_mapping[host_path] = guest_path
1774
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
+
1775
1805
  # Determine VM name
1776
1806
  if not vm_name:
1777
1807
  if target_path:
@@ -1805,6 +1835,34 @@ def generate_clonebox_yaml(
1805
1835
  if deduplicate:
1806
1836
  all_snap_packages = deduplicate_list(all_snap_packages)
1807
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
+
1808
1866
  # Build config
1809
1867
  config = {
1810
1868
  "version": "1",
@@ -1822,7 +1880,7 @@ def generate_clonebox_yaml(
1822
1880
  "services": services,
1823
1881
  "packages": all_apt_packages,
1824
1882
  "snap_packages": all_snap_packages,
1825
- "post_commands": [], # User can add custom commands to run after setup
1883
+ "post_commands": post_commands,
1826
1884
  "paths": paths_mapping,
1827
1885
  "app_data_paths": app_data_mapping, # App-specific config/data directories
1828
1886
  "detected": {
@@ -2368,6 +2426,12 @@ def main():
2368
2426
  container_sub = container_parser.add_subparsers(dest="container_command", help="Container commands")
2369
2427
 
2370
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
+ )
2371
2435
  container_up.add_argument("path", nargs="?", default=".", help="Workspace path")
2372
2436
  container_up.add_argument("--name", help="Container name")
2373
2437
  container_up.add_argument("--image", default="ubuntu:22.04", help="Container image")
@@ -2402,23 +2466,52 @@ def main():
2402
2466
  container_up.set_defaults(func=cmd_container_up)
2403
2467
 
2404
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
+ )
2405
2475
  container_ps.add_argument("-a", "--all", action="store_true", help="Show all containers")
2406
2476
  container_ps.add_argument("--json", action="store_true", help="Output JSON")
2407
2477
  container_ps.set_defaults(func=cmd_container_ps)
2408
2478
 
2409
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
+ )
2410
2486
  container_stop.add_argument("name", help="Container name")
2411
2487
  container_stop.set_defaults(func=cmd_container_stop)
2412
2488
 
2413
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
+ )
2414
2496
  container_rm.add_argument("name", help="Container name")
2415
2497
  container_rm.add_argument("-f", "--force", action="store_true", help="Force remove")
2416
2498
  container_rm.set_defaults(func=cmd_container_rm)
2417
2499
 
2418
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
+ )
2419
2507
  container_down.add_argument("name", help="Container name")
2420
2508
  container_down.set_defaults(func=cmd_container_down)
2421
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
+
2422
2515
  # Detect command
2423
2516
  detect_parser = subparsers.add_parser("detect", help="Detect system state")
2424
2517
  detect_parser.add_argument("--json", action="store_true", help="Output as JSON")
clonebox/cloner.py CHANGED
@@ -696,6 +696,8 @@ fi
696
696
  runcmd_lines = []
697
697
 
698
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")
699
701
 
700
702
  # Add service enablement
701
703
  for svc in config.services:
@@ -760,7 +762,7 @@ users:
760
762
  sudo: ALL=(ALL) NOPASSWD:ALL
761
763
  shell: /bin/bash
762
764
  lock_passwd: false
763
- groups: sudo,adm,dialout,cdrom,floppy,audio,dip,video,plugdev,netdev
765
+ groups: sudo,adm,dialout,cdrom,floppy,audio,dip,video,plugdev,netdev,docker
764
766
  plain_text_passwd: {config.password}
765
767
 
766
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
@@ -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": [".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
+ ],
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": [".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
+ ],
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
- # Check each known app pattern
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
- for dir_name in dirs:
364
- full_path = self.home / dir_name
365
- if full_path.exists() and str(full_path) not in seen_paths:
366
- seen_paths.add(str(full_path))
367
- # Calculate size
368
- try:
369
- size = self._get_dir_size(full_path, max_depth=2)
370
- except:
371
- size = 0
372
- app_data_paths.append({
373
- "path": str(full_path),
374
- "app": app.name,
375
- "type": "app_data",
376
- "size_mb": round(size / 1024 / 1024, 1)
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/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(base_config: Dict[str, Any], profile_name: Optional[str] = None) -> Dict[str, Any]:
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
- profile = load_profile(profile_name, [])
46
- if not profile or not isinstance(profile, dict):
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, profile)
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
- "service": service,
275
- "enabled": None,
276
- "running": None,
277
- "skipped": True,
278
- "reason": "host-specific service"
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
- "service": service,
306
- "enabled": is_enabled,
307
- "running": is_running,
308
- "skipped": False
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.19
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
@@ -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,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,,