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 +94 -1
- clonebox/cloner.py +3 -1
- clonebox/dashboard.py +133 -0
- clonebox/detector.py +45 -20
- clonebox/profiles.py +21 -4
- clonebox/validator.py +127 -32
- {clonebox-0.1.19.dist-info → clonebox-0.1.20.dist-info}/METADATA +1 -1
- clonebox-0.1.20.dist-info/RECORD +17 -0
- clonebox-0.1.19.dist-info/RECORD +0 -16
- {clonebox-0.1.19.dist-info → clonebox-0.1.20.dist-info}/WHEEL +0 -0
- {clonebox-0.1.19.dist-info → clonebox-0.1.20.dist-info}/entry_points.txt +0 -0
- {clonebox-0.1.19.dist-info → clonebox-0.1.20.dist-info}/licenses/LICENSE +0 -0
- {clonebox-0.1.19.dist-info → clonebox-0.1.20.dist-info}/top_level.txt +0 -0
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":
|
|
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": [
|
|
278
|
+
"firefox": [
|
|
279
|
+
"snap/firefox/common/.mozilla/firefox",
|
|
280
|
+
"snap/firefox/common/.cache/mozilla/firefox",
|
|
281
|
+
".mozilla/firefox",
|
|
282
|
+
".cache/mozilla/firefox",
|
|
283
|
+
],
|
|
279
284
|
|
|
280
285
|
# IDEs and editors - settings, extensions, projects history
|
|
281
286
|
"code": [".config/Code", ".vscode", ".vscode-server"],
|
|
282
287
|
"vscode": [".config/Code", ".vscode", ".vscode-server"],
|
|
283
|
-
"pycharm": [
|
|
288
|
+
"pycharm": [
|
|
289
|
+
"snap/pycharm-community/common/.config/JetBrains",
|
|
290
|
+
"snap/pycharm-community/common/.local/share/JetBrains",
|
|
291
|
+
"snap/pycharm-community/common/.cache/JetBrains",
|
|
292
|
+
".config/JetBrains",
|
|
293
|
+
".local/share/JetBrains",
|
|
294
|
+
".cache/JetBrains",
|
|
295
|
+
],
|
|
284
296
|
"idea": [".config/JetBrains", ".local/share/JetBrains"],
|
|
285
297
|
"webstorm": [".config/JetBrains", ".local/share/JetBrains"],
|
|
286
298
|
"goland": [".config/JetBrains", ".local/share/JetBrains"],
|
|
@@ -354,27 +366,40 @@ class SystemDetector:
|
|
|
354
366
|
app_data_paths = []
|
|
355
367
|
seen_paths = set()
|
|
356
368
|
|
|
369
|
+
matched_patterns = set()
|
|
370
|
+
|
|
357
371
|
for app in applications:
|
|
358
372
|
app_name = app.name.lower()
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
for pattern, dirs in self.APP_DATA_DIRS.items():
|
|
373
|
+
|
|
374
|
+
for pattern in self.APP_DATA_DIRS:
|
|
362
375
|
if pattern in app_name:
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
376
|
+
matched_patterns.add(pattern)
|
|
377
|
+
|
|
378
|
+
for pattern in ("firefox", "chrome", "chromium", "pycharm"):
|
|
379
|
+
matched_patterns.add(pattern)
|
|
380
|
+
|
|
381
|
+
for pattern in sorted(matched_patterns):
|
|
382
|
+
dirs = self.APP_DATA_DIRS.get(pattern, [])
|
|
383
|
+
if not dirs:
|
|
384
|
+
continue
|
|
385
|
+
|
|
386
|
+
snap_dirs = [d for d in dirs if d.startswith("snap/")]
|
|
387
|
+
preferred_dirs = snap_dirs if any((self.home / d).exists() for d in snap_dirs) else dirs
|
|
388
|
+
|
|
389
|
+
for dir_name in preferred_dirs:
|
|
390
|
+
full_path = self.home / dir_name
|
|
391
|
+
if full_path.exists() and str(full_path) not in seen_paths:
|
|
392
|
+
seen_paths.add(str(full_path))
|
|
393
|
+
try:
|
|
394
|
+
size = self._get_dir_size(full_path, max_depth=2)
|
|
395
|
+
except Exception:
|
|
396
|
+
size = 0
|
|
397
|
+
app_data_paths.append({
|
|
398
|
+
"path": str(full_path),
|
|
399
|
+
"app": pattern,
|
|
400
|
+
"type": "app_data",
|
|
401
|
+
"size_mb": round(size / 1024 / 1024, 1),
|
|
402
|
+
})
|
|
378
403
|
|
|
379
404
|
return app_data_paths
|
|
380
405
|
|
clonebox/profiles.py
CHANGED
|
@@ -7,11 +7,17 @@ import yaml
|
|
|
7
7
|
|
|
8
8
|
def load_profile(profile_name: str, search_paths: list[Path]) -> Optional[Dict[str, Any]]:
|
|
9
9
|
"""Load profile YAML from ~/.clonebox.d/, .clonebox.d/, templates/profiles/"""
|
|
10
|
+
search_paths = search_paths or []
|
|
11
|
+
|
|
10
12
|
profile_paths = [
|
|
11
13
|
Path.home() / ".clonebox.d" / f"{profile_name}.yaml",
|
|
12
14
|
Path.cwd() / ".clonebox.d" / f"{profile_name}.yaml",
|
|
13
15
|
]
|
|
14
16
|
|
|
17
|
+
for base in search_paths:
|
|
18
|
+
profile_paths.insert(0, base / "templates" / "profiles" / f"{profile_name}.yaml")
|
|
19
|
+
profile_paths.insert(0, base / f"{profile_name}.yaml")
|
|
20
|
+
|
|
15
21
|
for profile_path in profile_paths:
|
|
16
22
|
if profile_path.exists():
|
|
17
23
|
return yaml.safe_load(profile_path.read_text())
|
|
@@ -37,13 +43,24 @@ def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any
|
|
|
37
43
|
return merged
|
|
38
44
|
|
|
39
45
|
|
|
40
|
-
def merge_with_profile(
|
|
46
|
+
def merge_with_profile(
|
|
47
|
+
base_config: Dict[str, Any],
|
|
48
|
+
profile_name: Optional[str] = None,
|
|
49
|
+
*,
|
|
50
|
+
profile: Optional[Dict[str, Any]] = None,
|
|
51
|
+
search_paths: Optional[list[Path]] = None,
|
|
52
|
+
) -> Dict[str, Any]:
|
|
41
53
|
"""Merge profile OVER base config (profile wins)."""
|
|
54
|
+
if profile is not None:
|
|
55
|
+
if not isinstance(profile, dict):
|
|
56
|
+
return base_config
|
|
57
|
+
return _deep_merge(base_config, profile)
|
|
58
|
+
|
|
42
59
|
if not profile_name:
|
|
43
60
|
return base_config
|
|
44
61
|
|
|
45
|
-
|
|
46
|
-
if not
|
|
62
|
+
loaded = load_profile(profile_name, search_paths or [])
|
|
63
|
+
if not loaded or not isinstance(loaded, dict):
|
|
47
64
|
return base_config
|
|
48
65
|
|
|
49
|
-
return _deep_merge(base_config,
|
|
66
|
+
return _deep_merge(base_config, loaded)
|
clonebox/validator.py
CHANGED
|
@@ -24,6 +24,7 @@ class VMValidator:
|
|
|
24
24
|
"packages": {"passed": 0, "failed": 0, "total": 0, "details": []},
|
|
25
25
|
"snap_packages": {"passed": 0, "failed": 0, "total": 0, "details": []},
|
|
26
26
|
"services": {"passed": 0, "failed": 0, "total": 0, "details": []},
|
|
27
|
+
"apps": {"passed": 0, "failed": 0, "total": 0, "details": []},
|
|
27
28
|
"overall": "unknown"
|
|
28
29
|
}
|
|
29
30
|
|
|
@@ -254,68 +255,155 @@ class VMValidator:
|
|
|
254
255
|
if not services:
|
|
255
256
|
self.console.print("[dim]No services configured[/]")
|
|
256
257
|
return self.results["services"]
|
|
257
|
-
|
|
258
|
-
# Initialize skipped counter
|
|
258
|
+
|
|
259
259
|
if "skipped" not in self.results["services"]:
|
|
260
260
|
self.results["services"]["skipped"] = 0
|
|
261
|
-
|
|
261
|
+
|
|
262
262
|
svc_table = Table(title="Service Validation", border_style="cyan")
|
|
263
263
|
svc_table.add_column("Service", style="bold")
|
|
264
264
|
svc_table.add_column("Enabled", justify="center")
|
|
265
265
|
svc_table.add_column("Running", justify="center")
|
|
266
266
|
svc_table.add_column("Note", style="dim")
|
|
267
|
-
|
|
267
|
+
|
|
268
268
|
for service in services:
|
|
269
|
-
# Check if service should be skipped (host-specific)
|
|
270
269
|
if service in self.VM_EXCLUDED_SERVICES:
|
|
271
270
|
svc_table.add_row(service, "[dim]—[/]", "[dim]—[/]", "host-only")
|
|
272
271
|
self.results["services"]["skipped"] += 1
|
|
273
|
-
self.results["services"]["details"].append(
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
272
|
+
self.results["services"]["details"].append(
|
|
273
|
+
{
|
|
274
|
+
"service": service,
|
|
275
|
+
"enabled": None,
|
|
276
|
+
"running": None,
|
|
277
|
+
"skipped": True,
|
|
278
|
+
"reason": "host-specific service",
|
|
279
|
+
}
|
|
280
|
+
)
|
|
280
281
|
continue
|
|
281
|
-
|
|
282
|
+
|
|
282
283
|
self.results["services"]["total"] += 1
|
|
283
|
-
|
|
284
|
-
# Check if enabled
|
|
284
|
+
|
|
285
285
|
enabled_cmd = f"systemctl is-enabled {service} 2>/dev/null"
|
|
286
286
|
enabled_status = self._exec_in_vm(enabled_cmd)
|
|
287
287
|
is_enabled = enabled_status == "enabled"
|
|
288
|
-
|
|
289
|
-
# Check if running
|
|
288
|
+
|
|
290
289
|
running_cmd = f"systemctl is-active {service} 2>/dev/null"
|
|
291
290
|
running_status = self._exec_in_vm(running_cmd)
|
|
292
291
|
is_running = running_status == "active"
|
|
293
|
-
|
|
292
|
+
|
|
294
293
|
enabled_icon = "[green]✅[/]" if is_enabled else "[yellow]⚠️[/]"
|
|
295
294
|
running_icon = "[green]✅[/]" if is_running else "[red]❌[/]"
|
|
296
|
-
|
|
295
|
+
|
|
297
296
|
svc_table.add_row(service, enabled_icon, running_icon, "")
|
|
298
|
-
|
|
297
|
+
|
|
299
298
|
if is_enabled and is_running:
|
|
300
299
|
self.results["services"]["passed"] += 1
|
|
301
300
|
else:
|
|
302
301
|
self.results["services"]["failed"] += 1
|
|
303
|
-
|
|
304
|
-
self.results["services"]["details"].append(
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
302
|
+
|
|
303
|
+
self.results["services"]["details"].append(
|
|
304
|
+
{
|
|
305
|
+
"service": service,
|
|
306
|
+
"enabled": is_enabled,
|
|
307
|
+
"running": is_running,
|
|
308
|
+
"skipped": False,
|
|
309
|
+
}
|
|
310
|
+
)
|
|
311
|
+
|
|
311
312
|
self.console.print(svc_table)
|
|
312
313
|
skipped = self.results["services"].get("skipped", 0)
|
|
313
314
|
msg = f"{self.results['services']['passed']}/{self.results['services']['total']} services active"
|
|
314
315
|
if skipped > 0:
|
|
315
316
|
msg += f" ({skipped} host-only skipped)"
|
|
316
317
|
self.console.print(f"[dim]{msg}[/]")
|
|
317
|
-
|
|
318
|
+
|
|
318
319
|
return self.results["services"]
|
|
320
|
+
|
|
321
|
+
def validate_apps(self) -> Dict:
|
|
322
|
+
packages = self.config.get("packages", [])
|
|
323
|
+
snap_packages = self.config.get("snap_packages", [])
|
|
324
|
+
app_data_paths = self.config.get("app_data_paths", {})
|
|
325
|
+
|
|
326
|
+
expected = []
|
|
327
|
+
|
|
328
|
+
if "firefox" in packages:
|
|
329
|
+
expected.append("firefox")
|
|
330
|
+
if "pycharm-community" in snap_packages:
|
|
331
|
+
expected.append("pycharm-community")
|
|
332
|
+
|
|
333
|
+
for _, guest_path in app_data_paths.items():
|
|
334
|
+
if guest_path == "/home/ubuntu/.config/google-chrome":
|
|
335
|
+
expected.append("google-chrome")
|
|
336
|
+
break
|
|
337
|
+
|
|
338
|
+
expected = sorted(set(expected))
|
|
339
|
+
if not expected:
|
|
340
|
+
return self.results["apps"]
|
|
341
|
+
|
|
342
|
+
self.console.print("\n[bold]🧩 Validating Apps...[/]")
|
|
343
|
+
table = Table(title="App Validation", border_style="cyan")
|
|
344
|
+
table.add_column("App", style="bold")
|
|
345
|
+
table.add_column("Installed", justify="center")
|
|
346
|
+
table.add_column("Profile", justify="center")
|
|
347
|
+
|
|
348
|
+
def _check_dir_nonempty(path: str) -> bool:
|
|
349
|
+
out = self._exec_in_vm(
|
|
350
|
+
f"test -d {path} && [ $(ls -A {path} 2>/dev/null | wc -l) -gt 0 ] && echo yes || echo no",
|
|
351
|
+
timeout=10,
|
|
352
|
+
)
|
|
353
|
+
return out == "yes"
|
|
354
|
+
|
|
355
|
+
for app in expected:
|
|
356
|
+
self.results["apps"]["total"] += 1
|
|
357
|
+
installed = False
|
|
358
|
+
profile_ok = False
|
|
359
|
+
|
|
360
|
+
if app == "firefox":
|
|
361
|
+
installed = (
|
|
362
|
+
self._exec_in_vm("command -v firefox >/dev/null 2>&1 && echo yes || echo no")
|
|
363
|
+
== "yes"
|
|
364
|
+
)
|
|
365
|
+
if _check_dir_nonempty("/home/ubuntu/snap/firefox/common/.mozilla/firefox"):
|
|
366
|
+
profile_ok = True
|
|
367
|
+
elif _check_dir_nonempty("/home/ubuntu/.mozilla/firefox"):
|
|
368
|
+
profile_ok = True
|
|
369
|
+
|
|
370
|
+
elif app == "pycharm-community":
|
|
371
|
+
installed = (
|
|
372
|
+
self._exec_in_vm(
|
|
373
|
+
"snap list pycharm-community >/dev/null 2>&1 && echo yes || echo no"
|
|
374
|
+
)
|
|
375
|
+
== "yes"
|
|
376
|
+
)
|
|
377
|
+
profile_ok = _check_dir_nonempty(
|
|
378
|
+
"/home/ubuntu/snap/pycharm-community/common/.config/JetBrains"
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
elif app == "google-chrome":
|
|
382
|
+
installed = (
|
|
383
|
+
self._exec_in_vm(
|
|
384
|
+
"(command -v google-chrome >/dev/null 2>&1 || command -v google-chrome-stable >/dev/null 2>&1) && echo yes || echo no"
|
|
385
|
+
)
|
|
386
|
+
== "yes"
|
|
387
|
+
)
|
|
388
|
+
profile_ok = _check_dir_nonempty("/home/ubuntu/.config/google-chrome")
|
|
389
|
+
|
|
390
|
+
table.add_row(
|
|
391
|
+
app,
|
|
392
|
+
"[green]✅[/]" if installed else "[red]❌[/]",
|
|
393
|
+
"[green]✅[/]" if profile_ok else "[red]❌[/]",
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
if installed and profile_ok:
|
|
397
|
+
self.results["apps"]["passed"] += 1
|
|
398
|
+
else:
|
|
399
|
+
self.results["apps"]["failed"] += 1
|
|
400
|
+
|
|
401
|
+
self.results["apps"]["details"].append(
|
|
402
|
+
{"app": app, "installed": installed, "profile": profile_ok}
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
self.console.print(table)
|
|
406
|
+
return self.results["apps"]
|
|
319
407
|
|
|
320
408
|
def validate_all(self) -> Dict:
|
|
321
409
|
"""Run all validations and return comprehensive results."""
|
|
@@ -344,27 +432,31 @@ class VMValidator:
|
|
|
344
432
|
self.validate_packages()
|
|
345
433
|
self.validate_snap_packages()
|
|
346
434
|
self.validate_services()
|
|
435
|
+
self.validate_apps()
|
|
347
436
|
|
|
348
437
|
# Calculate overall status
|
|
349
438
|
total_checks = (
|
|
350
439
|
self.results["mounts"]["total"] +
|
|
351
440
|
self.results["packages"]["total"] +
|
|
352
441
|
self.results["snap_packages"]["total"] +
|
|
353
|
-
self.results["services"]["total"]
|
|
442
|
+
self.results["services"]["total"] +
|
|
443
|
+
self.results["apps"]["total"]
|
|
354
444
|
)
|
|
355
445
|
|
|
356
446
|
total_passed = (
|
|
357
447
|
self.results["mounts"]["passed"] +
|
|
358
448
|
self.results["packages"]["passed"] +
|
|
359
449
|
self.results["snap_packages"]["passed"] +
|
|
360
|
-
self.results["services"]["passed"]
|
|
450
|
+
self.results["services"]["passed"] +
|
|
451
|
+
self.results["apps"]["passed"]
|
|
361
452
|
)
|
|
362
453
|
|
|
363
454
|
total_failed = (
|
|
364
455
|
self.results["mounts"]["failed"] +
|
|
365
456
|
self.results["packages"]["failed"] +
|
|
366
457
|
self.results["snap_packages"]["failed"] +
|
|
367
|
-
self.results["services"]["failed"]
|
|
458
|
+
self.results["services"]["failed"] +
|
|
459
|
+
self.results["apps"]["failed"]
|
|
368
460
|
)
|
|
369
461
|
|
|
370
462
|
# Get skipped services count
|
|
@@ -392,6 +484,9 @@ class VMValidator:
|
|
|
392
484
|
str(self.results["services"]["failed"]),
|
|
393
485
|
str(skipped_services),
|
|
394
486
|
str(self.results["services"]["total"]))
|
|
487
|
+
summary_table.add_row("Apps", str(self.results["apps"]["passed"]),
|
|
488
|
+
str(self.results["apps"]["failed"]), "—",
|
|
489
|
+
str(self.results["apps"]["total"]))
|
|
395
490
|
summary_table.add_row("[bold]TOTAL", f"[bold green]{total_passed}",
|
|
396
491
|
f"[bold red]{total_failed}",
|
|
397
492
|
f"[dim]{skipped_services}[/]",
|
|
@@ -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,,
|
clonebox-0.1.19.dist-info/RECORD
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
clonebox/__init__.py,sha256=CyfHVVq6KqBr4CNERBpXk_O6Q5B35q03YpdQbokVvvI,408
|
|
2
|
-
clonebox/__main__.py,sha256=Fcoyzwwyz5-eC_sBlQk5a5RbKx8uodQz5sKJ190U0NU,135
|
|
3
|
-
clonebox/cli.py,sha256=WnAlgR3h0gIMwkc0uHAr44E4eS25GTBsNbKbuumrUug,99790
|
|
4
|
-
clonebox/cloner.py,sha256=OXytYy4K4sr-ytP55HZK9frt1vU5Kf5EOCIKYhpdJtQ,32516
|
|
5
|
-
clonebox/container.py,sha256=tiYK1ZB-DhdD6A2FuMA0h_sRNkUI7KfYcJ0tFOcdyeM,6105
|
|
6
|
-
clonebox/detector.py,sha256=FM8QJ6RTO0jXyUJIcAJivLwUh82xm287ayFD_VIuvgs,22521
|
|
7
|
-
clonebox/models.py,sha256=Uxz9eHov2epJpNYbl0ejaOX91iMSjqdHskGdC8-smVk,7789
|
|
8
|
-
clonebox/profiles.py,sha256=ZyuPct9Jg5m2ImnSEbHvBMp3x7Ufj2420D84rXt2T1Y,1537
|
|
9
|
-
clonebox/validator.py,sha256=qIkFMkWmpx4k2-vPkLigZ6pK5wtl748CmpuNLB6yOIM,17645
|
|
10
|
-
clonebox/templates/profiles/ml-dev.yaml,sha256=MT7Wu3xGBnYIsO5mzZ2GDI4AAEFGOroIx0eU3XjNARg,140
|
|
11
|
-
clonebox-0.1.19.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
12
|
-
clonebox-0.1.19.dist-info/METADATA,sha256=HXLOv0aaY7OlXGJKtk8LSzlbqeQwgHp9Bax29KzQLV4,35353
|
|
13
|
-
clonebox-0.1.19.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
14
|
-
clonebox-0.1.19.dist-info/entry_points.txt,sha256=FES95Vi3btfViLEEoHdb8nikNxTqzaooi9ehZw9ZfWI,47
|
|
15
|
-
clonebox-0.1.19.dist-info/top_level.txt,sha256=LdMo2cvCrEcRGH2M8JgQNVsCoszLV0xug6kx1JnaRjo,9
|
|
16
|
-
clonebox-0.1.19.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|