clonebox 0.1.2__py3-none-any.whl → 0.1.4__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
@@ -3,40 +3,38 @@
3
3
  CloneBox CLI - Interactive command-line interface for creating VMs.
4
4
  """
5
5
 
6
- import sys
7
- import os
8
- import json
9
6
  import argparse
10
- from pathlib import Path
11
- from typing import Optional
7
+ import json
8
+ import os
9
+ import sys
12
10
  from datetime import datetime
11
+ from pathlib import Path
13
12
 
13
+ import questionary
14
14
  import yaml
15
-
15
+ from questionary import Style
16
16
  from rich.console import Console
17
- from rich.table import Table
18
17
  from rich.panel import Panel
19
18
  from rich.progress import Progress, SpinnerColumn, TextColumn
20
- from rich import print as rprint
21
- import questionary
22
- from questionary import Style
19
+ from rich.table import Table
23
20
 
24
21
  from clonebox import __version__
25
22
  from clonebox.cloner import SelectiveVMCloner, VMConfig
26
23
  from clonebox.detector import SystemDetector
27
24
 
28
-
29
25
  # Custom questionary style
30
- custom_style = Style([
31
- ('qmark', 'fg:cyan bold'),
32
- ('question', 'bold'),
33
- ('answer', 'fg:green'),
34
- ('pointer', 'fg:cyan bold'),
35
- ('highlighted', 'fg:cyan bold'),
36
- ('selected', 'fg:green'),
37
- ('separator', 'fg:gray'),
38
- ('instruction', 'fg:gray italic'),
39
- ])
26
+ custom_style = Style(
27
+ [
28
+ ("qmark", "fg:cyan bold"),
29
+ ("question", "bold"),
30
+ ("answer", "fg:green"),
31
+ ("pointer", "fg:cyan bold"),
32
+ ("highlighted", "fg:cyan bold"),
33
+ ("selected", "fg:green"),
34
+ ("separator", "fg:gray"),
35
+ ("instruction", "fg:gray italic"),
36
+ ]
37
+ )
40
38
 
41
39
  console = Console()
42
40
 
@@ -61,140 +59,140 @@ def print_banner():
61
59
  def interactive_mode():
62
60
  """Run the interactive VM creation wizard."""
63
61
  print_banner()
64
-
62
+
65
63
  console.print("[bold cyan]🔍 Detecting system state...[/]\n")
66
-
64
+
67
65
  with Progress(
68
66
  SpinnerColumn(),
69
67
  TextColumn("[progress.description]{task.description}"),
70
68
  console=console,
71
- transient=True
69
+ transient=True,
72
70
  ) as progress:
73
71
  task = progress.add_task("Scanning services, apps, and paths...", total=None)
74
72
  detector = SystemDetector()
75
73
  snapshot = detector.detect_all()
76
74
  sys_info = detector.get_system_info()
77
75
  docker_containers = detector.detect_docker_containers()
78
-
76
+
79
77
  # Show system info
80
- console.print(Panel(
81
- f"[bold]Hostname:[/] {sys_info['hostname']}\n"
82
- f"[bold]User:[/] {sys_info['user']}\n"
83
- f"[bold]CPU:[/] {sys_info['cpu_count']} cores\n"
84
- f"[bold]RAM:[/] {sys_info['memory_available_gb']:.1f} / {sys_info['memory_total_gb']:.1f} GB available\n"
85
- f"[bold]Disk:[/] {sys_info['disk_free_gb']:.1f} / {sys_info['disk_total_gb']:.1f} GB free",
86
- title="[bold cyan]System Info[/]",
87
- border_style="cyan"
88
- ))
89
-
78
+ console.print(
79
+ Panel(
80
+ f"[bold]Hostname:[/] {sys_info['hostname']}\n"
81
+ f"[bold]User:[/] {sys_info['user']}\n"
82
+ f"[bold]CPU:[/] {sys_info['cpu_count']} cores\n"
83
+ f"[bold]RAM:[/] {sys_info['memory_available_gb']:.1f} / {sys_info['memory_total_gb']:.1f} GB available\n"
84
+ f"[bold]Disk:[/] {sys_info['disk_free_gb']:.1f} / {sys_info['disk_total_gb']:.1f} GB free",
85
+ title="[bold cyan]System Info[/]",
86
+ border_style="cyan",
87
+ )
88
+ )
89
+
90
90
  console.print()
91
-
91
+
92
92
  # === VM Name ===
93
- vm_name = questionary.text(
94
- "VM name:",
95
- default="clonebox-vm",
96
- style=custom_style
97
- ).ask()
98
-
93
+ vm_name = questionary.text("VM name:", default="clonebox-vm", style=custom_style).ask()
94
+
99
95
  if not vm_name:
100
96
  console.print("[red]Cancelled.[/]")
101
97
  return
102
-
98
+
103
99
  # === RAM ===
104
- max_ram = int(sys_info['memory_available_gb'] * 1024 * 0.75) # 75% of available
100
+ max_ram = int(sys_info["memory_available_gb"] * 1024 * 0.75) # 75% of available
105
101
  default_ram = min(4096, max_ram)
106
-
102
+
107
103
  ram_mb = questionary.text(
108
- f"RAM (MB) [max recommended: {max_ram}]:",
109
- default=str(default_ram),
110
- style=custom_style
104
+ f"RAM (MB) [max recommended: {max_ram}]:", default=str(default_ram), style=custom_style
111
105
  ).ask()
112
106
  ram_mb = int(ram_mb) if ram_mb else default_ram
113
-
107
+
114
108
  # === vCPUs ===
115
- max_vcpus = sys_info['cpu_count']
109
+ max_vcpus = sys_info["cpu_count"]
116
110
  default_vcpus = max(2, max_vcpus // 2)
117
-
111
+
118
112
  vcpus = questionary.text(
119
- f"vCPUs [max: {max_vcpus}]:",
120
- default=str(default_vcpus),
121
- style=custom_style
113
+ f"vCPUs [max: {max_vcpus}]:", default=str(default_vcpus), style=custom_style
122
114
  ).ask()
123
115
  vcpus = int(vcpus) if vcpus else default_vcpus
124
-
116
+
125
117
  # === Services Selection ===
126
118
  console.print("\n[bold cyan]📦 Select services to include in VM:[/]")
127
-
119
+
128
120
  service_choices = []
129
121
  for svc in snapshot.running_services:
130
122
  label = f"{svc.name} ({svc.status})"
131
123
  if svc.description:
132
124
  label += f" - {svc.description[:40]}"
133
125
  service_choices.append(questionary.Choice(label, value=svc.name))
134
-
126
+
135
127
  selected_services = []
136
128
  if service_choices:
137
- selected_services = questionary.checkbox(
138
- "Services (space to select, enter to confirm):",
139
- choices=service_choices,
140
- style=custom_style
141
- ).ask() or []
129
+ selected_services = (
130
+ questionary.checkbox(
131
+ "Services (space to select, enter to confirm):",
132
+ choices=service_choices,
133
+ style=custom_style,
134
+ ).ask()
135
+ or []
136
+ )
142
137
  else:
143
138
  console.print("[dim] No interesting services detected[/]")
144
-
139
+
145
140
  # === Applications/Processes Selection ===
146
141
  console.print("\n[bold cyan]🚀 Select applications to track:[/]")
147
-
142
+
148
143
  app_choices = []
149
144
  for app in snapshot.running_apps[:20]: # Limit to top 20
150
145
  label = f"{app.name} (PID: {app.pid}, {app.memory_mb:.0f} MB)"
151
146
  if app.working_dir:
152
147
  label += f" @ {app.working_dir[:30]}"
153
148
  app_choices.append(questionary.Choice(label, value=app))
154
-
149
+
155
150
  selected_apps = []
156
151
  if app_choices:
157
- selected_apps = questionary.checkbox(
158
- "Applications (will add their working dirs):",
159
- choices=app_choices,
160
- style=custom_style
161
- ).ask() or []
152
+ selected_apps = (
153
+ questionary.checkbox(
154
+ "Applications (will add their working dirs):",
155
+ choices=app_choices,
156
+ style=custom_style,
157
+ ).ask()
158
+ or []
159
+ )
162
160
  else:
163
161
  console.print("[dim] No interesting applications detected[/]")
164
-
162
+
165
163
  # === Docker Containers ===
166
164
  if docker_containers:
167
165
  console.print("\n[bold cyan]🐳 Docker containers detected:[/]")
168
-
166
+
169
167
  container_choices = [
170
- questionary.Choice(
171
- f"{c['name']} ({c['image']}) - {c['status']}",
172
- value=c['name']
173
- )
168
+ questionary.Choice(f"{c['name']} ({c['image']}) - {c['status']}", value=c["name"])
174
169
  for c in docker_containers
175
170
  ]
176
-
177
- selected_containers = questionary.checkbox(
178
- "Containers (will share docker socket):",
179
- choices=container_choices,
180
- style=custom_style
181
- ).ask() or []
182
-
171
+
172
+ selected_containers = (
173
+ questionary.checkbox(
174
+ "Containers (will share docker socket):",
175
+ choices=container_choices,
176
+ style=custom_style,
177
+ ).ask()
178
+ or []
179
+ )
180
+
183
181
  # If any docker selected, add docker socket
184
182
  if selected_containers:
185
183
  if "docker" not in selected_services:
186
184
  selected_services.append("docker")
187
-
185
+
188
186
  # === Paths Selection ===
189
187
  console.print("\n[bold cyan]📁 Select paths to mount in VM:[/]")
190
-
188
+
191
189
  # Group paths by type
192
190
  path_groups = {}
193
191
  for p in snapshot.paths:
194
192
  if p.type not in path_groups:
195
193
  path_groups[p.type] = []
196
194
  path_groups[p.type].append(p)
197
-
195
+
198
196
  path_choices = []
199
197
  for ptype in ["project", "config", "data"]:
200
198
  if ptype in path_groups:
@@ -204,102 +202,114 @@ def interactive_mode():
204
202
  if p.description:
205
203
  label += f" - {p.description}"
206
204
  path_choices.append(questionary.Choice(label, value=p.path))
207
-
205
+
208
206
  selected_paths = []
209
207
  if path_choices:
210
- selected_paths = questionary.checkbox(
211
- "Paths (will be bind-mounted read-write):",
212
- choices=path_choices,
213
- style=custom_style
214
- ).ask() or []
215
-
208
+ selected_paths = (
209
+ questionary.checkbox(
210
+ "Paths (will be bind-mounted read-write):", choices=path_choices, style=custom_style
211
+ ).ask()
212
+ or []
213
+ )
214
+
216
215
  # Add working directories from selected applications
217
216
  for app in selected_apps:
218
217
  if app.working_dir and app.working_dir not in selected_paths:
219
218
  selected_paths.append(app.working_dir)
220
-
219
+
221
220
  # === Additional Packages ===
222
221
  console.print("\n[bold cyan]📦 Additional packages to install:[/]")
223
-
222
+
224
223
  common_packages = [
225
- "build-essential", "git", "curl", "wget", "vim", "htop",
226
- "python3", "python3-pip", "python3-venv",
227
- "nodejs", "npm",
228
- "docker.io", "docker-compose",
229
- "nginx", "postgresql", "redis",
224
+ "build-essential",
225
+ "git",
226
+ "curl",
227
+ "wget",
228
+ "vim",
229
+ "htop",
230
+ "python3",
231
+ "python3-pip",
232
+ "python3-venv",
233
+ "nodejs",
234
+ "npm",
235
+ "docker.io",
236
+ "docker-compose",
237
+ "nginx",
238
+ "postgresql",
239
+ "redis",
230
240
  ]
231
-
241
+
232
242
  pkg_choices = [questionary.Choice(pkg, value=pkg) for pkg in common_packages]
233
-
234
- selected_packages = questionary.checkbox(
235
- "Packages (space to select):",
236
- choices=pkg_choices,
237
- style=custom_style
238
- ).ask() or []
239
-
243
+
244
+ selected_packages = (
245
+ questionary.checkbox(
246
+ "Packages (space to select):", choices=pkg_choices, style=custom_style
247
+ ).ask()
248
+ or []
249
+ )
250
+
240
251
  # Add custom packages
241
252
  custom_pkgs = questionary.text(
242
- "Additional packages (space-separated):",
243
- default="",
244
- style=custom_style
253
+ "Additional packages (space-separated):", default="", style=custom_style
245
254
  ).ask()
246
-
255
+
247
256
  if custom_pkgs:
248
257
  selected_packages.extend(custom_pkgs.split())
249
-
258
+
250
259
  # === Base Image ===
251
260
  base_image = questionary.text(
252
- "Base image path (optional, leave empty for blank disk):",
253
- default="",
254
- style=custom_style
261
+ "Base image path (optional, leave empty for blank disk):", default="", style=custom_style
255
262
  ).ask()
256
-
263
+
257
264
  # === GUI ===
258
265
  enable_gui = questionary.confirm(
259
- "Enable SPICE graphics (GUI)?",
260
- default=True,
261
- style=custom_style
266
+ "Enable SPICE graphics (GUI)?", default=True, style=custom_style
262
267
  ).ask()
263
-
268
+
264
269
  # === Summary ===
265
270
  console.print("\n")
266
-
271
+
267
272
  # Build paths mapping
268
273
  paths_mapping = {}
269
274
  for idx, host_path in enumerate(selected_paths):
270
275
  guest_path = f"/mnt/host{idx}"
271
276
  paths_mapping[host_path] = guest_path
272
-
277
+
273
278
  # Summary table
274
279
  summary_table = Table(title="VM Configuration Summary", border_style="cyan")
275
280
  summary_table.add_column("Setting", style="bold")
276
281
  summary_table.add_column("Value")
277
-
282
+
278
283
  summary_table.add_row("Name", vm_name)
279
284
  summary_table.add_row("RAM", f"{ram_mb} MB")
280
285
  summary_table.add_row("vCPUs", str(vcpus))
281
286
  summary_table.add_row("Services", ", ".join(selected_services) or "None")
282
- summary_table.add_row("Packages", ", ".join(selected_packages[:5]) + ("..." if len(selected_packages) > 5 else "") or "None")
287
+ summary_table.add_row(
288
+ "Packages",
289
+ ", ".join(selected_packages[:5]) + ("..." if len(selected_packages) > 5 else "") or "None",
290
+ )
283
291
  summary_table.add_row("Paths", f"{len(paths_mapping)} bind mounts")
284
292
  summary_table.add_row("GUI", "Yes (SPICE)" if enable_gui else "No")
285
-
293
+
286
294
  console.print(summary_table)
287
-
295
+
288
296
  if paths_mapping:
289
297
  console.print("\n[bold]Bind mounts:[/]")
290
298
  for host, guest in paths_mapping.items():
291
299
  console.print(f" [cyan]{host}[/] → [green]{guest}[/]")
292
-
300
+
293
301
  console.print()
294
-
302
+
295
303
  # === Confirm ===
296
- if not questionary.confirm("Create VM with these settings?", default=True, style=custom_style).ask():
304
+ if not questionary.confirm(
305
+ "Create VM with these settings?", default=True, style=custom_style
306
+ ).ask():
297
307
  console.print("[yellow]Cancelled.[/]")
298
308
  return
299
-
309
+
300
310
  # === Create VM ===
301
311
  console.print("\n[bold cyan]🔧 Creating VM...[/]\n")
302
-
312
+
303
313
  config = VMConfig(
304
314
  name=vm_name,
305
315
  ram_mb=ram_mb,
@@ -310,10 +320,10 @@ def interactive_mode():
310
320
  packages=selected_packages,
311
321
  services=selected_services,
312
322
  )
313
-
323
+
314
324
  try:
315
- cloner = SelectiveVMCloner()
316
-
325
+ cloner = SelectiveVMCloner(user_session=user_session)
326
+
317
327
  # Check prerequisites
318
328
  checks = cloner.check_prerequisites()
319
329
  if not all(checks.values()):
@@ -321,26 +331,26 @@ def interactive_mode():
321
331
  for check, passed in checks.items():
322
332
  icon = "✅" if passed else "❌"
323
333
  console.print(f" {icon} {check}")
324
-
334
+
325
335
  if not checks["libvirt_connected"]:
326
336
  console.print("\n[red]Cannot proceed without libvirt connection.[/]")
327
337
  console.print("Try: [cyan]sudo systemctl start libvirtd[/]")
328
338
  return
329
-
339
+
330
340
  vm_uuid = cloner.create_vm(config, console=console)
331
-
341
+
332
342
  # Ask to start
333
343
  if questionary.confirm("Start VM now?", default=True, style=custom_style).ask():
334
344
  cloner.start_vm(vm_name, open_viewer=enable_gui, console=console)
335
345
  console.print("\n[bold green]🎉 VM is running![/]")
336
-
346
+
337
347
  if paths_mapping:
338
348
  console.print("\n[bold]Inside the VM, mount shared folders with:[/]")
339
349
  for idx, (host, guest) in enumerate(paths_mapping.items()):
340
350
  console.print(f" [cyan]sudo mount -t 9p -o trans=virtio mount{idx} {guest}[/]")
341
-
351
+
342
352
  console.print(f"\n[dim]VM UUID: {vm_uuid}[/]")
343
-
353
+
344
354
  except Exception as e:
345
355
  console.print(f"\n[red]❌ Error: {e}[/]")
346
356
  raise
@@ -349,7 +359,7 @@ def interactive_mode():
349
359
  def cmd_create(args):
350
360
  """Create VM from JSON config."""
351
361
  config_data = json.loads(args.config)
352
-
362
+
353
363
  config = VMConfig(
354
364
  name=args.name,
355
365
  ram_mb=args.ram,
@@ -360,40 +370,40 @@ def cmd_create(args):
360
370
  packages=config_data.get("packages", []),
361
371
  services=config_data.get("services", []),
362
372
  )
363
-
373
+
364
374
  cloner = SelectiveVMCloner()
365
375
  vm_uuid = cloner.create_vm(config, console=console)
366
-
376
+
367
377
  if args.start:
368
378
  cloner.start_vm(args.name, open_viewer=not args.no_gui, console=console)
369
-
379
+
370
380
  console.print(f"[green]✅ VM created: {vm_uuid}[/]")
371
381
 
372
382
 
373
383
  def cmd_start(args):
374
384
  """Start a VM or create from .clonebox.yaml."""
375
385
  name = args.name
376
-
386
+
377
387
  # Check if it's a path (contains / or . or ~)
378
388
  if name and (name.startswith(".") or name.startswith("/") or name.startswith("~")):
379
389
  # Treat as path - load .clonebox.yaml
380
390
  target_path = Path(name).expanduser().resolve()
381
-
391
+
382
392
  if target_path.is_dir():
383
393
  config_file = target_path / CLONEBOX_CONFIG_FILE
384
394
  else:
385
395
  config_file = target_path
386
-
396
+
387
397
  if not config_file.exists():
388
398
  console.print(f"[red]❌ Config not found: {config_file}[/]")
389
399
  console.print(f"[dim]Run 'clonebox clone {target_path}' first to generate config[/]")
390
400
  return
391
-
401
+
392
402
  console.print(f"[bold cyan]📦 Loading config: {config_file}[/]\n")
393
-
403
+
394
404
  config = load_clonebox_config(config_file)
395
405
  vm_name = config["vm"]["name"]
396
-
406
+
397
407
  # Check if VM already exists
398
408
  cloner = SelectiveVMCloner()
399
409
  try:
@@ -404,19 +414,19 @@ def cmd_start(args):
404
414
  return
405
415
  except:
406
416
  pass
407
-
417
+
408
418
  # Create new VM from config
409
419
  console.print(f"[cyan]Creating VM '{vm_name}' from config...[/]\n")
410
420
  vm_uuid = create_vm_from_config(config, start=True)
411
421
  console.print(f"\n[bold green]🎉 VM '{vm_name}' is running![/]")
412
422
  console.print(f"[dim]UUID: {vm_uuid}[/]")
413
-
423
+
414
424
  if config.get("paths"):
415
425
  console.print("\n[bold]Inside VM, mount paths with:[/]")
416
426
  for idx, (host, guest) in enumerate(config["paths"].items()):
417
427
  console.print(f" [cyan]sudo mount -t 9p -o trans=virtio mount{idx} {guest}[/]")
418
428
  return
419
-
429
+
420
430
  # Default: treat as VM name
421
431
  if not name:
422
432
  # No argument - check current directory for .clonebox.yaml
@@ -426,10 +436,12 @@ def cmd_start(args):
426
436
  args.name = "."
427
437
  return cmd_start(args)
428
438
  else:
429
- console.print("[red]❌ No VM name specified and no .clonebox.yaml in current directory[/]")
439
+ console.print(
440
+ "[red]❌ No VM name specified and no .clonebox.yaml in current directory[/]"
441
+ )
430
442
  console.print("[dim]Usage: clonebox start <vm-name> or clonebox start .[/]")
431
443
  return
432
-
444
+
433
445
  cloner = SelectiveVMCloner()
434
446
  cloner.start_vm(name, open_viewer=not args.no_viewer, console=console)
435
447
 
@@ -444,13 +456,11 @@ def cmd_delete(args):
444
456
  """Delete a VM."""
445
457
  if not args.yes:
446
458
  if not questionary.confirm(
447
- f"Delete VM '{args.name}' and its storage?",
448
- default=False,
449
- style=custom_style
459
+ f"Delete VM '{args.name}' and its storage?", default=False, style=custom_style
450
460
  ).ask():
451
461
  console.print("[yellow]Cancelled.[/]")
452
462
  return
453
-
463
+
454
464
  cloner = SelectiveVMCloner()
455
465
  cloner.delete_vm(args.name, delete_storage=not args.keep_storage, console=console)
456
466
 
@@ -459,20 +469,20 @@ def cmd_list(args):
459
469
  """List all VMs."""
460
470
  cloner = SelectiveVMCloner()
461
471
  vms = cloner.list_vms()
462
-
472
+
463
473
  if not vms:
464
474
  console.print("[dim]No VMs found.[/]")
465
475
  return
466
-
476
+
467
477
  table = Table(title="Virtual Machines", border_style="cyan")
468
478
  table.add_column("Name", style="bold")
469
479
  table.add_column("State")
470
480
  table.add_column("UUID", style="dim")
471
-
481
+
472
482
  for vm in vms:
473
483
  state_style = "green" if vm["state"] == "running" else "dim"
474
484
  table.add_row(vm["name"], f"[{state_style}]{vm['state']}[/]", vm["uuid"][:8])
475
-
485
+
476
486
  console.print(table)
477
487
 
478
488
 
@@ -491,64 +501,71 @@ def deduplicate_list(items: list, key=None) -> list:
491
501
  return result
492
502
 
493
503
 
494
- def generate_clonebox_yaml(snapshot, detector, deduplicate: bool = True,
495
- target_path: str = None, vm_name: str = None) -> str:
504
+ def generate_clonebox_yaml(
505
+ snapshot,
506
+ detector,
507
+ deduplicate: bool = True,
508
+ target_path: str = None,
509
+ vm_name: str = None,
510
+ network_mode: str = "auto",
511
+ ) -> str:
496
512
  """Generate YAML config from system snapshot."""
497
513
  sys_info = detector.get_system_info()
498
-
514
+
499
515
  # Collect services
500
516
  services = [s.name for s in snapshot.running_services]
501
517
  if deduplicate:
502
518
  services = deduplicate_list(services)
503
-
519
+
504
520
  # Collect paths with types
505
521
  paths_by_type = {"project": [], "config": [], "data": []}
506
522
  for p in snapshot.paths:
507
523
  if p.type in paths_by_type:
508
524
  paths_by_type[p.type].append(p.path)
509
-
525
+
510
526
  if deduplicate:
511
527
  for ptype in paths_by_type:
512
528
  paths_by_type[ptype] = deduplicate_list(paths_by_type[ptype])
513
-
529
+
514
530
  # Collect working directories from running apps
515
531
  working_dirs = []
516
532
  for app in snapshot.applications:
517
533
  if app.working_dir and app.working_dir != "/" and app.working_dir.startswith("/home"):
518
534
  working_dirs.append(app.working_dir)
519
-
535
+
520
536
  if deduplicate:
521
537
  working_dirs = deduplicate_list(working_dirs)
522
-
538
+
523
539
  # If target_path specified, prioritize it
524
540
  if target_path:
525
- target_path = str(Path(target_path).resolve())
526
- if target_path not in paths_by_type["project"]:
527
- paths_by_type["project"].insert(0, target_path)
528
-
541
+ target_path = Path(target_path).resolve()
542
+ target_str = str(target_path)
543
+ if target_str not in paths_by_type["project"]:
544
+ paths_by_type["project"].insert(0, target_str)
545
+
529
546
  # Build paths mapping
530
547
  paths_mapping = {}
531
548
  idx = 0
532
549
  for host_path in paths_by_type["project"][:5]: # Limit projects
533
550
  paths_mapping[host_path] = f"/mnt/project{idx}"
534
551
  idx += 1
535
-
552
+
536
553
  for host_path in working_dirs[:3]: # Limit working dirs
537
554
  if host_path not in paths_mapping:
538
555
  paths_mapping[host_path] = f"/mnt/workdir{idx}"
539
556
  idx += 1
540
-
557
+
541
558
  # Determine VM name
542
559
  if not vm_name:
543
560
  if target_path:
544
- vm_name = f"clone-{Path(target_path).name}"
561
+ vm_name = f"clone-{target_path.name}"
545
562
  else:
546
563
  vm_name = f"clone-{sys_info['hostname']}"
547
-
564
+
548
565
  # Calculate recommended resources
549
- ram_mb = min(4096, int(sys_info['memory_available_gb'] * 1024 * 0.5))
550
- vcpus = max(2, sys_info['cpu_count'] // 2)
551
-
566
+ ram_mb = min(4096, int(sys_info["memory_available_gb"] * 1024 * 0.5))
567
+ vcpus = max(2, sys_info["cpu_count"] // 2)
568
+
552
569
  # Build config
553
570
  config = {
554
571
  "version": "1",
@@ -559,11 +576,16 @@ def generate_clonebox_yaml(snapshot, detector, deduplicate: bool = True,
559
576
  "vcpus": vcpus,
560
577
  "gui": True,
561
578
  "base_image": None,
579
+ "network_mode": network_mode,
562
580
  },
563
581
  "services": services,
564
582
  "packages": [
565
- "build-essential", "git", "curl", "vim",
566
- "python3", "python3-pip",
583
+ "build-essential",
584
+ "git",
585
+ "curl",
586
+ "vim",
587
+ "python3",
588
+ "python3-pip",
567
589
  ],
568
590
  "paths": paths_mapping,
569
591
  "detected": {
@@ -575,20 +597,20 @@ def generate_clonebox_yaml(snapshot, detector, deduplicate: bool = True,
575
597
  "projects": paths_by_type["project"],
576
598
  "configs": paths_by_type["config"][:5],
577
599
  "data": paths_by_type["data"][:5],
578
- }
579
- }
600
+ },
601
+ },
580
602
  }
581
-
603
+
582
604
  return yaml.dump(config, default_flow_style=False, allow_unicode=True, sort_keys=False)
583
605
 
584
606
 
585
607
  def load_clonebox_config(path: Path) -> dict:
586
608
  """Load .clonebox.yaml config file."""
587
609
  config_file = path / CLONEBOX_CONFIG_FILE if path.is_dir() else path
588
-
610
+
589
611
  if not config_file.exists():
590
612
  raise FileNotFoundError(f"Config file not found: {config_file}")
591
-
613
+
592
614
  with open(config_file) as f:
593
615
  return yaml.safe_load(f)
594
616
 
@@ -605,67 +627,74 @@ def create_vm_from_config(config: dict, start: bool = False, user_session: bool
605
627
  packages=config.get("packages", []),
606
628
  services=config.get("services", []),
607
629
  user_session=user_session,
630
+ network_mode=config["vm"].get("network_mode", "auto"),
608
631
  )
609
-
632
+
610
633
  cloner = SelectiveVMCloner(user_session=user_session)
611
-
634
+
612
635
  # Check prerequisites and show detailed info
613
636
  checks = cloner.check_prerequisites()
614
-
637
+
615
638
  if not checks["images_dir_writable"]:
616
639
  console.print(f"[yellow]⚠️ Storage directory: {checks['images_dir']}[/]")
617
640
  if "images_dir_error" in checks:
618
641
  console.print(f"[red]{checks['images_dir_error']}[/]")
619
642
  raise PermissionError(checks["images_dir_error"])
620
-
643
+
621
644
  console.print(f"[dim]Session: {checks['session_type']}, Storage: {checks['images_dir']}[/]")
622
-
645
+
623
646
  vm_uuid = cloner.create_vm(vm_config, console=console)
624
-
647
+
625
648
  if start:
626
649
  cloner.start_vm(vm_config.name, open_viewer=vm_config.gui, console=console)
627
-
650
+
628
651
  return vm_uuid
629
652
 
630
653
 
631
654
  def cmd_clone(args):
632
655
  """Generate clone config from path and optionally create VM."""
633
656
  target_path = Path(args.path).resolve()
634
-
657
+
635
658
  if not target_path.exists():
636
659
  console.print(f"[red]❌ Path does not exist: {target_path}[/]")
637
660
  return
638
-
661
+
639
662
  console.print(f"[bold cyan]📦 Generating clone config for: {target_path}[/]\n")
640
-
663
+
641
664
  # Detect system state
642
665
  with Progress(
643
666
  SpinnerColumn(),
644
667
  TextColumn("[progress.description]{task.description}"),
645
668
  console=console,
646
- transient=True
669
+ transient=True,
647
670
  ) as progress:
648
671
  progress.add_task("Scanning system...", total=None)
649
672
  detector = SystemDetector()
650
673
  snapshot = detector.detect_all()
651
-
674
+
652
675
  # Generate config
653
676
  vm_name = args.name or f"clone-{target_path.name}"
654
677
  yaml_content = generate_clonebox_yaml(
655
- snapshot, detector,
678
+ snapshot,
679
+ detector,
656
680
  deduplicate=args.dedupe,
657
681
  target_path=str(target_path),
658
- vm_name=vm_name
682
+ vm_name=vm_name,
683
+ network_mode=args.network,
659
684
  )
660
-
685
+
661
686
  # Save config file
662
- config_file = target_path / CLONEBOX_CONFIG_FILE if target_path.is_dir() else target_path.parent / CLONEBOX_CONFIG_FILE
687
+ config_file = (
688
+ target_path / CLONEBOX_CONFIG_FILE
689
+ if target_path.is_dir()
690
+ else target_path.parent / CLONEBOX_CONFIG_FILE
691
+ )
663
692
  config_file.write_text(yaml_content)
664
693
  console.print(f"[green]✅ Config saved: {config_file}[/]\n")
665
-
694
+
666
695
  # Show config
667
696
  console.print(Panel(yaml_content, title="[bold].clonebox.yaml[/]", border_style="cyan"))
668
-
697
+
669
698
  # Open in editor if requested
670
699
  if args.edit:
671
700
  editor = os.environ.get("EDITOR", "nano")
@@ -673,30 +702,28 @@ def cmd_clone(args):
673
702
  os.system(f"{editor} {config_file}")
674
703
  # Reload after edit
675
704
  yaml_content = config_file.read_text()
676
-
705
+
677
706
  # Ask to create VM
678
707
  if args.run:
679
708
  create_now = True
680
709
  else:
681
710
  create_now = questionary.confirm(
682
- "Create VM with this config?",
683
- default=True,
684
- style=custom_style
711
+ "Create VM with this config?", default=True, style=custom_style
685
712
  ).ask()
686
-
713
+
687
714
  if create_now:
688
715
  config = yaml.safe_load(yaml_content)
689
- user_session = getattr(args, 'user', False)
690
-
716
+ user_session = getattr(args, "user", False)
717
+
691
718
  console.print("\n[bold cyan]🔧 Creating VM...[/]\n")
692
719
  if user_session:
693
720
  console.print("[cyan]Using user session (qemu:///session) - no root required[/]")
694
-
721
+
695
722
  try:
696
723
  vm_uuid = create_vm_from_config(config, start=True, user_session=user_session)
697
724
  console.print(f"\n[bold green]🎉 VM '{config['vm']['name']}' is running![/]")
698
725
  console.print(f"[dim]UUID: {vm_uuid}[/]")
699
-
726
+
700
727
  # Show mount instructions
701
728
  if config.get("paths"):
702
729
  console.print("\n[bold]Inside VM, mount paths with:[/]")
@@ -704,36 +731,40 @@ def cmd_clone(args):
704
731
  console.print(f" [cyan]sudo mount -t 9p -o trans=virtio mount{idx} {guest}[/]")
705
732
  except PermissionError as e:
706
733
  console.print(f"[red]❌ Permission Error:[/]\n{e}")
707
- console.print(f"\n[yellow]💡 Try running with --user flag:[/]")
734
+ console.print("\n[yellow]💡 Try running with --user flag:[/]")
708
735
  console.print(f" [cyan]clonebox clone {target_path} --user[/]")
709
736
  except Exception as e:
710
737
  console.print(f"[red]❌ Error: {e}[/]")
711
738
  else:
712
- console.print(f"\n[dim]To create VM later, run:[/]")
739
+ console.print("\n[dim]To create VM later, run:[/]")
713
740
  console.print(f" [cyan]clonebox start {target_path}[/]")
714
741
 
715
742
 
716
743
  def cmd_detect(args):
717
744
  """Detect and show system state."""
718
745
  console.print("[bold cyan]🔍 Detecting system state...[/]\n")
719
-
746
+
720
747
  detector = SystemDetector()
721
748
  snapshot = detector.detect_all()
722
-
749
+
723
750
  # JSON output
724
751
  if args.json:
725
752
  result = {
726
753
  "services": [{"name": s.name, "status": s.status} for s in snapshot.running_services],
727
- "applications": [{"name": a.name, "pid": a.pid, "cwd": a.working_dir} for a in snapshot.applications],
728
- "paths": [{"path": p.path, "type": p.type, "size_mb": p.size_mb} for p in snapshot.paths],
754
+ "applications": [
755
+ {"name": a.name, "pid": a.pid, "cwd": a.working_dir} for a in snapshot.applications
756
+ ],
757
+ "paths": [
758
+ {"path": p.path, "type": p.type, "size_mb": p.size_mb} for p in snapshot.paths
759
+ ],
729
760
  }
730
761
  print(json.dumps(result, indent=2))
731
762
  return
732
-
763
+
733
764
  # YAML output
734
765
  if args.yaml:
735
766
  result = generate_clonebox_yaml(snapshot, detector, deduplicate=args.dedupe)
736
-
767
+
737
768
  if args.output:
738
769
  output_path = Path(args.output)
739
770
  output_path.write_text(result)
@@ -741,25 +772,25 @@ def cmd_detect(args):
741
772
  else:
742
773
  print(result)
743
774
  return
744
-
775
+
745
776
  # Services
746
777
  services = detector.detect_services()
747
778
  running = [s for s in services if s.status == "running"]
748
-
779
+
749
780
  if running:
750
781
  table = Table(title="Running Services", border_style="green")
751
782
  table.add_column("Service")
752
783
  table.add_column("Status")
753
784
  table.add_column("Enabled")
754
-
785
+
755
786
  for svc in running:
756
787
  table.add_row(svc.name, f"[green]{svc.status}[/]", "✓" if svc.enabled else "")
757
-
788
+
758
789
  console.print(table)
759
-
790
+
760
791
  # Applications
761
792
  apps = detector.detect_applications()
762
-
793
+
763
794
  if apps:
764
795
  console.print()
765
796
  table = Table(title="Running Applications", border_style="blue")
@@ -767,85 +798,88 @@ def cmd_detect(args):
767
798
  table.add_column("PID")
768
799
  table.add_column("Memory")
769
800
  table.add_column("Working Dir")
770
-
801
+
771
802
  for app in apps[:15]:
772
803
  table.add_row(
773
- app.name,
774
- str(app.pid),
804
+ app.name,
805
+ str(app.pid),
775
806
  f"{app.memory_mb:.0f} MB",
776
- app.working_dir[:40] if app.working_dir else ""
807
+ app.working_dir[:40] if app.working_dir else "",
777
808
  )
778
-
809
+
779
810
  console.print(table)
780
-
811
+
781
812
  # Paths
782
813
  paths = detector.detect_paths()
783
-
814
+
784
815
  if paths:
785
816
  console.print()
786
817
  table = Table(title="Detected Paths", border_style="yellow")
787
818
  table.add_column("Type")
788
819
  table.add_column("Path")
789
820
  table.add_column("Size")
790
-
821
+
791
822
  for p in paths[:20]:
792
823
  table.add_row(
793
- f"[cyan]{p.type}[/]",
794
- p.path,
795
- f"{p.size_mb:.0f} MB" if p.size_mb > 0 else "-"
824
+ f"[cyan]{p.type}[/]", p.path, f"{p.size_mb:.0f} MB" if p.size_mb > 0 else "-"
796
825
  )
797
-
826
+
798
827
  console.print(table)
799
828
 
800
829
 
801
830
  def main():
802
831
  """Main entry point."""
803
832
  parser = argparse.ArgumentParser(
804
- prog="clonebox",
805
- description="Clone your workstation environment to an isolated VM"
833
+ prog="clonebox", description="Clone your workstation environment to an isolated VM"
806
834
  )
807
835
  parser.add_argument("--version", action="version", version=f"clonebox {__version__}")
808
-
836
+
809
837
  subparsers = parser.add_subparsers(dest="command", help="Commands")
810
-
838
+
811
839
  # Interactive mode (default)
812
840
  parser.set_defaults(func=lambda args: interactive_mode())
813
-
841
+
814
842
  # Create command
815
843
  create_parser = subparsers.add_parser("create", help="Create VM from config")
816
844
  create_parser.add_argument("--name", "-n", default="clonebox-vm", help="VM name")
817
- create_parser.add_argument("--config", "-c", required=True,
818
- help='JSON config: {"paths": {}, "packages": [], "services": []}')
845
+ create_parser.add_argument(
846
+ "--config",
847
+ "-c",
848
+ required=True,
849
+ help='JSON config: {"paths": {}, "packages": [], "services": []}',
850
+ )
819
851
  create_parser.add_argument("--ram", type=int, default=4096, help="RAM in MB")
820
852
  create_parser.add_argument("--vcpus", type=int, default=4, help="Number of vCPUs")
821
853
  create_parser.add_argument("--base-image", help="Path to base qcow2 image")
822
854
  create_parser.add_argument("--no-gui", action="store_true", help="Disable SPICE graphics")
823
855
  create_parser.add_argument("--start", "-s", action="store_true", help="Start VM after creation")
824
856
  create_parser.set_defaults(func=cmd_create)
825
-
857
+
826
858
  # Start command
827
859
  start_parser = subparsers.add_parser("start", help="Start a VM")
828
- start_parser.add_argument("name", nargs="?", default=None, help="VM name or '.' to use .clonebox.yaml")
860
+ start_parser.add_argument(
861
+ "name", nargs="?", default=None, help="VM name or '.' to use .clonebox.yaml"
862
+ )
829
863
  start_parser.add_argument("--no-viewer", action="store_true", help="Don't open virt-viewer")
830
864
  start_parser.set_defaults(func=cmd_start)
831
-
865
+
832
866
  # Stop command
833
867
  stop_parser = subparsers.add_parser("stop", help="Stop a VM")
834
868
  stop_parser.add_argument("name", help="VM name")
835
869
  stop_parser.add_argument("--force", "-f", action="store_true", help="Force stop")
836
870
  stop_parser.set_defaults(func=cmd_stop)
837
-
871
+
838
872
  # Delete command
839
873
  delete_parser = subparsers.add_parser("delete", help="Delete a VM")
840
874
  delete_parser.add_argument("name", help="VM name")
841
875
  delete_parser.add_argument("--yes", "-y", action="store_true", help="Skip confirmation")
842
876
  delete_parser.add_argument("--keep-storage", action="store_true", help="Keep disk images")
843
877
  delete_parser.set_defaults(func=cmd_delete)
844
-
878
+
845
879
  # List command
846
880
  list_parser = subparsers.add_parser("list", aliases=["ls"], help="List VMs")
847
881
  list_parser.set_defaults(func=cmd_list)
848
-
882
+
849
883
  # Detect command
850
884
  detect_parser = subparsers.add_parser("detect", help="Detect system state")
851
885
  detect_parser.add_argument("--json", action="store_true", help="Output as JSON")
@@ -853,20 +887,38 @@ def main():
853
887
  detect_parser.add_argument("--dedupe", action="store_true", help="Remove duplicate entries")
854
888
  detect_parser.add_argument("-o", "--output", help="Save output to file")
855
889
  detect_parser.set_defaults(func=cmd_detect)
856
-
890
+
857
891
  # Clone command
858
892
  clone_parser = subparsers.add_parser("clone", help="Generate clone config from path")
859
- clone_parser.add_argument("path", nargs="?", default=".", help="Path to clone (default: current dir)")
893
+ clone_parser.add_argument(
894
+ "path", nargs="?", default=".", help="Path to clone (default: current dir)"
895
+ )
860
896
  clone_parser.add_argument("--name", "-n", help="VM name (default: directory name)")
861
- clone_parser.add_argument("--run", "-r", action="store_true", help="Create and start VM immediately")
862
- clone_parser.add_argument("--edit", "-e", action="store_true", help="Open config in editor before creating")
863
- clone_parser.add_argument("--dedupe", action="store_true", default=True, help="Remove duplicate entries")
864
- clone_parser.add_argument("--user", "-u", action="store_true",
865
- help="Use user session (qemu:///session) - no root required, stores in ~/.local/share/libvirt/")
897
+ clone_parser.add_argument(
898
+ "--run", "-r", action="store_true", help="Create and start VM immediately"
899
+ )
900
+ clone_parser.add_argument(
901
+ "--edit", "-e", action="store_true", help="Open config in editor before creating"
902
+ )
903
+ clone_parser.add_argument(
904
+ "--dedupe", action="store_true", default=True, help="Remove duplicate entries"
905
+ )
906
+ clone_parser.add_argument(
907
+ "--user",
908
+ "-u",
909
+ action="store_true",
910
+ help="Use user session (qemu:///session) - no root required, stores in ~/.local/share/libvirt/",
911
+ )
912
+ clone_parser.add_argument(
913
+ "--network",
914
+ choices=["auto", "default", "user"],
915
+ default="auto",
916
+ help="Network mode: auto (default), default (libvirt network), user (slirp)",
917
+ )
866
918
  clone_parser.set_defaults(func=cmd_clone)
867
-
919
+
868
920
  args = parser.parse_args()
869
-
921
+
870
922
  if hasattr(args, "func"):
871
923
  try:
872
924
  args.func(args)