clonebox 1.1.20__py3-none-any.whl → 1.1.23__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
@@ -945,10 +945,28 @@ def interactive_mode():
945
945
  cloner = SelectiveVMCloner(user_session=getattr(args, "user", False))
946
946
 
947
947
  # Check prerequisites
948
- checks = cloner.check_prerequisites()
949
- if not all(checks.values()):
948
+ checks = cloner.check_prerequisites(config)
949
+ required_keys = [
950
+ "libvirt_connected",
951
+ "kvm_available",
952
+ "images_dir_writable",
953
+ "genisoimage_installed",
954
+ "qemu_img_installed",
955
+ ]
956
+ if checks.get("default_network_required", True):
957
+ required_keys.append("default_network")
958
+ if getattr(config, "gui", False):
959
+ required_keys.append("virt_viewer_installed")
960
+
961
+ required_checks = {
962
+ k: checks.get(k)
963
+ for k in required_keys
964
+ if isinstance(checks.get(k), bool)
965
+ }
966
+
967
+ if required_checks and not all(required_checks.values()):
950
968
  console.print("[yellow]⚠️ Prerequisites check:[/]")
951
- for check, passed in checks.items():
969
+ for check, passed in required_checks.items():
952
970
  icon = "✅" if passed else "❌"
953
971
  console.print(f" {icon} {check}")
954
972
 
@@ -1898,7 +1916,10 @@ def cmd_test(args):
1898
1916
 
1899
1917
  if not qga_ready:
1900
1918
  console.print("[yellow]⚠️ QEMU Guest Agent still not connected[/]")
1901
-
1919
+ console.print(
1920
+ f"[dim]Tip: you can watch live cloud-init output via serial console: virsh --connect {conn_uri} console {vm_name}[/]"
1921
+ )
1922
+
1902
1923
  # Check cloud-init status immediately if QGA is ready
1903
1924
  if qga_ready:
1904
1925
  console.print("[dim] Checking cloud-init status via QGA...[/]")
@@ -2624,10 +2645,24 @@ def create_vm_from_config(config, start=False, user_session=False, replace=False
2624
2645
  cloner = SelectiveVMCloner(user_session=user_session)
2625
2646
 
2626
2647
  # Check prerequisites
2627
- checks = cloner.check_prerequisites()
2628
- if not all(checks.values()):
2648
+ checks = cloner.check_prerequisites(vm_config)
2649
+ required_keys = [
2650
+ "libvirt_connected",
2651
+ "kvm_available",
2652
+ "images_dir_writable",
2653
+ "genisoimage_installed",
2654
+ "qemu_img_installed",
2655
+ ]
2656
+ if checks.get("default_network_required", True):
2657
+ required_keys.append("default_network")
2658
+ if getattr(vm_config, "gui", False):
2659
+ required_keys.append("virt_viewer_installed")
2660
+
2661
+ required_checks = {k: checks.get(k) for k in required_keys if isinstance(checks.get(k), bool)}
2662
+
2663
+ if required_checks and not all(required_checks.values()):
2629
2664
  console.print("[yellow]⚠️ Prerequisites check:[/]")
2630
- for check, passed in checks.items():
2665
+ for check, passed in required_checks.items():
2631
2666
  icon = "✅" if passed else "❌"
2632
2667
  console.print(f" {icon} {check}")
2633
2668
 
clonebox/cloner.py CHANGED
@@ -15,6 +15,7 @@ import tempfile
15
15
  import time
16
16
  import urllib.request
17
17
  import uuid
18
+ import zlib
18
19
  import xml.etree.ElementTree as ET
19
20
  from dataclasses import dataclass, field
20
21
  from datetime import datetime
@@ -288,6 +289,17 @@ class SelectiveVMCloner:
288
289
  """Check if libvirt default network is active."""
289
290
  return self._default_network_state() == "active"
290
291
 
292
+ def _passt_supported(self) -> bool:
293
+ try:
294
+ if self.conn is None:
295
+ return False
296
+ if not hasattr(self.conn, "getLibVersion"):
297
+ return False
298
+ # libvirt 9.0.0 introduced the passt backend and <portForward> support
299
+ return int(self.conn.getLibVersion()) >= 9_000_000
300
+ except Exception:
301
+ return False
302
+
291
303
  def resolve_network_mode(self, config: VMConfig) -> str:
292
304
  """Resolve network mode based on config and session type."""
293
305
  mode = (config.network_mode or "auto").lower()
@@ -299,22 +311,34 @@ class SelectiveVMCloner:
299
311
  return mode
300
312
  return "default"
301
313
 
302
- def check_prerequisites(self) -> dict:
314
+ def check_prerequisites(self, config: Optional[VMConfig] = None) -> dict:
303
315
  """Check system prerequisites for VM creation."""
304
316
  images_dir = self.get_images_dir()
305
317
 
318
+ resolved_network_mode: Optional[str] = None
319
+ if config is not None:
320
+ try:
321
+ resolved_network_mode = self.resolve_network_mode(config)
322
+ except Exception:
323
+ resolved_network_mode = None
324
+
306
325
  checks = {
307
326
  "libvirt_connected": False,
308
327
  "kvm_available": False,
309
328
  "default_network": False,
329
+ "default_network_required": True,
310
330
  "images_dir_writable": False,
311
331
  "images_dir": str(images_dir),
312
332
  "session_type": "user" if self.user_session else "system",
313
333
  "genisoimage_installed": False,
314
334
  "virt_viewer_installed": False,
315
335
  "qemu_img_installed": False,
336
+ "passt_installed": shutil.which("passt") is not None,
337
+ "passt_available": False,
316
338
  }
317
339
 
340
+ checks["passt_available"] = checks["passt_installed"] and self._passt_supported()
341
+
318
342
  # Check for genisoimage
319
343
  checks["genisoimage_installed"] = shutil.which("genisoimage") is not None
320
344
 
@@ -340,8 +364,14 @@ class SelectiveVMCloner:
340
364
 
341
365
  # Check default network
342
366
  default_net_state = self._default_network_state()
343
- checks["default_network"] = default_net_state == "active"
344
- if default_net_state in {"inactive", "missing", "unknown"}:
367
+ checks["default_network_required"] = (resolved_network_mode or "default") != "user"
368
+ if checks["default_network_required"]:
369
+ checks["default_network"] = default_net_state == "active"
370
+ else:
371
+ # For user-mode networking (slirp/passt), libvirt's default network is not required.
372
+ checks["default_network"] = True
373
+
374
+ if checks["default_network_required"] and default_net_state in {"inactive", "missing", "unknown"}:
345
375
  checks["network_error"] = (
346
376
  "Default network not found or inactive.\n"
347
377
  " For user session, CloneBox can use user-mode networking (slirp) automatically.\n"
@@ -351,6 +381,9 @@ class SelectiveVMCloner:
351
381
  " Or use system session: clonebox clone . (without --user)\n"
352
382
  )
353
383
 
384
+ if resolved_network_mode is not None:
385
+ checks["network_mode"] = resolved_network_mode
386
+
354
387
  # Check images directory
355
388
  if images_dir.exists():
356
389
  checks["images_dir_writable"] = os.access(images_dir, os.W_OK)
@@ -524,6 +557,36 @@ class SelectiveVMCloner:
524
557
  f"If the VM already exists, try: clonebox clone . --name {config.name} --replace\n"
525
558
  ) from e
526
559
 
560
+ try:
561
+ if console and self.resolve_network_mode(config) == "user":
562
+ if shutil.which("passt") and self._passt_supported():
563
+ ssh_key_path = vm_dir / "ssh_key"
564
+ if ssh_key_path.exists():
565
+ ssh_port = 22000 + (zlib.crc32(config.name.encode("utf-8")) % 1000)
566
+ console.print(
567
+ f"[dim]SSH access (passthrough): ssh -i {ssh_key_path} -p {ssh_port} {config.username}@127.0.0.1[/]"
568
+ )
569
+ else:
570
+ console.print(
571
+ "[yellow]⚠️ passt not available - SSH port-forward will NOT be configured in --user mode[/]"
572
+ )
573
+ console.print(
574
+ "[dim]Install passt (and recreate VM) to enable SSH fallback, or use virsh console for live logs.[/]"
575
+ )
576
+
577
+ if self.user_session:
578
+ console.print(
579
+ f"[dim]Host serial log (when enabled): {vm_dir / 'serial.log'}[/]"
580
+ )
581
+
582
+ if console:
583
+ conn_uri = "qemu:///session" if self.user_session else "qemu:///system"
584
+ console.print(
585
+ f"[dim]Live install logs: virsh --connect {conn_uri} console {config.name}[/]"
586
+ )
587
+ except Exception:
588
+ pass
589
+
527
590
  # Start if autostart requested
528
591
  if getattr(config, "autostart", False):
529
592
  self.start_vm(config.name, open_viewer=True)
@@ -1358,6 +1421,13 @@ fi
1358
1421
  if network_mode == "user":
1359
1422
  iface = ET.SubElement(devices, "interface", type="user")
1360
1423
  ET.SubElement(iface, "model", type="virtio")
1424
+
1425
+ if shutil.which("passt") and self._passt_supported():
1426
+ ET.SubElement(iface, "backend", type="passt")
1427
+
1428
+ ssh_port = 22000 + (zlib.crc32(config.name.encode("utf-8")) % 1000)
1429
+ pf = ET.SubElement(iface, "portForward", proto="tcp", address="127.0.0.1")
1430
+ ET.SubElement(pf, "range", start=str(ssh_port), to="22")
1361
1431
  else:
1362
1432
  iface = ET.SubElement(devices, "interface", type="network")
1363
1433
  ET.SubElement(iface, "source", network="default")
@@ -1376,6 +1446,13 @@ fi
1376
1446
  serial = ET.SubElement(devices, "serial", type="pty")
1377
1447
  ET.SubElement(serial, "target", port="0")
1378
1448
 
1449
+ if self.user_session:
1450
+ vm_dir = Path(str(root_disk)).parent
1451
+ serial_log_path = str(vm_dir / "serial.log")
1452
+ serial_file = ET.SubElement(devices, "serial", type="file")
1453
+ ET.SubElement(serial_file, "source", path=serial_log_path)
1454
+ ET.SubElement(serial_file, "target", port="1")
1455
+
1379
1456
  console_elem = ET.SubElement(devices, "console", type="pty")
1380
1457
  ET.SubElement(console_elem, "target", type="serial", port="0")
1381
1458
 
@@ -1398,6 +1475,7 @@ fi
1398
1475
 
1399
1476
  # Channel for guest agent
1400
1477
  channel = ET.SubElement(devices, "channel", type="unix")
1478
+ # For both user and system sessions, let libvirt handle the path
1401
1479
  ET.SubElement(channel, "source", mode="bind")
1402
1480
  ET.SubElement(channel, "target", type="virtio", name="org.qemu.guest_agent.0")
1403
1481
 
@@ -1563,7 +1641,7 @@ fi
1563
1641
 
1564
1642
  # User-data
1565
1643
  # Add desktop environment if GUI is enabled
1566
- base_packages = ["qemu-guest-agent", "cloud-guest-utils"]
1644
+ base_packages = ["qemu-guest-agent", "cloud-guest-utils", "openssh-server"]
1567
1645
  if config.gui:
1568
1646
  base_packages.extend(
1569
1647
  [
@@ -1619,10 +1697,13 @@ fi
1619
1697
 
1620
1698
  # Phase 3: Core services
1621
1699
  runcmd_lines.append(" - echo '[3/10] 🔧 Enabling core services...'")
1622
- runcmd_lines.append(" - echo ' → [1/2] Enabling qemu-guest-agent'")
1700
+ runcmd_lines.append(" - echo ' → [1/3] Enabling qemu-guest-agent'")
1623
1701
  runcmd_lines.append(" - systemctl enable --now qemu-guest-agent || echo ' → ❌ Failed to enable qemu-guest-agent'")
1624
- runcmd_lines.append(" - echo ' → [2/2] Enabling snapd'")
1702
+ runcmd_lines.append(" - echo ' → [2/3] Enabling SSH server'")
1703
+ runcmd_lines.append(" - systemctl enable --now ssh || systemctl enable --now sshd || echo ' → ❌ Failed to enable ssh'")
1704
+ runcmd_lines.append(" - echo ' → [3/3] Enabling snapd'")
1625
1705
  runcmd_lines.append(" - systemctl enable --now snapd || echo ' → ❌ Failed to enable snapd'")
1706
+ runcmd_lines.append(" - systemctl enable --now serial-getty@ttyS0.service >/dev/null 2>&1 || true")
1626
1707
  runcmd_lines.append(" - echo ' → Waiting for snap system seed...'")
1627
1708
  runcmd_lines.append(" - timeout 300 snap wait system seed.loaded || true")
1628
1709
  runcmd_lines.append(" - echo ' → ✓ [3/10] Core services enabled'")
@@ -2544,7 +2625,12 @@ if __name__ == "__main__":
2544
2625
  runcmd_yaml = "\n".join(runcmd_lines) if runcmd_lines else ""
2545
2626
 
2546
2627
  # Build bootcmd combining mount commands and extra security bootcmds
2547
- bootcmd_lines = list(bootcmd_extra) if bootcmd_extra else []
2628
+ bootcmd_lines = [
2629
+ ' - sh -c "echo \"[clonebox] bootcmd: enabling serial console (ttyS0)\" > /dev/ttyS0 || true"',
2630
+ ' - systemctl enable --now serial-getty@ttyS0.service >/dev/null 2>&1 || true',
2631
+ ]
2632
+ if bootcmd_extra:
2633
+ bootcmd_lines.extend(list(bootcmd_extra))
2548
2634
 
2549
2635
  bootcmd_block = ""
2550
2636
  if bootcmd_lines:
@@ -2572,6 +2658,10 @@ users:
2572
2658
 
2573
2659
  user_data_header += f"ssh_pwauth: {ssh_pwauth}\n"
2574
2660
 
2661
+ output_targets = "/dev/ttyS0"
2662
+ if user_session:
2663
+ output_targets += " /dev/ttyS1"
2664
+
2575
2665
  # Assemble final user-data
2576
2666
  user_data = f"""{user_data_header}
2577
2667
  # Make sure root partition + filesystem grows to fill the qcow2 disk size
@@ -2584,6 +2674,9 @@ resize_rootfs: true
2584
2674
  # Update package cache and upgrade
2585
2675
  package_update: true
2586
2676
  package_upgrade: false
2677
+
2678
+ output:
2679
+ all: "| tee -a /var/log/cloud-init-output.log {output_targets}"
2587
2680
  {bootcmd_block}
2588
2681
 
2589
2682
  # Install packages moved to runcmd for better logging
@@ -0,0 +1 @@
1
+ from .validator import VMValidator
@@ -0,0 +1,303 @@
1
+ from typing import Dict, List, Optional
2
+
3
+ from rich.panel import Panel
4
+ from rich.table import Table
5
+
6
+
7
+ class AppValidationMixin:
8
+ def validate_apps(self) -> Dict:
9
+ setup_in_progress = self._setup_in_progress() is True
10
+ packages = self.config.get("packages", [])
11
+ snap_packages = self.config.get("snap_packages", [])
12
+ copy_paths = self.config.get("copy_paths", None)
13
+ if not isinstance(copy_paths, dict) or not copy_paths:
14
+ copy_paths = self.config.get("app_data_paths", {})
15
+ vm_user = self.config.get("vm", {}).get("username", "ubuntu")
16
+
17
+ snap_app_specs = {
18
+ "pycharm-community": {
19
+ "process_patterns": ["pycharm-community", "pycharm", "jetbrains"],
20
+ "required_interfaces": [
21
+ "desktop",
22
+ "desktop-legacy",
23
+ "x11",
24
+ "wayland",
25
+ "home",
26
+ "network",
27
+ ],
28
+ },
29
+ "chromium": {
30
+ "process_patterns": ["chromium", "chromium-browser"],
31
+ "required_interfaces": [
32
+ "desktop",
33
+ "desktop-legacy",
34
+ "x11",
35
+ "wayland",
36
+ "home",
37
+ "network",
38
+ ],
39
+ },
40
+ "firefox": {
41
+ "process_patterns": ["firefox"],
42
+ "required_interfaces": [
43
+ "desktop",
44
+ "desktop-legacy",
45
+ "x11",
46
+ "wayland",
47
+ "home",
48
+ "network",
49
+ ],
50
+ },
51
+ "code": {
52
+ "process_patterns": ["code"],
53
+ "required_interfaces": [
54
+ "desktop",
55
+ "desktop-legacy",
56
+ "x11",
57
+ "wayland",
58
+ "home",
59
+ "network",
60
+ ],
61
+ },
62
+ }
63
+
64
+ expected = []
65
+
66
+ if "firefox" in packages:
67
+ expected.append("firefox")
68
+
69
+ for snap_pkg in snap_packages:
70
+ if snap_pkg in snap_app_specs:
71
+ expected.append(snap_pkg)
72
+
73
+ for _, guest_path in copy_paths.items():
74
+ if guest_path == "/home/ubuntu/.config/google-chrome":
75
+ expected.append("google-chrome")
76
+ break
77
+
78
+ expected = sorted(set(expected))
79
+ if not expected:
80
+ return self.results["apps"]
81
+
82
+ self.console.print("\n[bold]🧩 Validating Apps...[/]")
83
+ table = Table(title="App Validation", border_style="cyan")
84
+ table.add_column("App", style="bold")
85
+ table.add_column("Installed", justify="center")
86
+ table.add_column("Profile", justify="center")
87
+ table.add_column("Running", justify="center")
88
+ table.add_column("PID", justify="right", style="dim")
89
+ table.add_column("Note", style="dim")
90
+
91
+ def _pgrep_pattern(pattern: str) -> str:
92
+ if not pattern:
93
+ return pattern
94
+ return f"[{pattern[0]}]{pattern[1:]}"
95
+
96
+ def _check_any_process_running(patterns: List[str]) -> Optional[bool]:
97
+ for pattern in patterns:
98
+ p = _pgrep_pattern(pattern)
99
+ out = self._exec_in_vm(
100
+ f"pgrep -u {vm_user} -f '{p}' >/dev/null 2>&1 && echo yes || echo no",
101
+ timeout=10,
102
+ )
103
+ if out is None:
104
+ return None
105
+ if out == "yes":
106
+ return True
107
+ return False
108
+
109
+ def _find_first_pid(patterns: List[str]) -> Optional[str]:
110
+ for pattern in patterns:
111
+ p = _pgrep_pattern(pattern)
112
+ out = self._exec_in_vm(
113
+ f"pgrep -u {vm_user} -f '{p}' 2>/dev/null | head -n 1 || true",
114
+ timeout=10,
115
+ )
116
+ if out is None:
117
+ return None
118
+ pid = out.strip()
119
+ if pid:
120
+ return pid
121
+ return ""
122
+
123
+ def _collect_app_logs(app_name: str) -> str:
124
+ chunks: List[str] = []
125
+
126
+ def add(cmd: str, title: str, timeout: int = 20):
127
+ out = self._exec_in_vm(cmd, timeout=timeout)
128
+ if out is None:
129
+ return
130
+ out = out.strip()
131
+ if not out:
132
+ return
133
+ chunks.append(f"{title}\n$ {cmd}\n{out}")
134
+
135
+ if app_name in snap_app_specs:
136
+ add(f"snap connections {app_name} 2>/dev/null | head -n 40", "Snap connections")
137
+ add(f"snap logs {app_name} -n 80 2>/dev/null | tail -n 60", "Snap logs")
138
+
139
+ if app_name == "pycharm-community":
140
+ add(
141
+ "tail -n 80 /home/ubuntu/snap/pycharm-community/common/.config/JetBrains/*/log/idea.log 2>/dev/null || true",
142
+ "idea.log",
143
+ )
144
+
145
+ if app_name == "google-chrome":
146
+ add(
147
+ "journalctl -n 200 --no-pager 2>/dev/null | grep -i chrome | tail -n 60 || true",
148
+ "Journal (chrome)",
149
+ )
150
+ if app_name == "firefox":
151
+ add(
152
+ "journalctl -n 200 --no-pager 2>/dev/null | grep -i firefox | tail -n 60 || true",
153
+ "Journal (firefox)",
154
+ )
155
+
156
+ return "\n\n".join(chunks)
157
+
158
+ def _snap_missing_interfaces(snap_name: str, required: List[str]) -> Optional[List[str]]:
159
+ out = self._exec_in_vm(
160
+ f"snap connections {snap_name} 2>/dev/null | awk 'NR>1{{print $1, $3}}'",
161
+ timeout=15,
162
+ )
163
+ if out is None:
164
+ return None
165
+
166
+ connected = set()
167
+ for line in out.splitlines():
168
+ parts = line.split()
169
+ if len(parts) < 2:
170
+ continue
171
+ iface, slot = parts[0], parts[1]
172
+ if slot != "-":
173
+ connected.add(iface)
174
+
175
+ missing = [i for i in required if i not in connected]
176
+ return missing
177
+
178
+ def _check_dir_nonempty(path: str) -> bool:
179
+ out = self._exec_in_vm(
180
+ f"test -d {path} && [ $(ls -A {path} 2>/dev/null | wc -l) -gt 0 ] && echo yes || echo no",
181
+ timeout=10,
182
+ )
183
+ return out == "yes"
184
+
185
+ for app in expected:
186
+ self.results["apps"]["total"] += 1
187
+ installed = False
188
+ profile_ok = False
189
+ running: Optional[bool] = None
190
+ pid: Optional[str] = None
191
+ note = ""
192
+ pending = False
193
+
194
+ if app == "firefox":
195
+ installed = (
196
+ self._exec_in_vm("command -v firefox >/dev/null 2>&1 && echo yes || echo no") == "yes"
197
+ )
198
+ if _check_dir_nonempty("/home/ubuntu/snap/firefox/common/.mozilla/firefox"):
199
+ profile_ok = True
200
+ elif _check_dir_nonempty("/home/ubuntu/.mozilla/firefox"):
201
+ profile_ok = True
202
+
203
+ if installed:
204
+ running = _check_any_process_running(["firefox"])
205
+ pid = _find_first_pid(["firefox"]) if running else ""
206
+
207
+ elif app in snap_app_specs:
208
+ installed = (
209
+ self._exec_in_vm(f"snap list {app} >/dev/null 2>&1 && echo yes || echo no") == "yes"
210
+ )
211
+ if app == "pycharm-community":
212
+ profile_ok = _check_dir_nonempty(
213
+ "/home/ubuntu/snap/pycharm-community/common/.config/JetBrains"
214
+ )
215
+ else:
216
+ profile_ok = True
217
+
218
+ if installed:
219
+ patterns = snap_app_specs[app]["process_patterns"]
220
+ running = _check_any_process_running(patterns)
221
+ pid = _find_first_pid(patterns) if running else ""
222
+ if running is False:
223
+ missing_ifaces = _snap_missing_interfaces(
224
+ app,
225
+ snap_app_specs[app]["required_interfaces"],
226
+ )
227
+ if missing_ifaces:
228
+ note = f"missing interfaces: {', '.join(missing_ifaces)}"
229
+ elif missing_ifaces == []:
230
+ note = "not running"
231
+ else:
232
+ note = "interfaces unknown"
233
+
234
+ elif app == "google-chrome":
235
+ installed = (
236
+ self._exec_in_vm(
237
+ "(command -v google-chrome >/dev/null 2>&1 || command -v google-chrome-stable >/dev/null 2>&1) && echo yes || echo no"
238
+ )
239
+ == "yes"
240
+ )
241
+ profile_ok = _check_dir_nonempty("/home/ubuntu/.config/google-chrome")
242
+
243
+ if installed:
244
+ running = _check_any_process_running(["google-chrome", "google-chrome-stable"])
245
+ pid = (
246
+ _find_first_pid(["google-chrome", "google-chrome-stable"]) if running else ""
247
+ )
248
+
249
+ if self.require_running_apps and installed and profile_ok and running is None:
250
+ note = note or "running unknown"
251
+
252
+ if setup_in_progress and not installed:
253
+ pending = True
254
+ note = note or "setup in progress"
255
+ elif setup_in_progress and not profile_ok:
256
+ pending = True
257
+ note = note or "profile import in progress"
258
+
259
+ running_icon = (
260
+ "[dim]—[/]"
261
+ if not installed
262
+ else (
263
+ "[green]✅[/]" if running is True else "[yellow]⚠️[/]" if running is False else "[dim]?[/]"
264
+ )
265
+ )
266
+
267
+ pid_value = "—" if not installed else ("?" if pid is None else (pid or "—"))
268
+
269
+ installed_icon = "[green]✅[/]" if installed else ("[yellow]⏳[/]" if pending else "[red]❌[/]")
270
+ profile_icon = "[green]✅[/]" if profile_ok else ("[yellow]⏳[/]" if pending else "[red]❌[/]")
271
+
272
+ table.add_row(app, installed_icon, profile_icon, running_icon, pid_value, note)
273
+
274
+ should_pass = installed and profile_ok
275
+ if self.require_running_apps and installed and profile_ok:
276
+ should_pass = running is True
277
+
278
+ if pending:
279
+ self.results["apps"]["skipped"] += 1
280
+ elif should_pass:
281
+ self.results["apps"]["passed"] += 1
282
+ else:
283
+ self.results["apps"]["failed"] += 1
284
+
285
+ self.results["apps"]["details"].append(
286
+ {
287
+ "app": app,
288
+ "installed": installed,
289
+ "profile": profile_ok,
290
+ "running": running,
291
+ "pid": pid,
292
+ "note": note,
293
+ "pending": pending,
294
+ }
295
+ )
296
+
297
+ if installed and profile_ok and running in (False, None):
298
+ logs = _collect_app_logs(app)
299
+ if logs:
300
+ self.console.print(Panel(logs, title=f"Logs: {app}", border_style="yellow"))
301
+
302
+ self.console.print(table)
303
+ return self.results["apps"]