Skip to main content

mvm_cli/
commands.rs

1use anyhow::{Context, Result};
2use clap::{CommandFactory, Parser, Subcommand};
3use serde::Serialize;
4use std::sync::{Arc, Mutex};
5
6use crate::bootstrap;
7use crate::fleet;
8use crate::logging::{self, LogFormat};
9use crate::shell_init;
10use crate::template_cmd;
11use crate::ui;
12use crate::update;
13
14use mvm_core::naming::{validate_flake_ref, validate_template_name, validate_vm_name};
15use mvm_core::util::parse_human_size;
16use mvm_core::vm_backend::VmId;
17use mvm_runtime::config;
18use mvm_runtime::shell;
19use mvm_runtime::vm::backend::AnyBackend;
20use mvm_runtime::vm::{firecracker, image, lima, microvm};
21
22/// Parameters for building a `VmStartConfig` from runtime-specific types.
23struct VmStartParams<'a> {
24    name: String,
25    rootfs_path: String,
26    vmlinux_path: String,
27    initrd_path: Option<String>,
28    revision_hash: String,
29    flake_ref: String,
30    profile: Option<String>,
31    cpus: u32,
32    memory_mib: u32,
33    volumes: &'a [image::RuntimeVolume],
34    config_files: &'a [microvm::DriveFile],
35    secret_files: &'a [microvm::DriveFile],
36    port_mappings: &'a [config::PortMapping],
37}
38
39impl VmStartParams<'_> {
40    fn into_start_config(self) -> mvm_core::vm_backend::VmStartConfig {
41        mvm_core::vm_backend::VmStartConfig {
42            name: self.name,
43            rootfs_path: self.rootfs_path,
44            kernel_path: Some(self.vmlinux_path),
45            initrd_path: self.initrd_path,
46            revision_hash: self.revision_hash,
47            flake_ref: self.flake_ref,
48            profile: self.profile,
49            cpus: self.cpus,
50            memory_mib: self.memory_mib,
51            ports: self
52                .port_mappings
53                .iter()
54                .map(|p| mvm_core::vm_backend::VmPortMapping {
55                    host: p.host,
56                    guest: p.guest,
57                })
58                .collect(),
59            volumes: self
60                .volumes
61                .iter()
62                .map(|v| mvm_core::vm_backend::VmVolume {
63                    host: v.host.clone(),
64                    guest: v.guest.clone(),
65                    size: v.size.clone(),
66                    read_only: v.read_only,
67                })
68                .collect(),
69            config_files: self
70                .config_files
71                .iter()
72                .map(|f| mvm_core::vm_backend::VmFile {
73                    name: f.name.clone(),
74                    content: f.content.clone(),
75                    mode: f.mode,
76                })
77                .collect(),
78            secret_files: self
79                .secret_files
80                .iter()
81                .map(|f| mvm_core::vm_backend::VmFile {
82                    name: f.name.clone(),
83                    content: f.content.clone(),
84                    mode: f.mode,
85                })
86                .collect(),
87            runner_dir: None,
88        }
89    }
90}
91
92/// Global registry of spawned child PIDs so the signal handler can clean them up.
93static CHILD_PIDS: std::sync::LazyLock<Arc<Mutex<Vec<u32>>>> =
94    std::sync::LazyLock::new(|| Arc::new(Mutex::new(Vec::new())));
95
96/// When true, the Ctrl-C handler does nothing — console mode forwards
97/// raw bytes to the guest instead.
98static IN_CONSOLE_MODE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
99
100#[derive(Parser)]
101#[command(name = "mvmctl", version, about = "Lightweight VM development tool")]
102struct Cli {
103    /// Log format: human (default) or json (structured)
104    #[arg(long, global = true)]
105    log_format: Option<String>,
106
107    /// Override Firecracker version (e.g., v1.14.0)
108    #[arg(long, global = true)]
109    fc_version: Option<String>,
110
111    #[command(subcommand)]
112    command: Commands,
113}
114
115#[derive(Subcommand)]
116#[allow(clippy::large_enum_variant)] // Up variant has many CLI fields; boxing breaks Clap derive
117enum Commands {
118    /// Full environment setup from scratch
119    Bootstrap {
120        /// Production mode (skip Homebrew, assume Linux with apt)
121        #[arg(long)]
122        production: bool,
123    },
124    /// Create Lima VM, install Firecracker, download kernel/rootfs (requires limactl)
125    Setup {
126        /// Delete the existing rootfs and rebuild it from scratch
127        #[arg(long)]
128        recreate: bool,
129        /// Re-run all setup steps even if already complete
130        #[arg(long)]
131        force: bool,
132        /// Number of vCPUs for the Lima VM
133        #[arg(long, default_value = "8")]
134        lima_cpus: u32,
135        /// Memory (GiB) for the Lima VM
136        #[arg(long, default_value = "16")]
137        lima_mem: u32,
138    },
139    /// Manage the Lima development environment (up, down, shell, status)
140    Dev {
141        #[command(subcommand)]
142        action: Option<DevCmd>,
143    },
144    /// Remove old dev-build artifacts and run Nix garbage collection
145    Cleanup {
146        /// Number of newest build revisions to keep
147        #[arg(long)]
148        keep: Option<usize>,
149        /// Remove all cached build revisions
150        #[arg(long)]
151        all: bool,
152        /// Print each cached build path that gets removed
153        #[arg(long)]
154        verbose: bool,
155    },
156    /// Show console logs from a running microVM
157    Logs {
158        /// Name of the VM
159        #[arg(value_parser = clap_vm_name)]
160        name: String,
161        /// Follow log output (like tail -f)
162        #[arg(long, short = 'f')]
163        follow: bool,
164        /// Number of lines to show (default 50)
165        #[arg(long, short = 'n', default_value = "50")]
166        lines: u32,
167        /// Show Firecracker hypervisor logs instead of guest console output
168        #[arg(long)]
169        hypervisor: bool,
170    },
171    /// Forward a port from a running microVM to localhost
172    Forward {
173        /// Name of the VM
174        #[arg(value_parser = clap_vm_name)]
175        name: String,
176        /// Port mapping(s): GUEST_PORT or LOCAL_PORT:GUEST_PORT
177        #[arg(short, long, value_name = "PORT", value_parser = clap_port_spec)]
178        port: Vec<String>,
179        /// Port mapping(s) (positional, same as --port)
180        #[arg(trailing_var_arg = true, hide = true)]
181        ports: Vec<String>,
182    },
183    /// List running VMs
184    #[command(alias = "ls", alias = "status")]
185    Ps {
186        /// Show all VMs (including stopped)
187        #[arg(long, short = 'a')]
188        all: bool,
189        /// Output as JSON
190        #[arg(long)]
191        json: bool,
192    },
193    /// Check for and install the latest version of mvmctl
194    Update {
195        /// Only check for updates, don't install
196        #[arg(long)]
197        check: bool,
198        /// Force reinstall even if already up to date
199        #[arg(long)]
200        force: bool,
201        /// Skip cosign signature verification even if cosign is installed
202        #[arg(long)]
203        skip_verify: bool,
204    },
205    /// System diagnostics and dependency checks
206    Doctor {
207        /// Output results as JSON
208        #[arg(long)]
209        json: bool,
210    },
211    /// Manage global templates (shared base images)
212    Template {
213        #[command(subcommand)]
214        action: TemplateCmd,
215    },
216    /// Build a microVM image from a Mvmfile.toml config or Nix flake
217    Build {
218        /// Image name (built-in like "openclaw") or path to directory with Mvmfile.toml
219        #[arg(default_value = ".")]
220        path: String,
221        /// Output path for the built .elf image
222        #[arg(long, short = 'o')]
223        output: Option<String>,
224        /// Nix flake reference (enables flake build mode)
225        #[arg(long, value_parser = clap_flake_ref)]
226        flake: Option<String>,
227        /// Flake package variant (e.g. worker, gateway). Omit to use flake default.
228        #[arg(long)]
229        profile: Option<String>,
230        /// Watch flake.lock and rebuild on change (flake mode)
231        #[arg(long)]
232        watch: bool,
233        /// Output structured JSON events instead of human-readable output
234        #[arg(long)]
235        json: bool,
236    },
237    /// Build and run a VM from a Nix flake, a template, or the bundled default image.
238    ///
239    /// If neither `--flake` nor `--template` is supplied, the bundled
240    /// `nix/default-microvm/` image is used (built via Nix on first use,
241    /// cached at `~/.cache/mvm/default-microvm/`).
242    #[command(alias = "start", alias = "run", group(clap::ArgGroup::new("source")))]
243    Up {
244        /// Nix flake reference (local path or remote URI)
245        #[arg(long, group = "source", value_parser = clap_flake_ref)]
246        flake: Option<String>,
247        /// Run from a pre-built template (skip build)
248        #[arg(long, group = "source")]
249        template: Option<String>,
250        /// VM name (auto-generated if omitted)
251        #[arg(long, value_parser = clap_vm_name)]
252        name: Option<String>,
253        /// Flake package variant (e.g. worker, gateway). Omit to use flake default.
254        #[arg(long)]
255        profile: Option<String>,
256        /// vCPU cores
257        #[arg(long)]
258        cpus: Option<u32>,
259        /// Memory (supports human-readable sizes: 512M, 4G, 1024K, or plain MB)
260        #[arg(long)]
261        memory: Option<String>,
262        /// Runtime config (TOML) for persistent resources/volumes
263        #[arg(long)]
264        config: Option<String>,
265        /// Volume (host_dir:/guest/path or host:/guest/path:size). Repeatable.
266        #[arg(long, short = 'v', value_parser = clap_volume_spec)]
267        volume: Vec<String>,
268        /// Hypervisor backend (firecracker, qemu, apple-container, docker). Default: auto-detect.
269        #[arg(long, default_value = "firecracker")]
270        hypervisor: String,
271        /// Port mapping (format: HOST:GUEST or PORT). Repeatable.
272        #[arg(long, short = 'p', value_parser = clap_port_spec)]
273        port: Vec<String>,
274        /// Environment variable to inject (format: KEY=VALUE). Repeatable.
275        #[arg(long, short = 'e')]
276        env: Vec<String>,
277        /// Auto-forward declared ports after boot (blocks until Ctrl-C)
278        #[arg(long)]
279        forward: bool,
280        /// Bind a Prometheus metrics endpoint on this port (0 = disabled)
281        #[arg(long, default_value = "0")]
282        metrics_port: u16,
283        /// Reload ~/.mvm/config.toml automatically when it changes
284        #[arg(long)]
285        watch_config: bool,
286        /// Watch the flake for changes and auto-rebuild + reboot (requires local --flake)
287        #[arg(long)]
288        watch: bool,
289        /// Run in background (detached mode, like docker run -d)
290        #[arg(long, short = 'd')]
291        detach: bool,
292        /// Network preset (unrestricted, none, registries, dev)
293        #[arg(long)]
294        network_preset: Option<String>,
295        /// Network allowlist entry (format: HOST:PORT). Repeatable.
296        #[arg(long)]
297        network_allow: Vec<String>,
298        /// Seccomp profile tier (essential, minimal, standard, network, unrestricted)
299        #[arg(long, default_value = "unrestricted")]
300        seccomp: String,
301        /// Secret binding (format: KEY:host, KEY:host:header, or KEY=value:host). Repeatable.
302        #[arg(long, short = 's')]
303        secret: Vec<String>,
304        /// Named dev network to attach VM to (default: "default")
305        #[arg(long, default_value = "default")]
306        network: String,
307    },
308    /// Stop microVMs (from mvm.toml, by name, or all)
309    Down {
310        /// VM name to stop (or all VMs if omitted)
311        name: Option<String>,
312        /// Path to fleet config (stops only VMs defined in config)
313        #[arg(long, short = 'f')]
314        config: Option<String>,
315    },
316    /// Generate shell completions
317    Completions {
318        /// Shell to generate completions for
319        #[arg(value_enum)]
320        shell: clap_complete::Shell,
321    },
322    /// Print shell configuration (completions + dev aliases) to stdout
323    ShellInit,
324    /// Show runtime metrics (Prometheus text format by default)
325    Metrics {
326        /// Output as JSON instead of Prometheus exposition format
327        #[arg(long)]
328        json: bool,
329    },
330    /// Read or write global operator config (~/.mvm/config.toml)
331    Config {
332        #[command(subcommand)]
333        action: ConfigAction,
334    },
335    /// Remove Lima VM, Firecracker binary, and all mvm state (clean uninstall)
336    Uninstall {
337        /// Skip confirmation prompt
338        #[arg(long, short = 'y')]
339        yes: bool,
340        /// Also remove ~/.mvm/ config dir and /usr/local/bin/mvmctl binary
341        #[arg(long)]
342        all: bool,
343        /// Print what would be removed without actually removing anything
344        #[arg(long)]
345        dry_run: bool,
346    },
347    /// View the local audit log (~/.mvm/log/audit.jsonl)
348    Audit {
349        #[command(subcommand)]
350        action: AuditCmd,
351    },
352    /// Validate a Nix flake before building
353    Flake {
354        #[command(subcommand)]
355        action: FlakeCmd,
356    },
357    /// Show filesystem changes in a running VM (files created/modified/deleted since boot)
358    Diff {
359        /// VM name
360        name: String,
361        /// Output as JSON instead of human-readable
362        #[arg(long)]
363        json: bool,
364    },
365    /// Manage named dev networks
366    Network {
367        #[command(subcommand)]
368        action: NetworkCmd,
369    },
370    /// Browse and fetch images from the Nix-based image catalog
371    Image {
372        #[command(subcommand)]
373        action: ImageCmd,
374    },
375    /// Interactive console (PTY-over-vsock) to a running VM
376    Console {
377        /// VM name
378        #[arg(value_parser = clap_vm_name)]
379        name: String,
380        /// Run a single command instead of opening an interactive shell
381        #[arg(long)]
382        command: Option<String>,
383    },
384    /// Manage the XDG cache directory (~/.cache/mvm)
385    Cache {
386        #[command(subcommand)]
387        action: CacheCmd,
388    },
389    /// First-time setup wizard — installs deps, creates Lima VM, sets up default network
390    Init {
391        /// Skip interactive prompts, use defaults
392        #[arg(long)]
393        non_interactive: bool,
394        /// Number of vCPUs for the Lima VM
395        #[arg(long, default_value = "8")]
396        lima_cpus: u32,
397        /// Memory (GiB) for the Lima VM
398        #[arg(long, default_value = "16")]
399        lima_mem: u32,
400    },
401    /// Show security posture and status
402    Security {
403        #[command(subcommand)]
404        action: SecurityCmd,
405    },
406    /// Boot a transient microVM, run a single command, and tear down (dev-mode only).
407    ///
408    /// Inspired by cco — same one-command UX, but with a Firecracker microVM
409    /// as the sandbox. Use `--add-dir host:guest[:mode]` to share a host
410    /// directory (default `:ro`; pass `:rw` to rsync writes back to the
411    /// host on exit). Use `--` to separate the argv from `mvmctl exec` flags.
412    /// Alternatively, pass `--launch-plan ./launch.json` to invoke an
413    /// mvmforge-emitted entrypoint instead of an inline argv.
414    Exec {
415        /// Pre-built template to boot. If omitted, the bundled
416        /// `nix/default-microvm/` image is used (built via Nix on first use,
417        /// cached at `~/.cache/mvm/default-microvm/`). Each invocation boots
418        /// a fresh transient microVM — never the long-running `mvmctl dev` VM.
419        #[arg(long)]
420        template: Option<String>,
421        /// vCPU cores (default: 2).
422        #[arg(long, default_value = "2")]
423        cpus: u32,
424        /// Memory (supports human-readable: 512M, 1G, …).
425        #[arg(long, default_value = "512M")]
426        memory: String,
427        /// Share a host directory into the guest. Format:
428        /// `HOST_PATH:/GUEST_PATH[:MODE]` where MODE is `ro` (default,
429        /// writes are discarded) or `rw` (writes are rsynced back to the
430        /// host directory after the command exits — see ADR-002).
431        /// Repeatable.
432        #[arg(long = "add-dir", short = 'd')]
433        add_dir: Vec<String>,
434        /// Environment variable to inject (KEY=VALUE). Repeatable. Overrides
435        /// any env vars carried by `--launch-plan`.
436        #[arg(long, short = 'e')]
437        env: Vec<String>,
438        /// Per-command timeout in seconds (default: 60).
439        #[arg(long, default_value = "60")]
440        timeout: u64,
441        /// Path to an mvmforge `launch.json`. The first app's `entrypoint`
442        /// (command, working_dir, env) is invoked instead of a trailing argv.
443        /// Mutually exclusive with the trailing `<ARGV>...`.
444        #[arg(long = "launch-plan", value_name = "PATH", conflicts_with = "argv")]
445        launch_plan: Option<String>,
446        /// Argv to run inside the guest (use `--` to separate). Required
447        /// unless `--launch-plan` is supplied.
448        #[arg(trailing_var_arg = true, required_unless_present = "launch_plan")]
449        argv: Vec<String>,
450    },
451}
452
453#[derive(Subcommand)]
454enum AuditCmd {
455    /// Show the last N audit events (default: 20)
456    Tail {
457        /// Number of lines to show
458        #[arg(long, short = 'n', default_value = "20")]
459        lines: usize,
460        /// Follow log output (poll every 500 ms until Ctrl-C)
461        #[arg(long, short = 'f')]
462        follow: bool,
463    },
464}
465
466#[derive(Subcommand)]
467enum DevCmd {
468    /// Bootstrap and start the dev environment
469    Up {
470        /// Number of vCPUs for the Lima VM
471        #[arg(long, default_value = "8")]
472        lima_cpus: u32,
473        /// Memory (GiB) for the Lima VM
474        #[arg(long, default_value = "16")]
475        lima_mem: u32,
476        /// Project directory to cd into inside the VM
477        #[arg(long)]
478        project: Option<String>,
479        /// Bind a Prometheus metrics endpoint on this port (0 = disabled)
480        #[arg(long, default_value = "0")]
481        metrics_port: u16,
482        /// Reload ~/.mvm/config.toml automatically when it changes
483        #[arg(long)]
484        watch_config: bool,
485        /// Force Lima backend even on macOS 26+ (where Apple Container is default)
486        #[arg(long)]
487        lima: bool,
488        /// Open an interactive shell after starting
489        #[arg(long, short = 's')]
490        shell: bool,
491    },
492    /// Stop the Lima development VM
493    Down,
494    /// Open a shell in the running Lima VM
495    Shell {
496        /// Project directory to cd into inside the VM (Lima maps ~ → ~)
497        #[arg(long)]
498        project: Option<String>,
499    },
500    /// Show dev environment status (Lima VM, Firecracker, Nix)
501    Status,
502    /// Rebuild the dev environment (down + clear cache + up)
503    Rebuild {
504        /// Number of vCPUs for the Lima VM
505        #[arg(long, default_value = "8")]
506        lima_cpus: u32,
507        /// Memory (GiB) for the Lima VM
508        #[arg(long, default_value = "16")]
509        lima_mem: u32,
510        /// Force Lima backend even on macOS 26+
511        #[arg(long)]
512        lima: bool,
513        /// Open an interactive shell after rebuilding
514        #[arg(long, short = 's')]
515        shell: bool,
516    },
517}
518
519#[derive(Subcommand)]
520enum FlakeCmd {
521    /// Run `nix flake check` to validate a flake before building
522    Check {
523        /// Flake path or reference (default: current directory)
524        #[arg(long, default_value = ".")]
525        flake: String,
526        /// Output structured JSON instead of human-readable output
527        #[arg(long)]
528        json: bool,
529    },
530}
531
532#[derive(Subcommand)]
533enum NetworkCmd {
534    /// Create a named dev network with its own bridge and subnet
535    #[command(alias = "new")]
536    Create {
537        /// Network name (lowercase alphanumeric + hyphens)
538        name: String,
539        /// Subnet CIDR (auto-assigned if omitted)
540        #[arg(long)]
541        subnet: Option<String>,
542    },
543    /// List all dev networks
544    #[command(alias = "ls")]
545    List,
546    /// Show details of a named network
547    Inspect {
548        /// Network name
549        name: String,
550    },
551    /// Remove a named network
552    #[command(alias = "rm")]
553    Remove {
554        /// Network name
555        name: String,
556    },
557}
558
559#[derive(Subcommand)]
560enum ImageCmd {
561    /// List available images in the catalog
562    #[command(alias = "ls")]
563    List,
564    /// Search images by name or tag
565    Search {
566        /// Search query
567        query: String,
568    },
569    /// Fetch (build) an image from the catalog
570    Fetch {
571        /// Image name from the catalog
572        name: String,
573    },
574    /// Show details of a catalog image
575    Info {
576        /// Image name from the catalog
577        name: String,
578    },
579}
580
581#[derive(Subcommand)]
582enum SecurityCmd {
583    /// Show security posture evaluation for the current environment
584    Status {
585        /// Output as JSON
586        #[arg(long)]
587        json: bool,
588    },
589}
590
591#[derive(Subcommand)]
592enum CacheCmd {
593    /// Remove stale items from the cache directory
594    Prune {
595        /// Print what would be removed without actually removing anything
596        #[arg(long)]
597        dry_run: bool,
598    },
599    /// Show cache directory path and disk usage
600    Info,
601}
602
603#[derive(Subcommand)]
604enum TemplateCmd {
605    /// Create a new template (single role/profile)
606    #[command(alias = "new")]
607    Create {
608        /// Template name (e.g. "base", "openclaw")
609        name: String,
610        /// Nix flake reference for the template source
611        #[arg(long, default_value = ".", value_parser = clap_flake_ref)]
612        flake: String,
613        /// Flake package variant
614        #[arg(long, default_value = "default")]
615        profile: String,
616        /// VM role (worker or gateway)
617        #[arg(long, default_value = "worker")]
618        role: String,
619        /// Default vCPU count for VMs using this template
620        #[arg(long, default_value = "2")]
621        cpus: u8,
622        /// Default memory (supports human-readable sizes: 512M, 4G, or plain MB)
623        #[arg(long, default_value = "1024")]
624        mem: String,
625        /// Data disk size (supports human-readable sizes: 10G, 512M, or plain MB; 0 = no disk)
626        #[arg(long, default_value = "0")]
627        data_disk: String,
628    },
629    /// Create multiple role-specific templates (name-role)
630    CreateMulti {
631        /// Base template name (each role becomes <base>-<role>)
632        base: String,
633        /// Nix flake reference for the template source
634        #[arg(long, default_value = ".", value_parser = clap_flake_ref)]
635        flake: String,
636        /// Flake package variant
637        #[arg(long, default_value = "default")]
638        profile: String,
639        /// Comma-separated roles, e.g. gateway,agent
640        #[arg(long)]
641        roles: String,
642        /// Default vCPU count for VMs using this template
643        #[arg(long, default_value = "2")]
644        cpus: u8,
645        /// Default memory (supports human-readable sizes: 512M, 4G, or plain MB)
646        #[arg(long, default_value = "1024")]
647        mem: String,
648        /// Data disk size (supports human-readable sizes: 10G, 512M, or plain MB; 0 = no disk)
649        #[arg(long, default_value = "0")]
650        data_disk: String,
651    },
652    /// Build a template (shared image via nix build)
653    Build {
654        /// Template name to build
655        name: String,
656        /// Rebuild even if a cached revision exists
657        #[arg(long)]
658        force: bool,
659        /// After build, boot VM, wait for healthy, and create a snapshot for instant starts
660        #[arg(long)]
661        snapshot: bool,
662        /// Optional template config TOML to build multiple variants
663        #[arg(long)]
664        config: Option<String>,
665        /// Recompute the Nix fixed-output derivation hash (use after version bump)
666        #[arg(long)]
667        update_hash: bool,
668    },
669    /// Push a built template revision to the object storage registry
670    Push {
671        /// Template name to push
672        name: String,
673        /// Revision hash to push (defaults to current)
674        #[arg(long)]
675        revision: Option<String>,
676    },
677    /// Pull a template revision from the object storage registry
678    Pull {
679        /// Template name to pull
680        name: String,
681        /// Revision hash to pull (defaults to registry current)
682        #[arg(long)]
683        revision: Option<String>,
684    },
685    /// Verify a locally installed template revision against checksums.json
686    Verify {
687        /// Template name to verify
688        name: String,
689        /// Revision hash to verify (defaults to current)
690        #[arg(long)]
691        revision: Option<String>,
692    },
693    /// List all templates
694    List {
695        /// Output as JSON
696        #[arg(long)]
697        json: bool,
698    },
699    /// Show template details (spec, revisions, cache key)
700    Info {
701        /// Template name
702        name: String,
703        /// Output as JSON
704        #[arg(long)]
705        json: bool,
706    },
707    /// Edit an existing template's configuration
708    Edit {
709        /// Template name to edit
710        name: String,
711        /// Update Nix flake reference
712        #[arg(long)]
713        flake: Option<String>,
714        /// Update flake package variant
715        #[arg(long)]
716        profile: Option<String>,
717        /// Update VM role
718        #[arg(long)]
719        role: Option<String>,
720        /// Update vCPU count
721        #[arg(long)]
722        cpus: Option<u8>,
723        /// Update memory (supports human-readable sizes: 512M, 4G, or plain MB)
724        #[arg(long)]
725        mem: Option<String>,
726        /// Update data disk size (supports human-readable sizes: 10G, 512M, or plain MB)
727        #[arg(long)]
728        data_disk: Option<String>,
729    },
730    /// Delete a template and its artifacts
731    Delete {
732        /// Template name to delete
733        name: String,
734        /// Skip confirmation prompt
735        #[arg(long)]
736        force: bool,
737    },
738    /// Initialize on-disk template layout (idempotent)
739    Init {
740        /// Template name to initialize
741        name: String,
742        /// Create locally instead of in ~/.mvm/templates
743        #[arg(long)]
744        local: bool,
745        /// Create inside the Lima VM (overrides --local)
746        #[arg(long)]
747        vm: bool,
748        /// Base directory for local init (default: current dir)
749        #[arg(long, default_value = ".")]
750        dir: String,
751        /// Scaffold preset: minimal, http, postgres, worker, python (default: minimal)
752        #[arg(long)]
753        preset: Option<String>,
754        /// Natural-language prompt used to generate a local scaffold and metadata (uses OpenAI when OPENAI_API_KEY is set)
755        #[arg(long)]
756        prompt: Option<String>,
757    },
758}
759
760#[derive(Subcommand)]
761enum ConfigAction {
762    /// Print current config as TOML
763    Show,
764    /// Open the config file in $EDITOR (falls back to nano)
765    Edit,
766    /// Set a single config key
767    Set {
768        /// Config key (e.g. lima_cpus)
769        key: String,
770        /// New value
771        value: String,
772    },
773}
774
775// ============================================================================
776// Structured JSON event output for --json mode
777// ============================================================================
778
779/// Structured event emitted during sync/build operations in --json mode.
780#[derive(Debug, Serialize)]
781struct PhaseEvent {
782    timestamp: String,
783    command: &'static str,
784    phase: String,
785    status: &'static str,
786    #[serde(skip_serializing_if = "Option::is_none")]
787    message: Option<String>,
788    #[serde(skip_serializing_if = "Option::is_none")]
789    error: Option<String>,
790}
791
792impl PhaseEvent {
793    fn new(command: &'static str, phase: &str, status: &'static str) -> Self {
794        Self {
795            timestamp: chrono::Utc::now().to_rfc3339(),
796            command,
797            phase: phase.to_string(),
798            status,
799            message: None,
800            error: None,
801        }
802    }
803
804    fn with_message(mut self, msg: &str) -> Self {
805        self.message = Some(msg.to_string());
806        self
807    }
808
809    fn with_error(mut self, err: &str) -> Self {
810        self.error = Some(err.to_string());
811        self
812    }
813
814    fn emit(&self) {
815        if let Ok(json) = serde_json::to_string(self) {
816            println!("{}", json);
817        }
818    }
819}
820
821// ============================================================================
822// Entry point
823// ============================================================================
824
825/// Return the Clap `Command` tree for `mvmctl`.
826///
827/// Used by the `xtask` crate to generate man pages without duplicating the
828/// command definition.
829pub fn cli_command() -> clap::Command {
830    use clap::CommandFactory;
831    Cli::command()
832}
833
834pub fn run() -> Result<()> {
835    let cli = Cli::parse();
836
837    // Apply FC version override before anything reads it.
838    // SAFETY: called once at startup before any threads are spawned.
839    if let Some(ref version) = cli.fc_version {
840        unsafe { std::env::set_var("MVM_FC_VERSION", version) };
841    }
842
843    // Initialize logging
844    let log_format = match cli.log_format.as_deref() {
845        Some("json") => LogFormat::Json,
846        Some("human") => LogFormat::Human,
847        Some(other) => {
848            eprintln!(
849                "Unknown --log-format '{}', using 'human'. Valid: human, json",
850                other
851            );
852            LogFormat::Human
853        }
854        None => LogFormat::Human,
855    };
856    logging::init(log_format);
857
858    // Install Ctrl-C / SIGTERM handler for graceful shutdown.
859    let pids = Arc::clone(&CHILD_PIDS);
860    if let Err(e) = ctrlc::set_handler(move || {
861        // In console mode, Ctrl-C is forwarded as a raw byte to the guest.
862        if IN_CONSOLE_MODE.load(std::sync::atomic::Ordering::SeqCst) {
863            return;
864        }
865        eprintln!("\nInterrupted, cleaning up...");
866        // Kill any tracked child processes (e.g., socat port-forwarders).
867        if let Ok(pids) = pids.lock() {
868            for &pid in pids.iter() {
869                unsafe {
870                    libc::kill(pid as libc::pid_t, libc::SIGTERM);
871                }
872            }
873        }
874        std::process::exit(130);
875    }) {
876        tracing::warn!("failed to install signal handler: {e}");
877    }
878
879    // Load operator config once; used as fallback for lima_cpus, lima_mem, cpus, memory.
880    let cfg = mvm_core::user_config::load(None);
881
882    let result = match cli.command {
883        Commands::Bootstrap { production } => cmd_bootstrap(production),
884        Commands::Setup {
885            recreate,
886            force,
887            lima_cpus,
888            lima_mem,
889        } => {
890            let effective_cpus = if lima_cpus == 8 {
891                cfg.lima_cpus
892            } else {
893                lima_cpus
894            };
895            let effective_mem = if lima_mem == 16 {
896                cfg.lima_mem_gib
897            } else {
898                lima_mem
899            };
900            cmd_setup(recreate, force, effective_cpus, effective_mem)
901        }
902        Commands::Dev { action } => {
903            let action = action.unwrap_or(DevCmd::Up {
904                lima_cpus: 8,
905                lima_mem: 16,
906                project: None,
907                metrics_port: 0,
908                watch_config: false,
909                lima: false,
910                shell: false,
911            });
912            match action {
913                DevCmd::Up {
914                    lima_cpus,
915                    lima_mem,
916                    project,
917                    metrics_port,
918                    watch_config,
919                    lima,
920                    shell,
921                } => {
922                    let effective_cpus = if lima_cpus == 8 {
923                        cfg.lima_cpus
924                    } else {
925                        lima_cpus
926                    };
927                    let effective_mem = if lima_mem == 16 {
928                        cfg.lima_mem_gib
929                    } else {
930                        lima_mem
931                    };
932
933                    let use_apple_container =
934                        !lima && mvm_core::platform::current().has_apple_containers();
935
936                    if use_apple_container {
937                        cmd_dev_apple_container(effective_cpus, effective_mem, shell)
938                    } else {
939                        cmd_dev(
940                            effective_cpus,
941                            effective_mem,
942                            project.as_deref(),
943                            metrics_port,
944                            watch_config,
945                        )
946                    }
947                }
948                DevCmd::Down => {
949                    if mvm_core::platform::current().has_apple_containers() {
950                        cmd_dev_apple_container_down()
951                    } else {
952                        cmd_dev_down()
953                    }
954                }
955                DevCmd::Shell { project } => {
956                    if mvm_core::platform::current().has_apple_containers() {
957                        if !is_apple_container_dev_running() {
958                            anyhow::bail!("Dev VM is not running. Start it with: mvmctl dev up");
959                        }
960                        // Try connecting — the VM may be in another process
961                        match console_interactive("mvm-dev") {
962                            Ok(()) => Ok(()),
963                            Err(_) => {
964                                anyhow::bail!(
965                                    "Dev VM is running but owned by another process.\n\
966                                     Use the terminal where you ran 'mvmctl dev up',\n\
967                                     or restart with: mvmctl dev down && mvmctl dev up --shell"
968                                )
969                            }
970                        }
971                    } else {
972                        cmd_shell(project.as_deref())
973                    }
974                }
975                DevCmd::Status => {
976                    if mvm_core::platform::current().has_apple_containers() {
977                        cmd_dev_apple_container_status()
978                    } else {
979                        cmd_dev_status()
980                    }
981                }
982                DevCmd::Rebuild {
983                    lima_cpus,
984                    lima_mem,
985                    lima,
986                    shell,
987                } => {
988                    // Down
989                    if mvm_core::platform::current().has_apple_containers() {
990                        let _ = cmd_dev_apple_container_down();
991                    } else {
992                        let _ = cmd_dev_down();
993                    }
994
995                    // Clear cached dev image
996                    let cache_dir = format!("{}/dev", mvm_core::config::mvm_cache_dir());
997                    let _ = std::fs::remove_dir_all(&cache_dir);
998
999                    // Up
1000                    let effective_cpus = if lima_cpus == 8 {
1001                        cfg.lima_cpus
1002                    } else {
1003                        lima_cpus
1004                    };
1005                    let effective_mem = if lima_mem == 16 {
1006                        cfg.lima_mem_gib
1007                    } else {
1008                        lima_mem
1009                    };
1010                    let use_apple_container =
1011                        !lima && mvm_core::platform::current().has_apple_containers();
1012                    if use_apple_container {
1013                        cmd_dev_apple_container(effective_cpus, effective_mem, shell)
1014                    } else {
1015                        cmd_dev(effective_cpus, effective_mem, None, 0, false)
1016                    }
1017                }
1018            }
1019        }
1020        Commands::Cleanup { keep, all, verbose } => cmd_cleanup(keep, all, verbose),
1021        Commands::Logs {
1022            name,
1023            follow,
1024            lines,
1025            hypervisor,
1026        } => cmd_logs(&name, follow, lines, hypervisor),
1027        Commands::Forward { name, port, ports } => {
1028            let mut all_ports = port;
1029            all_ports.extend(ports);
1030            cmd_forward(&name, &all_ports)
1031        }
1032
1033        Commands::Ps { all, json } => cmd_ls(all, json),
1034        Commands::Update {
1035            check,
1036            force,
1037            skip_verify,
1038        } => cmd_update(check, force, skip_verify),
1039        Commands::Doctor { json } => cmd_doctor(json),
1040        Commands::Build {
1041            path,
1042            output,
1043            flake,
1044            profile,
1045            watch,
1046            json,
1047        } => {
1048            if let Some(flake_ref) = flake {
1049                cmd_build_flake(&flake_ref, profile.as_deref(), watch, json)
1050            } else {
1051                cmd_build(&path, output.as_deref())
1052            }
1053        }
1054        Commands::Up {
1055            flake,
1056            template,
1057            name,
1058            profile,
1059            cpus,
1060            memory,
1061            config,
1062            volume,
1063            hypervisor,
1064            port,
1065            env,
1066            forward,
1067            metrics_port,
1068            watch_config,
1069            watch,
1070            detach,
1071            network_preset,
1072            network_allow,
1073            seccomp,
1074            secret,
1075            network,
1076        } => {
1077            let memory_mb = memory
1078                .as_ref()
1079                .map(|s| parse_human_size(s))
1080                .transpose()
1081                .context("Invalid memory size")?;
1082            // CLI flag takes precedence; fall back to per-user config defaults.
1083            let effective_cpus = cpus.or(Some(cfg.default_cpus));
1084            let effective_memory = memory_mb.or(Some(cfg.default_memory_mib));
1085
1086            let network_policy = resolve_network_policy(network_preset.as_deref(), &network_allow)?;
1087            let seccomp_tier: mvm_security::seccomp::SeccompTier =
1088                seccomp.parse().context("Invalid --seccomp value")?;
1089            let secret_bindings: Vec<mvm_core::secret_binding::SecretBinding> = secret
1090                .iter()
1091                .map(|s| s.parse())
1092                .collect::<Result<Vec<_>>>()
1093                .context("Invalid --secret value")?;
1094
1095            cmd_run(RunParams {
1096                flake_ref: flake.as_deref(),
1097                template_name: template.as_deref(),
1098                name: name.as_deref(),
1099                profile: profile.as_deref(),
1100                cpus: effective_cpus,
1101                memory: effective_memory,
1102                config_path: config.as_deref(),
1103                volumes: &volume,
1104                hypervisor: &hypervisor,
1105                ports: &port,
1106                env_vars: &env,
1107                forward,
1108                metrics_port,
1109                watch_config,
1110                watch,
1111                detach,
1112                network_policy,
1113                network_name: &network,
1114                seccomp_tier,
1115                secret_bindings,
1116            })
1117        }
1118        Commands::Down { name, config } => cmd_down(name.as_deref(), config.as_deref()),
1119        Commands::Completions { shell } => cmd_completions(shell),
1120        Commands::ShellInit => shell_init::print_shell_init(),
1121        Commands::Metrics { json } => cmd_metrics(json),
1122        Commands::Template { action } => cmd_template(action),
1123        Commands::Config { action } => cmd_config(action),
1124        Commands::Uninstall { yes, all, dry_run } => cmd_uninstall(yes, all, dry_run),
1125        Commands::Audit { action } => cmd_audit(action),
1126        Commands::Diff { name, json } => cmd_diff(&name, json),
1127        Commands::Flake { action } => cmd_flake(action),
1128        Commands::Network { action } => cmd_network(action),
1129        Commands::Image { action } => cmd_image(action),
1130        Commands::Console { name, command } => cmd_console(&name, command.as_deref()),
1131        Commands::Cache { action } => cmd_cache(action),
1132        Commands::Init {
1133            non_interactive,
1134            lima_cpus,
1135            lima_mem,
1136        } => cmd_init(non_interactive, lima_cpus, lima_mem),
1137        Commands::Security { action } => cmd_security(action),
1138        Commands::Exec {
1139            template,
1140            cpus,
1141            memory,
1142            add_dir,
1143            env,
1144            timeout,
1145            launch_plan,
1146            argv,
1147        } => run_oneshot(OneshotParams {
1148            template,
1149            cpus,
1150            memory: &memory,
1151            add_dir: &add_dir,
1152            env: &env,
1153            timeout,
1154            launch_plan,
1155            argv,
1156        }),
1157    };
1158
1159    with_hints(result)
1160}
1161
1162// ============================================================================
1163// Clap value parsers — run at argument-parse time for early validation
1164// ============================================================================
1165
1166/// Validate a VM name at Clap parse time.
1167fn clap_vm_name(s: &str) -> Result<String, String> {
1168    mvm_core::naming::validate_vm_name(s).map_err(|e| e.to_string())?;
1169    Ok(s.to_owned())
1170}
1171
1172/// Validate a Nix flake reference at Clap parse time.
1173fn clap_flake_ref(s: &str) -> Result<String, String> {
1174    mvm_core::naming::validate_flake_ref(s).map_err(|e| e.to_string())?;
1175    Ok(s.to_owned())
1176}
1177
1178/// Validate a port spec (`PORT` or `HOST:GUEST`) at Clap parse time.
1179fn clap_port_spec(s: &str) -> Result<String, String> {
1180    if s.is_empty() {
1181        return Err("port spec must not be empty".to_owned());
1182    }
1183    if let Some((host_part, guest_part)) = s.split_once(':') {
1184        host_part
1185            .parse::<u16>()
1186            .map_err(|_| format!("invalid host port {:?} in {:?}", host_part, s))?;
1187        guest_part
1188            .parse::<u16>()
1189            .map_err(|_| format!("invalid guest port {:?} in {:?}", guest_part, s))?;
1190    } else {
1191        s.parse::<u16>()
1192            .map_err(|_| format!("invalid port {:?} — expected PORT or HOST:GUEST", s))?;
1193    }
1194    Ok(s.to_owned())
1195}
1196
1197/// Validate a volume spec (`host:/guest` or `host:/guest:size`) at Clap parse time.
1198fn clap_volume_spec(s: &str) -> Result<String, String> {
1199    if s.is_empty() {
1200        return Err("volume spec must not be empty".to_owned());
1201    }
1202    let parts: Vec<&str> = s.splitn(3, ':').collect();
1203    if parts.len() < 2 || parts[0].is_empty() || parts[1].is_empty() {
1204        return Err(format!(
1205            "invalid volume {:?} — expected host:/guest or host:/guest:size",
1206            s
1207        ));
1208    }
1209    Ok(s.to_owned())
1210}
1211
1212// ============================================================================
1213// Dev mode handlers
1214// ============================================================================
1215
1216fn cmd_bootstrap(production: bool) -> Result<()> {
1217    ui::info("Bootstrapping full environment...\n");
1218
1219    if !production {
1220        bootstrap::check_package_manager()?;
1221    }
1222
1223    ui::info("\nInstalling prerequisites...");
1224    bootstrap::ensure_lima()?;
1225
1226    // Bootstrap uses default Lima resources (8 vCPUs, 16 GiB), never forces
1227    run_setup_steps(false, 8, 16)?;
1228
1229    ui::success("\nBootstrap complete! Run 'mvmctl dev' to enter the development environment.");
1230    Ok(())
1231}
1232
1233fn cmd_setup(recreate: bool, force: bool, lima_cpus: u32, lima_mem: u32) -> Result<()> {
1234    if recreate {
1235        recreate_rootfs()?;
1236        ui::success("\nRootfs recreated! Run 'mvmctl start' or 'mvmctl dev' to launch.");
1237        return Ok(());
1238    }
1239
1240    if !bootstrap::is_lima_required() {
1241        // Native Linux — just install FC directly
1242        run_setup_steps(force, lima_cpus, lima_mem)?;
1243        ui::success("\nSetup complete! Run 'mvmctl start' to launch a microVM.");
1244        return Ok(());
1245    }
1246
1247    which::which("limactl").map_err(|_| {
1248        anyhow::anyhow!(
1249            "'limactl' not found. Install Lima first: brew install lima\n\
1250             Or run 'mvmctl bootstrap' for full automatic setup."
1251        )
1252    })?;
1253
1254    run_setup_steps(force, lima_cpus, lima_mem)?;
1255
1256    ui::success("\nSetup complete! Run 'mvmctl start' to launch a microVM.");
1257    Ok(())
1258}
1259
1260/// Stop the running microVM and rebuild the rootfs from the upstream squashfs.
1261fn recreate_rootfs() -> Result<()> {
1262    if bootstrap::is_lima_required() {
1263        lima::require_running()?;
1264    }
1265
1266    // Stop Firecracker if running
1267    if firecracker::is_running()? {
1268        ui::info("Stopping running microVM...");
1269        microvm::stop()?;
1270    }
1271
1272    ui::info("Removing existing rootfs...");
1273    shell::run_in_vm(&format!(
1274        "rm -f {dir}/ubuntu-*.ext4",
1275        dir = config::MICROVM_DIR,
1276    ))?;
1277
1278    ui::info("Rebuilding rootfs...");
1279    firecracker::prepare_rootfs()?;
1280    firecracker::write_state()?;
1281
1282    Ok(())
1283}
1284
1285fn cmd_dev(
1286    lima_cpus: u32,
1287    lima_mem: u32,
1288    project: Option<&str>,
1289    metrics_port: u16,
1290    watch_config: bool,
1291) -> Result<()> {
1292    let _metrics_server = if metrics_port > 0 {
1293        Some(crate::metrics_server::MetricsServer::start(metrics_port)?)
1294    } else {
1295        None
1296    };
1297
1298    // Start config watcher before setup so any reload during bootstrap is captured.
1299    let _config_watcher = if watch_config {
1300        let config_path = {
1301            let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
1302            std::path::PathBuf::from(home)
1303                .join(".mvm")
1304                .join("config.toml")
1305        };
1306        if config_path.exists() {
1307            match crate::config_watcher::ConfigWatcher::start(&config_path) {
1308                Ok(w) => {
1309                    tracing::info!("Watching ~/.mvm/config.toml for changes");
1310                    Some(w)
1311                }
1312                Err(e) => {
1313                    tracing::warn!("Could not start config watcher: {e}");
1314                    None
1315                }
1316            }
1317        } else {
1318            None
1319        }
1320    } else {
1321        None
1322    };
1323
1324    ui::info("Launching development environment...\n");
1325
1326    if bootstrap::is_lima_required() {
1327        // macOS or Linux without KVM — need Lima
1328        if which::which("limactl").is_err() {
1329            ui::info("Lima not found. Running bootstrap...\n");
1330            cmd_bootstrap(false)?;
1331        } else {
1332            let lima_status = lima::get_status()?;
1333            match lima_status {
1334                lima::LimaStatus::NotFound => {
1335                    ui::info("Lima VM not found. Running setup...\n");
1336                    run_setup_steps(false, lima_cpus, lima_mem)?;
1337                }
1338                lima::LimaStatus::Stopped => {
1339                    ui::info("Lima VM is stopped. Starting...");
1340                    lima::start()?;
1341                }
1342                lima::LimaStatus::Running => {}
1343            }
1344        }
1345    }
1346
1347    // Install Firecracker binary if not present
1348    if !firecracker::is_installed()? {
1349        ui::info("Firecracker not installed. Installing...\n");
1350        firecracker::install()?;
1351    }
1352
1353    // Download kernel + squashfs only if missing
1354    if !firecracker::has_base_assets()? {
1355        ui::info("Downloading kernel and rootfs...\n");
1356        firecracker::download_assets()?;
1357        firecracker::prepare_rootfs()?;
1358        firecracker::write_state()?;
1359    }
1360
1361    // Ensure shell completions and dev aliases are in ~/.zshrc
1362    shell_init::ensure_shell_init()?;
1363
1364    // Drop into the Lima VM shell (the development environment)
1365    cmd_shell(project)
1366}
1367
1368fn cmd_dev_down() -> Result<()> {
1369    if !bootstrap::is_lima_required() {
1370        ui::info("Lima is not required on this platform (native KVM available).");
1371        return Ok(());
1372    }
1373
1374    if which::which("limactl").is_err() {
1375        anyhow::bail!("Lima is not installed. Run 'mvmctl dev up' to bootstrap first.");
1376    }
1377
1378    let status = lima::get_status()?;
1379    match status {
1380        lima::LimaStatus::Running => {
1381            ui::info("Stopping Lima development VM...");
1382            lima::stop()?;
1383            ui::success("Development VM stopped.");
1384            Ok(())
1385        }
1386        lima::LimaStatus::Stopped => {
1387            ui::info("Development VM is already stopped.");
1388            Ok(())
1389        }
1390        lima::LimaStatus::NotFound => {
1391            anyhow::bail!(
1392                "Lima VM '{}' does not exist. Run 'mvmctl dev up' first.",
1393                config::VM_NAME
1394            );
1395        }
1396    }
1397}
1398
1399fn cmd_dev_status() -> Result<()> {
1400    if !bootstrap::is_lima_required() {
1401        ui::info("Lima is not required on this platform (native KVM available).");
1402        return Ok(());
1403    }
1404
1405    if which::which("limactl").is_err() {
1406        ui::warn("Lima is not installed. Run 'mvmctl dev up' to bootstrap.");
1407        return Ok(());
1408    }
1409
1410    let status = lima::get_status()?;
1411    let status_str = match status {
1412        lima::LimaStatus::Running => "Running",
1413        lima::LimaStatus::Stopped => "Stopped",
1414        lima::LimaStatus::NotFound => "Not found",
1415    };
1416
1417    ui::info(&format!("Lima VM '{}': {status_str}", config::VM_NAME));
1418
1419    if matches!(status, lima::LimaStatus::Running) {
1420        let fc_ver = shell::run_in_vm_stdout("firecracker --version 2>/dev/null | head -1")
1421            .unwrap_or_default();
1422        let nix_ver = shell::run_in_vm_stdout("nix --version 2>/dev/null").unwrap_or_default();
1423
1424        ui::info(&format!(
1425            "  Firecracker: {}",
1426            if fc_ver.trim().is_empty() {
1427                "not installed"
1428            } else {
1429                fc_ver.trim()
1430            }
1431        ));
1432        ui::info(&format!(
1433            "  Nix:         {}",
1434            if nix_ver.trim().is_empty() {
1435                "not installed"
1436            } else {
1437                nix_ver.trim()
1438            }
1439        ));
1440
1441        let mvm_in_vm =
1442            shell::run_in_vm_stdout("test -f /usr/local/bin/mvmctl && echo yes || echo no")
1443                .unwrap_or_default();
1444        if mvm_in_vm.trim() == "yes" {
1445            let mvm_ver = shell::run_in_vm_stdout("/usr/local/bin/mvmctl --version 2>/dev/null")
1446                .unwrap_or_default();
1447            ui::info(&format!(
1448                "  mvmctl:      {}",
1449                if mvm_ver.trim().is_empty() {
1450                    "installed"
1451                } else {
1452                    mvm_ver.trim()
1453                }
1454            ));
1455        } else {
1456            ui::warn("  mvmctl not installed in VM. Run 'mvmctl sync' to build and install it.");
1457        }
1458    }
1459
1460    Ok(())
1461}
1462
1463// ============================================================================
1464// Apple Container dev environment
1465// ============================================================================
1466
1467const DEV_VM_NAME: &str = "mvm-dev";
1468
1469/// Check if the Apple Container dev VM is running.
1470/// Checks both in-process VM tracking (PID file) and launchd agent.
1471fn is_apple_container_dev_running() -> bool {
1472    // Check persisted PID file (also checks if process is alive)
1473    let pid_running = mvm_apple_container::list_ids()
1474        .iter()
1475        .any(|id| id == DEV_VM_NAME);
1476    if pid_running {
1477        return true;
1478    }
1479    // Check if launchd agent is installed and loaded
1480    if dev_launchd_plist_path().exists() {
1481        let output = std::process::Command::new("launchctl")
1482            .args(["list", DEV_LAUNCHD_LABEL])
1483            .output();
1484        if let Ok(o) = output
1485            && o.status.success()
1486        {
1487            return true;
1488        }
1489    }
1490    false
1491}
1492
1493/// Boot the Apple Container dev VM, optionally opening an interactive console.
1494fn cmd_dev_apple_container(cpus: u32, memory_gib: u32, open_shell: bool) -> Result<()> {
1495    let is_daemon = std::env::var("MVM_DEV_DAEMON").as_deref() == Ok("1");
1496
1497    // When running as the daemon process, do the actual VM boot.
1498    if is_daemon {
1499        return cmd_dev_apple_container_daemon(cpus, memory_gib);
1500    }
1501
1502    ui::info("Starting dev environment via Apple Container...\n");
1503
1504    if is_apple_container_dev_running() {
1505        if open_shell {
1506            ui::info("Dev VM already running. Opening shell...");
1507            return console_interactive(DEV_VM_NAME);
1508        }
1509        ui::info("Dev VM already running.");
1510        return Ok(());
1511    }
1512
1513    // Clean up stale state from a previous process that died.
1514    cleanup_stale_dev_vm();
1515
1516    // Ensure dev image exists (build if needed — this runs in the CLI process)
1517    let (kernel, rootfs) = ensure_dev_image()?;
1518
1519    // Launch a background daemon process that keeps the VM alive.
1520    let exe = std::env::current_exe().context("cannot find current executable")?;
1521    let log_dir = format!("{}/dev", mvm_core::config::mvm_cache_dir());
1522    std::fs::create_dir_all(&log_dir)?;
1523
1524    // Sign the binary BEFORE launching via launchd. The daemon runs with
1525    // MVM_SIGNED=1 so it won't re-exec (which would lose launchd context).
1526    mvm_apple_container::ensure_signed();
1527
1528    ui::info(&format!(
1529        "Booting dev VM ({} vCPUs, {} GiB memory)...",
1530        cpus, memory_gib
1531    ));
1532
1533    // Install a launchd agent to run the daemon. This is a proper macOS
1534    // service that is cleanly unloaded by `dev down`.
1535    install_dev_launchd_agent(&exe, &kernel, &rootfs, cpus, memory_gib, &log_dir)?;
1536
1537    // Wait for the VM to become ready (vsock proxy socket + guest agent reachable)
1538    let proxy_path = dev_vsock_proxy_path();
1539    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
1540    loop {
1541        if std::time::Instant::now() > deadline {
1542            anyhow::bail!(
1543                "Dev VM did not start within 60 seconds.\n\
1544                           Check logs: {log_dir}/daemon-stderr.log"
1545            );
1546        }
1547        if std::path::Path::new(&proxy_path).exists()
1548            && vsock_proxy_connect(&proxy_path, mvm_guest::vsock::GUEST_AGENT_PORT).is_ok()
1549        {
1550            break;
1551        }
1552        std::thread::sleep(std::time::Duration::from_millis(500));
1553    }
1554
1555    ui::success("Dev VM ready.");
1556    ui::info("  Shell:      mvmctl dev shell");
1557    ui::info("  Stop VM:    mvmctl dev down");
1558
1559    if open_shell {
1560        ui::info("");
1561        let _ = console_interactive(DEV_VM_NAME);
1562    }
1563
1564    Ok(())
1565}
1566
1567/// Path for the vsock proxy Unix socket.
1568fn dev_vsock_proxy_path() -> String {
1569    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
1570    format!("{home}/.mvm/vms/{DEV_VM_NAME}/vsock.sock")
1571}
1572
1573/// Daemon mode: boot the VM, expose a vsock proxy socket, and block forever.
1574fn cmd_dev_apple_container_daemon(cpus: u32, memory_gib: u32) -> Result<()> {
1575    let kernel = std::env::var("MVM_DEV_KERNEL")
1576        .unwrap_or_else(|_| format!("{}/dev/vmlinux", mvm_core::config::mvm_cache_dir()));
1577    let rootfs = std::env::var("MVM_DEV_ROOTFS")
1578        .unwrap_or_else(|_| format!("{}/dev/rootfs.ext4", mvm_core::config::mvm_cache_dir()));
1579
1580    let memory_mib = (memory_gib as u64) * 1024;
1581    mvm_apple_container::start(DEV_VM_NAME, &kernel, &rootfs, cpus, memory_mib)
1582        .map_err(|e| anyhow::anyhow!("Failed to start dev VM: {e}"))?;
1583
1584    // Start a vsock proxy: listen on a Unix socket and proxy each
1585    // connection to the guest agent's vsock port. This lets `dev shell`
1586    // from another process connect to the VM.
1587    let proxy_path = dev_vsock_proxy_path();
1588    let _ = std::fs::remove_file(&proxy_path);
1589    start_vsock_proxy(&proxy_path);
1590
1591    // Block forever — the VM lives in this process.
1592    loop {
1593        std::thread::park();
1594    }
1595}
1596
1597const DEV_LAUNCHD_LABEL: &str = "com.mvm.dev";
1598
1599fn dev_launchd_plist_path() -> std::path::PathBuf {
1600    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
1601    std::path::PathBuf::from(format!(
1602        "{home}/Library/LaunchAgents/{DEV_LAUNCHD_LABEL}.plist"
1603    ))
1604}
1605
1606fn install_dev_launchd_agent(
1607    exe: &std::path::Path,
1608    kernel: &str,
1609    rootfs: &str,
1610    cpus: u32,
1611    memory_gib: u32,
1612    log_dir: &str,
1613) -> Result<()> {
1614    // Unload any previous agent first
1615    unload_dev_launchd_agent();
1616
1617    let plist = format!(
1618        r#"<?xml version="1.0" encoding="UTF-8"?>
1619<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1620<plist version="1.0">
1621<dict>
1622    <key>Label</key>
1623    <string>{DEV_LAUNCHD_LABEL}</string>
1624    <key>ProgramArguments</key>
1625    <array>
1626        <string>{exe}</string>
1627        <string>dev</string>
1628        <string>up</string>
1629    </array>
1630    <key>EnvironmentVariables</key>
1631    <dict>
1632        <key>MVM_DEV_DAEMON</key>
1633        <string>1</string>
1634        <key>MVM_DEV_KERNEL</key>
1635        <string>{kernel}</string>
1636        <key>MVM_DEV_ROOTFS</key>
1637        <string>{rootfs}</string>
1638        <key>MVM_DEV_CPUS</key>
1639        <string>{cpus}</string>
1640        <key>MVM_DEV_MEM_GIB</key>
1641        <string>{memory_gib}</string>
1642        <key>MVM_SIGNED</key>
1643        <string>0</string>
1644    </dict>
1645    <key>RunAtLoad</key>
1646    <true/>
1647    <key>KeepAlive</key>
1648    <false/>
1649    <key>StandardOutPath</key>
1650    <string>{log_dir}/daemon-stdout.log</string>
1651    <key>StandardErrorPath</key>
1652    <string>{log_dir}/daemon-stderr.log</string>
1653</dict>
1654</plist>"#,
1655        exe = exe.display(),
1656    );
1657
1658    let plist_path = dev_launchd_plist_path();
1659    let agents_dir = plist_path.parent().expect("plist path must have parent");
1660    std::fs::create_dir_all(agents_dir)?;
1661    std::fs::write(&plist_path, &plist)?;
1662
1663    let output = std::process::Command::new("launchctl")
1664        .args(["load", plist_path.to_str().unwrap_or("")])
1665        .output()
1666        .context("Failed to run launchctl")?;
1667
1668    if !output.status.success() {
1669        let stderr = String::from_utf8_lossy(&output.stderr);
1670        anyhow::bail!("launchctl load failed: {stderr}");
1671    }
1672
1673    Ok(())
1674}
1675
1676fn unload_dev_launchd_agent() {
1677    let plist_path = dev_launchd_plist_path();
1678    if plist_path.exists() {
1679        let _ = std::process::Command::new("launchctl")
1680            .args(["unload", plist_path.to_str().unwrap_or("")])
1681            .output();
1682        let _ = std::fs::remove_file(&plist_path);
1683    }
1684}
1685
1686/// Listen on a Unix socket and proxy each connection to the VM's vsock.
1687fn start_vsock_proxy(socket_path: &str) {
1688    use std::os::unix::net::UnixListener;
1689
1690    let listener = match UnixListener::bind(socket_path) {
1691        Ok(l) => l,
1692        Err(e) => {
1693            tracing::warn!("Failed to start vsock proxy: {e}");
1694            return;
1695        }
1696    };
1697
1698    std::thread::spawn(move || {
1699        for stream in listener.incoming().flatten() {
1700            // Each connection: read the target vsock port from the first 4 bytes,
1701            // then proxy bidirectionally to that vsock port.
1702            std::thread::spawn(move || {
1703                use std::io::Read;
1704                let mut client = stream;
1705                let mut port_buf = [0u8; 4];
1706                if client.read_exact(&mut port_buf).is_err() {
1707                    return;
1708                }
1709                let port = u32::from_le_bytes(port_buf);
1710
1711                let vsock = match mvm_apple_container::vsock_connect(DEV_VM_NAME, port) {
1712                    Ok(s) => s,
1713                    Err(_) => return,
1714                };
1715
1716                // Bidirectional proxy
1717                let mut vsock_read = match vsock.try_clone() {
1718                    Ok(s) => s,
1719                    Err(_) => return,
1720                };
1721                let mut client_write = match client.try_clone() {
1722                    Ok(s) => s,
1723                    Err(_) => return,
1724                };
1725
1726                let h = std::thread::spawn(move || {
1727                    let _ = std::io::copy(&mut vsock_read, &mut client_write);
1728                });
1729                let mut vsock_write = vsock;
1730                let _ = std::io::copy(&mut client, &mut vsock_write);
1731                let _ = h.join();
1732            });
1733        }
1734    });
1735}
1736
1737/// Kill the process that owns the dev VM and clean up its state.
1738fn stop_dev_vm_owner() {
1739    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
1740    let vm_dir = std::path::PathBuf::from(format!("{home}/.mvm/vms/{DEV_VM_NAME}"));
1741    let pid_file = vm_dir.join("pid");
1742
1743    if let Ok(pid_str) = std::fs::read_to_string(&pid_file)
1744        && let Ok(pid) = pid_str.trim().parse::<i32>()
1745    {
1746        // Don't kill ourselves
1747        if pid as u32 != std::process::id() {
1748            unsafe {
1749                libc::kill(pid, libc::SIGTERM);
1750            }
1751            // Wait briefly for it to exit
1752            for _ in 0..20 {
1753                if unsafe { libc::kill(pid, 0) } != 0 {
1754                    break;
1755                }
1756                std::thread::sleep(std::time::Duration::from_millis(100));
1757            }
1758        }
1759    }
1760
1761    let _ = std::fs::remove_dir_all(&vm_dir);
1762}
1763
1764/// Clean up stale persisted state from a dead dev VM process.
1765fn cleanup_stale_dev_vm() {
1766    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
1767    let vm_dir = std::path::PathBuf::from(format!("{home}/.mvm/vms/{DEV_VM_NAME}"));
1768    let pid_file = vm_dir.join("pid");
1769
1770    if !pid_file.exists() {
1771        return;
1772    }
1773
1774    let pid_str = match std::fs::read_to_string(&pid_file) {
1775        Ok(s) => s.trim().to_string(),
1776        Err(_) => return,
1777    };
1778    let pid: i32 = match pid_str.parse() {
1779        Ok(p) => p,
1780        Err(_) => return,
1781    };
1782
1783    // Check if the process is still alive (signal 0 = existence check)
1784    let alive = unsafe { libc::kill(pid, 0) } == 0;
1785    if alive {
1786        return; // process still running, not stale
1787    }
1788
1789    ui::info("Cleaning up stale dev VM state from a previous session...");
1790    let _ = std::fs::remove_dir_all(&vm_dir);
1791}
1792
1793/// Stop the Apple Container dev VM.
1794fn cmd_dev_apple_container_down() -> Result<()> {
1795    let was_running = is_apple_container_dev_running() || dev_launchd_plist_path().exists();
1796
1797    // Unload the launchd agent (stops the daemon process)
1798    unload_dev_launchd_agent();
1799    // Kill any lingering daemon process
1800    stop_dev_vm_owner();
1801    // Clean up state files
1802    cleanup_stale_dev_vm();
1803    let _ = std::fs::remove_file(dev_vsock_proxy_path());
1804
1805    if was_running {
1806        ui::success("Dev VM stopped.");
1807    } else {
1808        ui::info("Dev VM is not running.");
1809    }
1810    Ok(())
1811}
1812
1813/// Show Apple Container dev VM status.
1814fn cmd_dev_apple_container_status() -> Result<()> {
1815    let running = is_apple_container_dev_running();
1816    ui::info("Backend:  Apple Container (Virtualization.framework)");
1817    ui::info(&format!("Dev VM:   {DEV_VM_NAME}"));
1818    ui::info(&format!(
1819        "Status:   {}",
1820        if running { "running" } else { "stopped" }
1821    ));
1822
1823    if running
1824        && let Ok(mut stream) =
1825            mvm_apple_container::vsock_connect(DEV_VM_NAME, mvm_guest::vsock::GUEST_AGENT_PORT)
1826        && let Ok(mvm_guest::vsock::GuestResponse::ExecResult { stdout, .. }) =
1827            mvm_guest::vsock::send_request(
1828                &mut stream,
1829                &mvm_guest::vsock::GuestRequest::Exec {
1830                    command: "uname -r".to_string(),
1831                    stdin: None,
1832                    timeout_secs: Some(5),
1833                },
1834            )
1835    {
1836        ui::info(&format!("  Kernel:  {}", stdout.trim()));
1837    }
1838
1839    // Show dev image info
1840    let cache_dir = format!("{}/dev", mvm_core::config::mvm_cache_dir());
1841    let kernel_path = format!("{cache_dir}/vmlinux");
1842    let rootfs_path = format!("{cache_dir}/rootfs.ext4");
1843    ui::info(&format!(
1844        "  Image:   {}",
1845        if std::path::Path::new(&rootfs_path).exists() {
1846            "cached"
1847        } else {
1848            "not built"
1849        }
1850    ));
1851    if std::path::Path::new(&kernel_path).exists() {
1852        ui::info(&format!("  Kernel:  {kernel_path}"));
1853    }
1854    if std::path::Path::new(&rootfs_path).exists() {
1855        ui::info(&format!("  Rootfs:  {rootfs_path}"));
1856    }
1857
1858    Ok(())
1859}
1860
1861/// Ensure the Nix linux-builder VM is running, SSH is configured, and
1862/// nix-daemon knows about it.
1863///
1864/// `nix run 'nixpkgs#darwin.linux-builder'` starts a QEMU VM on port 31022
1865/// and writes `/etc/nix/builder_ed25519`, but does NOT create the SSH config
1866/// that maps `linux-builder` → `localhost:31022`. It also does not add the
1867/// `builders` line to nix.conf. This function handles all three pieces:
1868/// 1. Start the builder VM in the background if not already running
1869/// 2. Write the SSH host alias so nix-daemon can connect
1870/// 3. Add the `builders` line to nix.custom.conf
1871///
1872/// Returns `true` if the builder appears reachable after any fixup.
1873fn ensure_linux_builder_ssh_config() -> bool {
1874    #[cfg(not(target_os = "macos"))]
1875    {
1876        false
1877    }
1878
1879    #[cfg(target_os = "macos")]
1880    {
1881        use std::io::Write;
1882        use std::net::TcpStream;
1883        use std::time::Duration;
1884
1885        let key_path = "/etc/nix/builder_ed25519";
1886        let builder_port: u16 = 31022;
1887
1888        let builder_listening = || {
1889            TcpStream::connect_timeout(
1890                &format!("127.0.0.1:{builder_port}")
1891                    .parse()
1892                    .expect("valid socket address literal"),
1893                Duration::from_secs(2),
1894            )
1895            .is_ok()
1896        };
1897
1898        // If builder is not listening, try to start it automatically
1899        if !builder_listening() {
1900            let nix_bin = find_nix_binary();
1901            ui::info("  Starting Nix linux-builder VM in the background...");
1902
1903            // Launch as a background process. The builder writes
1904            // /etc/nix/builder_ed25519 on first run (needs sudo).
1905            // Redirect output to a log file so it doesn't clutter the terminal.
1906            let log_path = format!("{}/linux-builder.log", mvm_core::config::mvm_cache_dir());
1907            let log_file = std::fs::File::create(&log_path)
1908                .or_else(|_| std::fs::File::create("/dev/null"))
1909                .expect("failed to open /dev/null");
1910            let stderr_file = log_file
1911                .try_clone()
1912                .or_else(|_| std::fs::File::create("/dev/null"))
1913                .expect("failed to open /dev/null");
1914
1915            let child = std::process::Command::new(&nix_bin)
1916                .args(["run", "nixpkgs#darwin.linux-builder"])
1917                .stdout(log_file)
1918                .stderr(stderr_file)
1919                .stdin(std::process::Stdio::null())
1920                .spawn();
1921
1922            if child.is_err() {
1923                return false;
1924            }
1925
1926            // Wait for the builder to become ready (port 31022 + key file).
1927            // First boot can take a while as it downloads the builder VM.
1928            ui::info(
1929                "  Waiting for linux-builder to become ready (this may take a minute on first run)...",
1930            );
1931            let deadline = std::time::Instant::now() + Duration::from_secs(120);
1932            loop {
1933                if std::time::Instant::now() > deadline {
1934                    ui::warn("  Timed out waiting for linux-builder to start.");
1935                    return false;
1936                }
1937                if std::path::Path::new(key_path).exists() && builder_listening() {
1938                    break;
1939                }
1940                std::thread::sleep(Duration::from_secs(2));
1941            }
1942        }
1943
1944        // Key must exist (created by the linux-builder on first run)
1945        if !std::path::Path::new(key_path).exists() {
1946            return false;
1947        }
1948
1949        // --- SSH config ---
1950        // Check that the SSH config exists AND uses the correct user ("builder",
1951        // not "root"). Older versions wrote "User root" which doesn't work.
1952        let ssh_config_dir = std::path::Path::new("/etc/ssh/ssh_config.d");
1953        let config_path = ssh_config_dir.join("200-linux-builder.conf");
1954
1955        let expected_config = format!(
1956            "Host linux-builder\n\
1957             \x20 HostName localhost\n\
1958             \x20 Port {builder_port}\n\
1959             \x20 User builder\n\
1960             \x20 IdentityFile {key_path}\n\
1961             \x20 IdentitiesOnly yes\n\
1962             \x20 StrictHostKeyChecking no\n\
1963             \x20 UserKnownHostsFile /dev/null\n\
1964             \x20 LogLevel ERROR\n"
1965        );
1966
1967        let ssh_needs_write = if config_path.exists() {
1968            // Re-write if the existing config has wrong user
1969            std::fs::read_to_string(&config_path)
1970                .map(|c| !c.contains("User builder"))
1971                .unwrap_or(true)
1972        } else {
1973            // Also check via ssh -G whether some other config provides
1974            // a correct mapping (e.g. user's own ~/.ssh/config)
1975            let ssh_check = std::process::Command::new("ssh")
1976                .args(["-G", "linux-builder"])
1977                .output();
1978            if let Ok(out) = ssh_check {
1979                let cfg = String::from_utf8_lossy(&out.stdout);
1980                let has_host = cfg.lines().any(|l| {
1981                    l.strip_prefix("hostname ")
1982                        .is_some_and(|h| h.trim() != "linux-builder")
1983                });
1984                let has_user = cfg.lines().any(|l| {
1985                    l.strip_prefix("user ")
1986                        .is_some_and(|u| u.trim() == "builder")
1987                });
1988                !has_host || !has_user
1989            } else {
1990                true
1991            }
1992        };
1993
1994        let mut ssh_ok = !ssh_needs_write;
1995        if ssh_needs_write {
1996            let tmp_path = "/tmp/mvm-linux-builder-ssh.conf";
1997            if let Ok(mut f) = std::fs::File::create(tmp_path)
1998                && f.write_all(expected_config.as_bytes()).is_ok()
1999            {
2000                let status = std::process::Command::new("sudo")
2001                    .args(["cp", tmp_path, config_path.to_str().unwrap_or_default()])
2002                    .status();
2003                let _ = std::fs::remove_file(tmp_path);
2004                ssh_ok = matches!(status, Ok(s) if s.success());
2005            }
2006        }
2007
2008        if !ssh_ok {
2009            return false;
2010        }
2011
2012        // --- nix.conf builders line ---
2013        // nix-daemon needs a `builders` entry pointing at the linux-builder.
2014        // Determinate Nix uses nix.custom.conf (included from nix.conf).
2015        // Also fix stale configs that used the wrong SSH user.
2016        let builders_line = format!(
2017            "builders = ssh-ng://builder@linux-builder aarch64-linux {key_path} 4 1 kvm,big-parallel - -"
2018        );
2019
2020        let nix_custom = std::path::Path::new("/etc/nix/nix.custom.conf");
2021        let nix_conf = std::path::Path::new("/etc/nix/nix.conf");
2022
2023        let nix_needs_write = {
2024            let has_correct = [nix_custom, nix_conf].iter().any(|path| {
2025                std::fs::read_to_string(path)
2026                    .map(|c| {
2027                        c.lines().any(|l| {
2028                            l.trim_start().starts_with("builders")
2029                                && l.contains("builder@linux-builder")
2030                        })
2031                    })
2032                    .unwrap_or(false)
2033            });
2034            !has_correct
2035        };
2036
2037        if nix_needs_write {
2038            ui::info("  Configuring nix-daemon to use the linux-builder...");
2039
2040            // Read existing content, strip any old mvmctl builder block, append fresh one
2041            let existing = std::fs::read_to_string(nix_custom).unwrap_or_default();
2042            let cleaned: String = {
2043                let mut skip = false;
2044                let mut lines = Vec::new();
2045                for line in existing.lines() {
2046                    if line.contains("Added by mvmctl for darwin.linux-builder") {
2047                        skip = true;
2048                        continue;
2049                    }
2050                    if skip {
2051                        // Skip the builders and builders-use-substitutes lines
2052                        if line.trim_start().starts_with("builders") {
2053                            continue;
2054                        }
2055                        // Blank line after the block — skip it too, then stop skipping
2056                        if line.trim().is_empty() {
2057                            skip = false;
2058                            continue;
2059                        }
2060                        skip = false;
2061                    }
2062                    lines.push(line);
2063                }
2064                lines.join("\n")
2065            };
2066
2067            let new_content = format!(
2068                "{cleaned}\n\
2069                 # Added by mvmctl for darwin.linux-builder\n\
2070                 {builders_line}\n\
2071                 builders-use-substitutes = true\n"
2072            );
2073
2074            let tmp_path = "/tmp/mvm-nix-custom-append.conf";
2075            if let Ok(mut f) = std::fs::File::create(tmp_path)
2076                && f.write_all(new_content.as_bytes()).is_ok()
2077            {
2078                let status = std::process::Command::new("sudo")
2079                    .args(["cp", tmp_path, nix_custom.to_str().unwrap_or_default()])
2080                    .status();
2081                let _ = std::fs::remove_file(tmp_path);
2082                if !matches!(status, Ok(s) if s.success()) {
2083                    return false;
2084                }
2085
2086                // Restart nix-daemon so it picks up the new config.
2087                let restarted = std::process::Command::new("sudo")
2088                    .args([
2089                        "launchctl",
2090                        "kickstart",
2091                        "-k",
2092                        "system/systems.determinate.nix-daemon",
2093                    ])
2094                    .status()
2095                    .is_ok_and(|s| s.success());
2096                if !restarted {
2097                    let _ = std::process::Command::new("sudo")
2098                        .args([
2099                            "launchctl",
2100                            "kickstart",
2101                            "-k",
2102                            "system/org.nixos.nix-daemon",
2103                        ])
2104                        .status();
2105                }
2106            }
2107        }
2108
2109        true
2110    }
2111}
2112
2113/// Ensure the dev image (kernel + rootfs) exists in the cache.
2114///
2115/// Returns (kernel_path, rootfs_path). Builds from the dev-image Nix flake
2116/// if not cached.
2117fn ensure_dev_image() -> Result<(String, String)> {
2118    let cache_dir = format!("{}/dev", mvm_core::config::mvm_cache_dir());
2119    std::fs::create_dir_all(&cache_dir)?;
2120
2121    let kernel_path = format!("{cache_dir}/vmlinux");
2122    let rootfs_path = format!("{cache_dir}/rootfs.ext4");
2123
2124    if std::path::Path::new(&kernel_path).exists() && std::path::Path::new(&rootfs_path).exists() {
2125        return Ok((kernel_path, rootfs_path));
2126    }
2127
2128    // Try Nix build first (works if Linux builder is configured)
2129    let plat = mvm_core::platform::current();
2130    if plat.has_host_nix()
2131        && let Ok(flake_dir) = find_dev_image_flake()
2132    {
2133        ui::info("Building dev image via Nix (first time only)...");
2134        let nix_bin = find_nix_binary();
2135
2136        // On macOS, ensure the linux-builder SSH config exists so nix-daemon
2137        // can reach the builder VM on localhost:31022.
2138        if cfg!(target_os = "macos") && ensure_linux_builder_ssh_config() {
2139            ui::info("  Linux builder detected and SSH configured.");
2140        }
2141
2142        // Stream stderr to the terminal so the user sees build progress,
2143        // while capturing stdout (which contains the store path).
2144        let mut child = std::process::Command::new(&nix_bin)
2145            .args([
2146                "build",
2147                &format!(
2148                    "{flake_dir}#packages.{}.default",
2149                    mvm_build::dev_build::linux_system()
2150                ),
2151                "--no-link",
2152                "--print-out-paths",
2153            ])
2154            .stdout(std::process::Stdio::piped())
2155            .stderr(std::process::Stdio::inherit())
2156            .spawn()
2157            .context("Failed to run nix build")?;
2158
2159        let stdout = {
2160            let mut buf = String::new();
2161            if let Some(mut out) = child.stdout.take() {
2162                use std::io::Read;
2163                let _ = out.read_to_string(&mut buf);
2164            }
2165            buf
2166        };
2167        let status = child.wait().context("nix build process failed")?;
2168
2169        if status.success() {
2170            let store_path = stdout.trim().to_string();
2171            let ks = format!("{store_path}/vmlinux");
2172            let rs = format!("{store_path}/rootfs.ext4");
2173            if std::path::Path::new(&ks).exists() && std::path::Path::new(&rs).exists() {
2174                std::fs::copy(&ks, &kernel_path)?;
2175                std::fs::copy(&rs, &rootfs_path)?;
2176                ui::success("Dev image built and cached.");
2177                return Ok((kernel_path, rootfs_path));
2178            }
2179        }
2180
2181        // nix build failed. Re-run with captured stderr to detect the error type
2182        // (the first run streamed stderr to the terminal for user visibility).
2183        let diag = std::process::Command::new(&nix_bin)
2184            .args([
2185                "build",
2186                &format!(
2187                    "{flake_dir}#packages.{}.default",
2188                    mvm_build::dev_build::linux_system()
2189                ),
2190                "--no-link",
2191                "--dry-run",
2192            ])
2193            .output()
2194            .ok();
2195        let stderr = diag
2196            .as_ref()
2197            .map(|o| String::from_utf8_lossy(&o.stderr).into_owned())
2198            .unwrap_or_default();
2199        if stderr.contains("required system or feature not available") {
2200            ui::warn(
2201                "Nix cannot cross-compile Linux images on this Mac.\n\
2202                 No Linux builder detected. To fix this, either:\n\n\
2203                 \x20 1. Run in another terminal (keeps running):\n\
2204                 \x20    nix run 'nixpkgs#darwin.linux-builder'\n\n\
2205                 \x20 2. Or add to /etc/nix/nix.conf (permanent):\n\
2206                 \x20    builders = ssh-ng://builder@linux-builder aarch64-linux /etc/nix/builder_ed25519 4 1 kvm,big-parallel - -\n\
2207                 \x20    builders-use-substitutes = true\n\n\
2208                 Falling back to downloading a pre-built dev image...",
2209            );
2210        } else {
2211            ui::warn(&format!("Nix build failed, trying download:\n{stderr}"));
2212        }
2213    }
2214
2215    // Fallback: download pre-built dev image from GitHub release
2216    download_dev_image(&kernel_path, &rootfs_path)
2217}
2218
2219/// Download a pre-built dev image (kernel + rootfs) from GitHub releases.
2220fn download_dev_image(kernel_path: &str, rootfs_path: &str) -> Result<(String, String)> {
2221    let version = env!("CARGO_PKG_VERSION");
2222    let base_url = format!("https://github.com/auser/mvm/releases/download/v{version}");
2223    // Detect host arch to download the right image.
2224    // Apple Silicon (aarch64-darwin) needs aarch64-linux image.
2225    // Intel Mac (x86_64-darwin) needs x86_64-linux image.
2226    let arch = if cfg!(target_arch = "aarch64") {
2227        "aarch64"
2228    } else {
2229        "x86_64"
2230    };
2231    let kernel_url = format!("{base_url}/dev-vmlinux-{arch}");
2232    let rootfs_url = format!("{base_url}/dev-rootfs-{arch}.ext4");
2233
2234    ui::info(&format!("Downloading dev image (v{version})..."));
2235
2236    // Download kernel
2237    ui::info("  Fetching kernel...");
2238    download_file(&kernel_url, kernel_path)
2239        .with_context(|| format!("Failed to download kernel from {kernel_url}"))?;
2240
2241    // Download rootfs
2242    ui::info("  Fetching rootfs...");
2243    download_file(&rootfs_url, rootfs_path)
2244        .with_context(|| format!("Failed to download rootfs from {rootfs_url}"))?;
2245
2246    ui::success("Dev image downloaded and cached.");
2247    Ok((kernel_path.to_string(), rootfs_path.to_string()))
2248}
2249
2250/// Download a file from a URL using curl.
2251fn download_file(url: &str, dest: &str) -> Result<()> {
2252    let status = std::process::Command::new("curl")
2253        .args(["-fSL", "--progress-bar", "-o", dest, url])
2254        .stdin(std::process::Stdio::inherit())
2255        .stdout(std::process::Stdio::inherit())
2256        .stderr(std::process::Stdio::inherit())
2257        .status()
2258        .context("Failed to run curl")?;
2259
2260    if !status.success() {
2261        // Clean up partial download
2262        let _ = std::fs::remove_file(dest);
2263        anyhow::bail!(
2264            "Download failed. Pre-built images for v{version} may not yet be\n\
2265             published — release tags are pushed before the artifact-build\n\
2266             matrix completes, so a 404 here often just means the build is\n\
2267             still in flight. Check the release page or retry in a few\n\
2268             minutes:\n\
2269             \n\
2270             \x20   https://github.com/auser/mvm/releases/tag/v{version}\n\
2271             \n\
2272             To build locally instead, set up a Nix Linux builder:\n\
2273             \n\
2274             \x20 Option 1 — Temporary (run in another terminal):\n\
2275             \x20   nix run 'nixpkgs#darwin.linux-builder'\n\
2276             \n\
2277             \x20 Option 2 — Permanent (add to /etc/nix/nix.conf):\n\
2278             \x20   builders = ssh-ng://builder@linux-builder aarch64-linux /etc/nix/builder_ed25519 4 1 kvm,big-parallel - -\n\
2279             \x20   builders-use-substitutes = true",
2280            version = env!("CARGO_PKG_VERSION")
2281        );
2282    }
2283    Ok(())
2284}
2285
2286/// Find the `nix` binary, checking PATH and common install locations.
2287fn find_nix_binary() -> String {
2288    if which::which("nix").is_ok() {
2289        return "nix".to_string();
2290    }
2291    for path in &[
2292        "/nix/var/nix/profiles/default/bin/nix",
2293        "/run/current-system/sw/bin/nix",
2294    ] {
2295        if std::path::Path::new(path).exists() {
2296            return path.to_string();
2297        }
2298    }
2299    "nix".to_string() // fall back to PATH, let the error happen naturally
2300}
2301
2302/// Find the dev-image Nix flake directory.
2303fn find_dev_image_flake() -> Result<String> {
2304    // Check in the source tree
2305    let manifest_dir = env!("CARGO_MANIFEST_DIR");
2306    let workspace_root = std::path::Path::new(manifest_dir)
2307        .parent()
2308        .and_then(|p| p.parent())
2309        .ok_or_else(|| anyhow::anyhow!("Cannot find workspace root"))?;
2310
2311    let candidate = workspace_root.join("nix").join("dev-image");
2312    if candidate.join("flake.nix").exists() {
2313        return Ok(candidate.to_str().unwrap_or(".").to_string());
2314    }
2315
2316    // Fall back to the guest-lib minimal profile
2317    let guest_lib = workspace_root.join("nix").join("guest-lib");
2318    if guest_lib.join("flake.nix").exists() {
2319        return Ok(guest_lib.to_str().unwrap_or(".").to_string());
2320    }
2321
2322    anyhow::bail!(
2323        "Dev image flake not found. Expected at nix/dev-image/flake.nix\n\
2324         or nix/guest-lib/flake.nix"
2325    )
2326}
2327
2328/// Locate the bundled `nix/default-microvm/` flake.
2329///
2330/// This is the fallback used by image-taking commands (`mvmctl exec`,
2331/// `mvmctl up`/`run`/`start`) when neither `--flake` nor `--template` is
2332/// supplied.
2333fn find_default_microvm_flake() -> Result<String> {
2334    let manifest_dir = env!("CARGO_MANIFEST_DIR");
2335    let workspace_root = std::path::Path::new(manifest_dir)
2336        .parent()
2337        .and_then(|p| p.parent())
2338        .ok_or_else(|| anyhow::anyhow!("Cannot find workspace root"))?;
2339
2340    let candidate = workspace_root.join("nix").join("default-microvm");
2341    if candidate.join("flake.nix").exists() {
2342        return Ok(candidate.to_str().unwrap_or(".").to_string());
2343    }
2344    anyhow::bail!(
2345        "Default microVM image flake not found. Expected at nix/default-microvm/flake.nix"
2346    )
2347}
2348
2349/// Ensure the bundled default microVM image (kernel + rootfs) is in the cache.
2350///
2351/// Used by any image-taking command when no `--flake` or `--template` was
2352/// supplied. Builds via Nix on first use and caches under
2353/// `~/.cache/mvm/default-microvm/`. Returns `(kernel_path, rootfs_path)`.
2354///
2355/// On hosts without Nix (or where the local Nix build fails — e.g. macOS
2356/// without a Linux builder configured), falls back to downloading a
2357/// pre-built image from the matching GitHub release.
2358pub(crate) fn ensure_default_microvm_image() -> Result<(String, String)> {
2359    let cache_dir = format!("{}/default-microvm", mvm_core::config::mvm_cache_dir());
2360    std::fs::create_dir_all(&cache_dir)?;
2361
2362    let kernel_path = format!("{cache_dir}/vmlinux");
2363    let rootfs_path = format!("{cache_dir}/rootfs.ext4");
2364
2365    if std::path::Path::new(&kernel_path).exists() && std::path::Path::new(&rootfs_path).exists() {
2366        return Ok((kernel_path, rootfs_path));
2367    }
2368
2369    let plat = mvm_core::platform::current();
2370    if plat.has_host_nix()
2371        && let Ok(flake_dir) = find_default_microvm_flake()
2372    {
2373        let nix_bin = find_nix_binary();
2374
2375        if cfg!(target_os = "macos") && ensure_linux_builder_ssh_config() {
2376            ui::info("  Linux builder detected and SSH configured.");
2377        }
2378
2379        ui::info("Building default microVM image via Nix (first time only)...");
2380        let mut child = std::process::Command::new(&nix_bin)
2381            .args([
2382                "build",
2383                &format!(
2384                    "{flake_dir}#packages.{}.default",
2385                    mvm_build::dev_build::linux_system()
2386                ),
2387                "--no-link",
2388                "--print-out-paths",
2389            ])
2390            .stdout(std::process::Stdio::piped())
2391            .stderr(std::process::Stdio::inherit())
2392            .spawn()
2393            .context("Failed to run nix build")?;
2394
2395        let stdout = {
2396            let mut buf = String::new();
2397            if let Some(mut out) = child.stdout.take() {
2398                use std::io::Read;
2399                let _ = out.read_to_string(&mut buf);
2400            }
2401            buf
2402        };
2403        let status = child.wait().context("nix build process failed")?;
2404
2405        if status.success() {
2406            let store_path = stdout.trim().to_string();
2407            let ks = format!("{store_path}/vmlinux");
2408            let rs = format!("{store_path}/rootfs.ext4");
2409            if std::path::Path::new(&ks).exists() && std::path::Path::new(&rs).exists() {
2410                std::fs::copy(&ks, &kernel_path)?;
2411                std::fs::copy(&rs, &rootfs_path)?;
2412                ui::success("Default microVM image built and cached.");
2413                return Ok((kernel_path, rootfs_path));
2414            }
2415        }
2416
2417        ui::warn("Local Nix build failed; falling back to pre-built download.");
2418    }
2419
2420    download_default_microvm_image(&kernel_path, &rootfs_path)
2421}
2422
2423/// Download a pre-built default microVM image (kernel + rootfs) from the
2424/// matching GitHub release. Mirrors `download_dev_image`.
2425fn download_default_microvm_image(
2426    kernel_path: &str,
2427    rootfs_path: &str,
2428) -> Result<(String, String)> {
2429    let version = env!("CARGO_PKG_VERSION");
2430    let base_url = format!("https://github.com/auser/mvm/releases/download/v{version}");
2431    let arch = if cfg!(target_arch = "aarch64") {
2432        "aarch64"
2433    } else {
2434        "x86_64"
2435    };
2436    let kernel_url = format!("{base_url}/default-microvm-vmlinux-{arch}");
2437    let rootfs_url = format!("{base_url}/default-microvm-rootfs-{arch}.ext4");
2438
2439    ui::info(&format!(
2440        "Downloading default microVM image (v{version})..."
2441    ));
2442
2443    ui::info("  Fetching kernel...");
2444    download_file(&kernel_url, kernel_path)
2445        .with_context(|| format!("Failed to download kernel from {kernel_url}"))?;
2446
2447    ui::info("  Fetching rootfs...");
2448    download_file(&rootfs_url, rootfs_path)
2449        .with_context(|| format!("Failed to download rootfs from {rootfs_url}"))?;
2450
2451    ui::success("Default microVM image downloaded and cached.");
2452    Ok((kernel_path.to_string(), rootfs_path.to_string()))
2453}
2454
2455fn run_setup_steps(force: bool, lima_cpus: u32, lima_mem: u32) -> Result<()> {
2456    let total = 5;
2457
2458    // Step 1: Lima VM
2459    if bootstrap::is_lima_required() {
2460        let lima_status = lima::get_status()?;
2461        if !force && matches!(lima_status, lima::LimaStatus::Running) {
2462            ui::step(1, total, "Lima VM already running — skipping.");
2463        } else {
2464            let opts = config::LimaRenderOptions {
2465                cpus: Some(lima_cpus),
2466                memory_gib: Some(lima_mem),
2467                ..Default::default()
2468            };
2469            let lima_yaml = config::render_lima_yaml_with(&opts)?;
2470            ui::info(&format!(
2471                "Lima VM resources: {} vCPUs, {} GiB memory",
2472                lima_cpus, lima_mem,
2473            ));
2474            ui::step(1, total, "Setting up Lima VM...");
2475            lima::ensure_running(lima_yaml.path())?;
2476        }
2477    } else {
2478        ui::step(1, total, "Native Linux detected — skipping Lima VM setup.");
2479    }
2480
2481    // Step 2: Firecracker (+ jailer from same release tarball)
2482    if !force && firecracker::is_installed()? {
2483        ui::step(2, total, "Firecracker already installed — skipping.");
2484    } else {
2485        ui::step(2, total, "Installing Firecracker...");
2486        firecracker::install()?;
2487    }
2488
2489    // Step 3: Assets (kernel + squashfs)
2490    if !force && firecracker::has_base_assets()? {
2491        ui::step(
2492            3,
2493            total,
2494            "Kernel and rootfs already present \u{2014} skipping.",
2495        );
2496    } else {
2497        ui::step(3, total, "Downloading kernel and rootfs...");
2498        firecracker::download_assets()?;
2499    }
2500
2501    if firecracker::has_squashfs()? && !firecracker::validate_rootfs_squashfs()? {
2502        ui::warn("Downloaded rootfs is corrupted. Re-downloading...");
2503        shell::run_in_vm(&format!(
2504            "rm -f {dir}/ubuntu-*.squashfs.upstream",
2505            dir = config::MICROVM_DIR,
2506        ))?;
2507        firecracker::download_assets()?;
2508    }
2509
2510    // Step 4: Rootfs
2511    ui::step(4, total, "Preparing root filesystem...");
2512    firecracker::prepare_rootfs()?;
2513
2514    firecracker::write_state()?;
2515
2516    // Step 5: Security hardening
2517    ui::step(5, total, "Setting up security baseline...");
2518    setup_security_baseline()?;
2519
2520    Ok(())
2521}
2522
2523/// Deploy baseline security artifacts (seccomp profile, audit directory).
2524///
2525/// Idempotent — each step checks before acting.
2526fn setup_security_baseline() -> Result<()> {
2527    use mvm_runtime::security::{jailer, seccomp};
2528
2529    // Deploy strict seccomp filter profile
2530    seccomp::ensure_strict_profile()?;
2531    ui::info("  Seccomp strict profile deployed.");
2532
2533    // Create audit log directory structure
2534    shell::run_in_vm("sudo mkdir -p /var/lib/mvm/tenants")?;
2535    ui::info("  Audit log directory created.");
2536
2537    // Report jailer status (installed by firecracker::install() above)
2538    match jailer::jailer_available() {
2539        Ok(true) => ui::info("  Jailer binary available."),
2540        _ => ui::warn("  Jailer binary not found (may not be in this Firecracker release)."),
2541    }
2542
2543    Ok(())
2544}
2545
2546fn shell_escape(s: &str) -> String {
2547    if s.contains(' ') || s.contains('\'') || s.contains('"') {
2548        format!("'{}'", s.replace('\'', "'\\''"))
2549    } else {
2550        s.to_string()
2551    }
2552}
2553
2554fn cmd_shell(project: Option<&str>) -> Result<()> {
2555    lima::require_running()?;
2556
2557    // Print welcome banner with tool versions
2558    let fc_ver =
2559        shell::run_in_vm_stdout("firecracker --version 2>/dev/null | head -1").unwrap_or_default();
2560    let nix_ver = shell::run_in_vm_stdout("nix --version 2>/dev/null").unwrap_or_default();
2561
2562    ui::info("mvmctl development shell");
2563    ui::info(&format!(
2564        "  Firecracker: {}",
2565        if fc_ver.trim().is_empty() {
2566            "not installed"
2567        } else {
2568            fc_ver.trim()
2569        }
2570    ));
2571    ui::info(&format!(
2572        "  Nix:         {}",
2573        if nix_ver.trim().is_empty() {
2574            "not installed"
2575        } else {
2576            nix_ver.trim()
2577        }
2578    ));
2579    let mvm_in_vm = shell::run_in_vm_stdout("test -f /usr/local/bin/mvmctl && echo yes || echo no")
2580        .unwrap_or_default();
2581    if mvm_in_vm.trim() == "yes" {
2582        let mvm_ver = shell::run_in_vm_stdout("/usr/local/bin/mvmctl --version 2>/dev/null")
2583            .unwrap_or_default();
2584        ui::info(&format!(
2585            "  mvmctl:      {}",
2586            if mvm_ver.trim().is_empty() {
2587                "installed"
2588            } else {
2589                mvm_ver.trim()
2590            }
2591        ));
2592    } else {
2593        ui::warn("  mvmctl not installed in VM. Run 'mvmctl sync' to build and install it.");
2594    }
2595
2596    ui::info(&format!("  Lima VM:     {}\n", config::VM_NAME));
2597
2598    // Ensure shell completions and dev aliases are in the VM's ~/.zshrc
2599    // (the host's ~/.zshrc is separate from the VM's)
2600    if let Err(e) = shell_init::ensure_shell_init_in_vm() {
2601        ui::warn(&format!("Shell init in VM failed: {e}"));
2602    }
2603
2604    match project {
2605        Some(path) => {
2606            let cmd = format!("cd {} && exec bash -l", shell_escape(path));
2607            shell::replace_process("limactl", &["shell", config::VM_NAME, "bash", "-c", &cmd])
2608        }
2609        None => shell::replace_process("limactl", &["shell", config::VM_NAME, "bash", "-l"]),
2610    }
2611}
2612
2613fn cmd_cleanup(keep: Option<usize>, all: bool, verbose: bool) -> Result<()> {
2614    let keep_count = if all { 0 } else { keep.unwrap_or(5) };
2615
2616    if !all && keep_count == 0 {
2617        anyhow::bail!("--keep must be greater than 0 (or use --all)");
2618    }
2619
2620    // Show disk usage before cleanup.
2621    let disk_before = vm_disk_usage_pct();
2622    if let Some(pct) = disk_before {
2623        ui::info(&format!("Lima VM disk usage: {}%", pct));
2624    }
2625
2626    // Step 1: Clear temp files first — when the disk is 100% full the nix
2627    // daemon cannot start, so we need to free a little space before GC.
2628    ui::info("Clearing temporary files...");
2629    let _ = shell::run_in_vm("sudo rm -rf /tmp/* /var/tmp/* 2>/dev/null");
2630
2631    // Step 2: Remove old dev-build symlinks and artifacts.
2632    let env = mvm_runtime::build_env::RuntimeBuildEnv;
2633    let report = mvm_build::dev_build::cleanup_old_dev_builds(&env, keep_count)?;
2634
2635    if verbose {
2636        if report.removed_paths.is_empty() {
2637            ui::info("No cached build paths removed.");
2638        } else {
2639            ui::info("Removed cached build paths:");
2640            for path in &report.removed_paths {
2641                println!("  {}", path);
2642            }
2643        }
2644    }
2645
2646    if all {
2647        ui::success(&format!(
2648            "Removed {} cached build(s).",
2649            report.removed_count
2650        ));
2651    } else {
2652        ui::success(&format!(
2653            "Removed {} cached build(s), kept newest {}.",
2654            report.removed_count, keep_count
2655        ));
2656    }
2657
2658    // Step 3: Garbage-collect unreferenced Nix store paths inside the Lima VM.
2659    ui::info("Running nix-collect-garbage...");
2660    match shell::run_in_vm_stdout("nix-collect-garbage -d 2>&1 | tail -3") {
2661        Ok(output) => {
2662            let trimmed = output.trim();
2663            if !trimmed.is_empty() {
2664                println!("{trimmed}");
2665            }
2666        }
2667        Err(e) => {
2668            // If GC fails (disk too full for daemon), try clearing the Nix
2669            // user profile links and retrying once.
2670            ui::warn(&format!("nix-collect-garbage failed: {e}"));
2671            ui::info("Retrying after clearing Nix profile generations...");
2672            let _ = shell::run_in_vm("rm -rf ~/.local/state/nix/profiles/* 2>/dev/null");
2673            match shell::run_in_vm_stdout("nix-collect-garbage -d 2>&1 | tail -3") {
2674                Ok(output) => {
2675                    let trimmed = output.trim();
2676                    if !trimmed.is_empty() {
2677                        println!("{trimmed}");
2678                    }
2679                }
2680                Err(e2) => ui::warn(&format!("nix-collect-garbage retry failed: {e2}")),
2681            }
2682        }
2683    }
2684
2685    // Show disk usage after cleanup.
2686    let disk_after = vm_disk_usage_pct();
2687    if let Some(pct) = disk_after {
2688        let freed_msg = match disk_before {
2689            Some(before) if before > pct => format!(" (freed {}%)", before - pct),
2690            _ => String::new(),
2691        };
2692        ui::success(&format!("Lima VM disk usage: {}%{}", pct, freed_msg));
2693    }
2694
2695    Ok(())
2696}
2697
2698/// Read the Lima VM root filesystem usage percentage.
2699fn vm_disk_usage_pct() -> Option<u8> {
2700    let output = shell::run_in_vm_stdout("df --output=pcent / 2>/dev/null | tail -1").ok()?;
2701    output.trim().trim_end_matches('%').trim().parse().ok()
2702}
2703
2704fn cmd_logs(name: &str, follow: bool, lines: u32, hypervisor: bool) -> Result<()> {
2705    validate_vm_name(name).with_context(|| format!("Invalid VM name: {:?}", name))?;
2706    microvm::logs(name, follow, lines, hypervisor)
2707}
2708
2709fn cmd_diff(name: &str, json: bool) -> Result<()> {
2710    validate_vm_name(name).with_context(|| format!("Invalid VM name: {:?}", name))?;
2711
2712    let instance_dir = microvm::resolve_running_vm_dir(name)?;
2713    let changes = mvm_guest::vsock::query_fs_diff(&instance_dir)?;
2714
2715    if json {
2716        println!("{}", serde_json::to_string_pretty(&changes)?);
2717    } else if changes.is_empty() {
2718        ui::info("No filesystem changes detected.");
2719    } else {
2720        ui::info(&format!("{} change(s):", changes.len()));
2721        for change in &changes {
2722            let prefix = match change.kind {
2723                mvm_guest::vsock::FsChangeKind::Created => "+",
2724                mvm_guest::vsock::FsChangeKind::Modified => "~",
2725                mvm_guest::vsock::FsChangeKind::Deleted => "-",
2726            };
2727            if change.size > 0 {
2728                println!(
2729                    "  {} {} ({})",
2730                    prefix,
2731                    change.path,
2732                    human_bytes(change.size)
2733                );
2734            } else {
2735                println!("  {} {}", prefix, change.path);
2736            }
2737        }
2738    }
2739
2740    Ok(())
2741}
2742
2743fn human_bytes(bytes: u64) -> String {
2744    if bytes < 1024 {
2745        format!("{bytes}B")
2746    } else if bytes < 1024 * 1024 {
2747        format!("{:.1}K", bytes as f64 / 1024.0)
2748    } else if bytes < 1024 * 1024 * 1024 {
2749        format!("{:.1}M", bytes as f64 / (1024.0 * 1024.0))
2750    } else {
2751        format!("{:.1}G", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
2752    }
2753}
2754
2755/// Wait for the guest agent to respond to a Ping over vsock.
2756/// Returns true if the agent is reachable within `timeout_secs`.
2757fn wait_for_guest_agent(vm_id: &str, timeout_secs: u64) -> bool {
2758    use std::io::{Read, Write};
2759    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
2760    let ping = serde_json::to_vec(&mvm_guest::vsock::GuestRequest::Ping).unwrap_or_default();
2761    let len_bytes = (ping.len() as u32).to_be_bytes();
2762
2763    while std::time::Instant::now() < deadline {
2764        if let Ok(mut s) =
2765            mvm_apple_container::vsock_connect(vm_id, mvm_guest::vsock::GUEST_AGENT_PORT)
2766            && s.write_all(&len_bytes).is_ok()
2767            && s.write_all(&ping).is_ok()
2768            && s.flush().is_ok()
2769        {
2770            let mut resp_len = [0u8; 4];
2771            if s.read_exact(&mut resp_len).is_ok() {
2772                return true;
2773            }
2774        }
2775        std::thread::sleep(std::time::Duration::from_millis(500));
2776    }
2777    false
2778}
2779
2780/// Tell the guest agent to start a vsock→TCP forwarder for the given port.
2781fn request_port_forward(vm_id: &str, guest_port: u16) -> Result<u32> {
2782    let mut stream = mvm_apple_container::vsock_connect(vm_id, mvm_guest::vsock::GUEST_AGENT_PORT)
2783        .map_err(|e| anyhow::anyhow!("{e}"))?;
2784    mvm_guest::vsock::start_port_forward_on(&mut stream, guest_port)
2785}
2786
2787/// Forward a port from a running microVM to localhost.
2788///
2789/// On macOS this tunnels through Lima's SSH connection; on native Linux
2790/// it spawns a local socat proxy.
2791///
2792/// Each `port_spec` is either `GUEST_PORT` (binds to same local port) or
2793/// `LOCAL_PORT:GUEST_PORT`.  Multiple ports are forwarded concurrently —
2794/// background children handle all but the last, and Ctrl-C kills the group.
2795fn cmd_forward(name: &str, port_specs: &[String]) -> Result<()> {
2796    validate_vm_name(name).with_context(|| format!("Invalid VM name: {:?}", name))?;
2797    // Verify the VM is actually running.
2798    let _abs_dir = resolve_running_vm(name)?;
2799
2800    // Read the VM's guest IP from run-info.json.
2801    let info = microvm::read_vm_run_info(name)?;
2802
2803    // Use CLI port specs if provided, otherwise fall back to persisted ports.
2804    let parsed: Vec<(u16, u16)> = if port_specs.is_empty() {
2805        if info.ports.is_empty() {
2806            anyhow::bail!(
2807                "VM '{}' has no port mappings configured.\n\
2808                 Specify ports: mvmctl forward {} <PORT>...\n\
2809                 Or declare ports in mvm.toml.",
2810                name,
2811                name,
2812            );
2813        }
2814        ui::info("Using port mappings from VM config.");
2815        info.ports.iter().map(|p| (p.host, p.guest)).collect()
2816    } else {
2817        port_specs
2818            .iter()
2819            .map(|s| parse_port_spec(s))
2820            .collect::<Result<_>>()?
2821    };
2822    let guest_ip = info
2823        .guest_ip
2824        .as_deref()
2825        .filter(|s| !s.is_empty())
2826        .ok_or_else(|| {
2827            anyhow::anyhow!(
2828                "VM '{}' has no guest_ip in run-info. Was it started with 'mvmctl run'?",
2829                name,
2830            )
2831        })?;
2832
2833    for &(local_port, guest_port) in &parsed {
2834        ui::info(&format!(
2835            "Forwarding localhost:{} -> {}:{} (VM '{}')",
2836            local_port, guest_ip, guest_port, name,
2837        ));
2838    }
2839    ui::info("Press Ctrl-C to stop forwarding.");
2840
2841    if bootstrap::is_lima_required() {
2842        // macOS: SSH port-forward through Lima's SSH connection.
2843        // SSH -L supports multiple -L flags in a single session.
2844        lima::require_running()?;
2845        let home = std::env::var("HOME").unwrap_or_else(|_| "~".to_string());
2846        let ssh_config = format!("{}/.lima/{}/ssh.config", home, config::VM_NAME);
2847
2848        let mut cmd = std::process::Command::new("ssh");
2849        cmd.arg("-F").arg(&ssh_config).arg("-N"); // no remote command
2850        for &(local_port, guest_port) in &parsed {
2851            cmd.arg("-L")
2852                .arg(format!("{}:{}:{}", local_port, guest_ip, guest_port));
2853        }
2854        cmd.arg(format!("lima-{}", config::VM_NAME));
2855
2856        let status = cmd
2857            .status()
2858            .context("Failed to start SSH port forward. Is Lima running?")?;
2859
2860        if !status.success() {
2861            anyhow::bail!("SSH port forward exited with status {}", status);
2862        }
2863    } else {
2864        // Native Linux: socat proxy (microVM is directly reachable).
2865        // Spawn a child for each port; wait on all.
2866        let mut children: Vec<std::process::Child> = Vec::new();
2867        for &(local_port, guest_port) in &parsed {
2868            let child = std::process::Command::new("socat")
2869                .arg(format!("TCP-LISTEN:{},fork,reuseaddr", local_port))
2870                .arg(format!("TCP:{}:{}", guest_ip, guest_port))
2871                .spawn()
2872                .context("Failed to start socat. Install it with: sudo apt install socat")?;
2873            // Register PID so the signal handler can clean it up.
2874            if let Ok(mut pids) = CHILD_PIDS.lock() {
2875                pids.push(child.id());
2876            }
2877            children.push(child);
2878        }
2879        // Wait for all children to exit (Ctrl-C triggers the signal handler
2880        // which sends SIGTERM to each tracked child).
2881        for mut child in children {
2882            if let Err(e) = child.wait() {
2883                tracing::warn!("failed to wait on socat child: {e}");
2884            }
2885        }
2886        // Clear tracked PIDs after children exit.
2887        if let Ok(mut pids) = CHILD_PIDS.lock() {
2888            pids.clear();
2889        }
2890    }
2891
2892    Ok(())
2893}
2894
2895/// Parse a port spec like `3000` or `8080:3000` into `(local, guest)`.
2896fn parse_port_spec(spec: &str) -> Result<(u16, u16)> {
2897    if let Some((local, guest)) = spec.split_once(':') {
2898        let local: u16 = local
2899            .parse()
2900            .with_context(|| format!("invalid local port '{}'", local))?;
2901        let guest: u16 = guest
2902            .parse()
2903            .with_context(|| format!("invalid guest port '{}'", guest))?;
2904        Ok((local, guest))
2905    } else {
2906        let port: u16 = spec
2907            .parse()
2908            .with_context(|| format!("invalid port '{}'", spec))?;
2909        Ok((port, port))
2910    }
2911}
2912
2913/// Parse multiple port specs into `PortMapping` values.
2914fn parse_port_specs(specs: &[String]) -> Result<Vec<mvm_runtime::config::PortMapping>> {
2915    specs
2916        .iter()
2917        .map(|s| {
2918            let (host, guest) = parse_port_spec(s)?;
2919            Ok(mvm_runtime::config::PortMapping { host, guest })
2920        })
2921        .collect()
2922}
2923
2924/// Convert port mappings into a `DriveFile` for the config drive.
2925/// Writes `export MVM_PORT_MAP="3333:3000,3334:3002"`.
2926fn ports_to_drive_file(ports: &[mvm_runtime::config::PortMapping]) -> Option<microvm::DriveFile> {
2927    if ports.is_empty() {
2928        return None;
2929    }
2930    let map_str = ports
2931        .iter()
2932        .map(|p| format!("{}:{}", p.host, p.guest))
2933        .collect::<Vec<_>>()
2934        .join(",");
2935    Some(microvm::DriveFile {
2936        name: "mvm-ports.env".to_string(),
2937        content: format!("export MVM_PORT_MAP=\"{}\"\n", map_str),
2938        mode: 0o444,
2939    })
2940}
2941
2942/// Convert env var specs ("KEY=VALUE") into a `DriveFile` for the config drive.
2943fn env_vars_to_drive_file(env_vars: &[String]) -> Option<microvm::DriveFile> {
2944    if env_vars.is_empty() {
2945        return None;
2946    }
2947    let content = env_vars
2948        .iter()
2949        .map(|kv| format!("export {}", kv))
2950        .collect::<Vec<_>>()
2951        .join("\n");
2952    Some(microvm::DriveFile {
2953        name: "mvm-env.env".to_string(),
2954        content: format!("{}\n", content),
2955        mode: 0o444,
2956    })
2957}
2958
2959fn cmd_ls(_all: bool, json: bool) -> Result<()> {
2960    use mvm_core::vm_backend::VmInfo;
2961
2962    let mut all_vms: Vec<VmInfo> = Vec::new();
2963
2964    // Collect from Apple Container backend
2965    let ac_backend = AnyBackend::from_hypervisor("apple-container");
2966    if let Ok(vms) = ac_backend.list() {
2967        all_vms.extend(vms);
2968    }
2969
2970    // Collect from Docker backend
2971    let docker_backend = AnyBackend::from_hypervisor("docker");
2972    if let Ok(vms) = docker_backend.list() {
2973        all_vms.extend(vms);
2974    }
2975
2976    // Collect from Firecracker backend (if Lima is running)
2977    if bootstrap::is_lima_required() {
2978        if let Ok(lima::LimaStatus::Running) = lima::get_status() {
2979            let fc_backend = AnyBackend::from_hypervisor("firecracker");
2980            if let Ok(vms) = fc_backend.list() {
2981                all_vms.extend(vms);
2982            }
2983        }
2984    } else {
2985        // Native Linux — Firecracker runs directly
2986        let fc_backend = AnyBackend::from_hypervisor("firecracker");
2987        if let Ok(vms) = fc_backend.list() {
2988            all_vms.extend(vms);
2989        }
2990    }
2991
2992    if json {
2993        println!("{}", serde_json::to_string_pretty(&all_vms)?);
2994        return Ok(());
2995    }
2996
2997    if all_vms.is_empty() {
2998        println!("No running VMs.");
2999        return Ok(());
3000    }
3001
3002    // Docker-style table output
3003    println!(
3004        "{:<20} {:<18} {:<10} {:<8} {:<10} {:<20} IMAGE",
3005        "NAME", "BACKEND", "STATUS", "CPUS", "MEMORY", "PORTS"
3006    );
3007    for vm in &all_vms {
3008        let backend_name = if vm.flake_ref.as_deref().is_some() {
3009            // Determine backend from context
3010            if mvm_core::platform::current().has_apple_containers() {
3011                "apple-container"
3012            } else {
3013                "firecracker"
3014            }
3015        } else {
3016            "unknown"
3017        };
3018        let status = format!("{:?}", vm.status);
3019        let mem = if vm.memory_mib > 0 {
3020            format!("{}Mi", vm.memory_mib)
3021        } else {
3022            "-".to_string()
3023        };
3024        let image = vm
3025            .flake_ref
3026            .as_deref()
3027            .or(vm.profile.as_deref())
3028            .unwrap_or("-");
3029        let ports = if vm.ports.is_empty() {
3030            "-".to_string()
3031        } else {
3032            vm.ports
3033                .iter()
3034                .map(|p| format!("{}→{}", p.host, p.guest))
3035                .collect::<Vec<_>>()
3036                .join(", ")
3037        };
3038        println!(
3039            "{:<20} {:<18} {:<10} {:<8} {:<10} {:<20} {}",
3040            vm.name,
3041            backend_name,
3042            status,
3043            if vm.cpus > 0 {
3044                vm.cpus.to_string()
3045            } else {
3046                "-".to_string()
3047            },
3048            mem,
3049            ports,
3050            image,
3051        );
3052    }
3053
3054    Ok(())
3055}
3056
3057fn cmd_update(check: bool, force: bool, skip_verify: bool) -> Result<()> {
3058    let result = update::update(check, force, skip_verify);
3059    if result.is_ok() && !check {
3060        mvm_core::audit::emit(mvm_core::audit::LocalAuditKind::UpdateInstall, None, None);
3061    }
3062    result
3063}
3064
3065fn cmd_doctor(json: bool) -> Result<()> {
3066    crate::doctor::run(json)
3067}
3068
3069// ============================================================================
3070// Error hints
3071// ============================================================================
3072
3073/// Wrap a command result with actionable hints for common errors.
3074fn with_hints(result: Result<()>) -> Result<()> {
3075    if let Err(ref e) = result {
3076        let msg = format!("{:#}", e);
3077        if msg.contains("limactl: command not found") || msg.contains("limactl: not found") {
3078            ui::warn("Hint: Install Lima with 'brew install lima' or run 'mvmctl bootstrap'.");
3079        } else if msg.contains("firecracker: command not found")
3080            || msg.contains("firecracker: not found")
3081        {
3082            ui::warn("Hint: Run 'mvmctl setup' to install Firecracker.");
3083        } else if msg.contains("/dev/kvm") {
3084            ui::warn(
3085                "Hint: Enable KVM/virtualization in your BIOS or VM settings.\n      \
3086                 On macOS, KVM is available inside the Lima VM.",
3087            );
3088        } else if msg.contains("Permission denied") && msg.contains(".mvm") {
3089            ui::warn("Hint: Check directory permissions on ~/.mvm (set MVM_DATA_DIR to override).");
3090        } else if msg.contains("nix: command not found") || msg.contains("nix: not found") {
3091            ui::warn("Hint: Nix is installed inside the Lima VM. Run 'mvmctl shell' first.");
3092        } else if msg.contains("Lima VM is not running") || msg.contains("VM is not started") {
3093            ui::warn(
3094                "Hint: Start the dev environment with 'mvmctl dev' or run 'mvmctl setup' \
3095                 to initialise it first.",
3096            );
3097        } else if msg.contains("already exists") && msg.contains("template") {
3098            ui::warn("Hint: Use '--force' to overwrite the existing template.");
3099        } else if msg.contains("error: builder for") && msg.contains("failed with exit code") {
3100            ui::warn(
3101                "Hint: Nix build failed. Check the log above for the failing derivation.\n      \
3102                 Common fixes: ensure flake inputs are up to date ('nix flake update'), \
3103                 or check your flake.nix for syntax errors.",
3104            );
3105        } else if msg.contains("does not provide attribute")
3106            || msg.contains("flake has no")
3107            || msg.contains("does not provide a package")
3108        {
3109            ui::warn(
3110                "Hint: Flake attribute not found. Your flake.lock may be stale.\n      \
3111                 Try: nix flake update (inside the Lima VM or flake directory).",
3112            );
3113        } else if msg.contains("No space left on device") || msg.contains("ENOSPC") {
3114            ui::warn(
3115                "Hint: Disk full. Run 'mvmctl doctor' to check space, \
3116                 or run 'nix-collect-garbage -d' inside the Lima VM.",
3117            );
3118        } else if msg.contains("timed out") || msg.contains("connection refused") {
3119            ui::warn(
3120                "Hint: The Lima VM may be unresponsive. Try 'mvmctl status' or \
3121                 restart with 'mvmctl stop && mvmctl dev'.",
3122            );
3123        } else if msg.contains("hash mismatch") && msg.contains("got:") {
3124            ui::warn(
3125                "Hint: Fixed-output derivation hash changed. Run \
3126                 'mvmctl template build <name> --update-hash' to recompute.",
3127            );
3128        } else if msg.contains("does it exist?") && msg.contains("template") {
3129            ui::warn("Hint: List available templates with 'mvmctl template list'.");
3130        }
3131    }
3132    result
3133}
3134
3135fn cmd_build(path: &str, output: Option<&str>) -> Result<()> {
3136    let elf_path = image::build(path, output)?;
3137    ui::success(&format!("\nImage ready: {}", elf_path));
3138    ui::info(&format!("Run with: mvmctl start {}", elf_path));
3139    Ok(())
3140}
3141
3142fn cmd_build_flake(flake_ref: &str, profile: Option<&str>, watch: bool, json: bool) -> Result<()> {
3143    validate_flake_ref(flake_ref)
3144        .with_context(|| format!("Invalid flake reference: {:?}", flake_ref))?;
3145
3146    let build_env = mvm_runtime::build_env::default_build_env();
3147    let env = build_env.as_ref();
3148
3149    // Skip Lima when Nix is available on the host (macOS with linux-builder).
3150    let using_host_nix = mvm_core::platform::current().has_host_nix();
3151    if !using_host_nix && bootstrap::is_lima_required() {
3152        lima::require_running()?;
3153    }
3154
3155    let resolved = resolve_flake_ref(flake_ref)?;
3156    let watch_enabled = watch && !resolved.contains(':');
3157
3158    if watch && resolved.contains(':') && !json {
3159        ui::warn("Watch mode requires a local flake; running a single build instead.");
3160    }
3161
3162    loop {
3163        let profile_display = profile.unwrap_or("default");
3164
3165        if json {
3166            PhaseEvent::new("build", "nix-build", "started")
3167                .with_message(&format!("flake={} profile={}", resolved, profile_display))
3168                .emit();
3169        } else {
3170            ui::step(
3171                1,
3172                2,
3173                &format!("Building flake {} (profile={})", resolved, profile_display),
3174            );
3175        }
3176
3177        let result = match mvm_build::dev_build::dev_build(env, &resolved, profile) {
3178            Ok(r) => r,
3179            Err(e) => {
3180                if json {
3181                    PhaseEvent::new("build", "nix-build", "failed")
3182                        .with_error(&format!("{:#}", e))
3183                        .emit();
3184                }
3185                return Err(e);
3186            }
3187        };
3188        if let Err(e) = mvm_build::dev_build::ensure_guest_agent_if_needed(env, &result) {
3189            ui::warn(&format!(
3190                "Could not verify guest agent ({}). If built with mkGuest, the agent is already included.",
3191                e
3192            ));
3193        }
3194
3195        if json {
3196            #[derive(Serialize)]
3197            struct BuildResult {
3198                timestamp: String,
3199                command: &'static str,
3200                phase: &'static str,
3201                status: &'static str,
3202                revision: String,
3203                cached: bool,
3204                kernel: String,
3205                rootfs: String,
3206            }
3207            let event = BuildResult {
3208                timestamp: chrono::Utc::now().to_rfc3339(),
3209                command: "build",
3210                phase: "nix-build",
3211                status: "completed",
3212                revision: result.revision_hash.clone(),
3213                cached: result.cached,
3214                kernel: result.vmlinux_path.clone(),
3215                rootfs: result.rootfs_path.clone(),
3216            };
3217            if let Ok(j) = serde_json::to_string(&event) {
3218                println!("{}", j);
3219            }
3220        } else {
3221            ui::step(2, 2, "Build complete");
3222
3223            if result.cached {
3224                ui::success(&format!("\nCache hit — revision {}", result.revision_hash));
3225            } else {
3226                ui::success(&format!(
3227                    "\nBuild complete — revision {}",
3228                    result.revision_hash
3229                ));
3230            }
3231
3232            ui::info(&format!("  Kernel: {}", result.vmlinux_path));
3233            ui::info(&format!("  Rootfs: {}", result.rootfs_path));
3234            ui::info(&format!("\nRun with: mvmctl run --flake {}", flake_ref));
3235        }
3236
3237        if !watch_enabled {
3238            return Ok(());
3239        }
3240
3241        // Watch mode: wait for filesystem changes using native events
3242        if !json {
3243            ui::info("Watching for .nix and .lock changes (Ctrl+C to exit)...");
3244        }
3245        match crate::watch::wait_for_changes(&resolved) {
3246            Ok(trigger) => {
3247                if !json {
3248                    let display = crate::watch::display_trigger(&trigger, &resolved);
3249                    ui::info(&format!("\nChange detected: {display} — rebuilding..."));
3250                }
3251            }
3252            Err(e) => {
3253                if !json {
3254                    ui::warn(&format!("Watch error: {e} — falling back to single build"));
3255                }
3256                return Ok(());
3257            }
3258        }
3259    }
3260}
3261
3262/// Resolve CLI network flags into a `NetworkPolicy`.
3263/// `--network-preset` and `--network-allow` are mutually exclusive.
3264fn resolve_network_policy(
3265    preset: Option<&str>,
3266    allow: &[String],
3267) -> Result<mvm_core::network_policy::NetworkPolicy> {
3268    use mvm_core::network_policy::{HostPort, NetworkPolicy, NetworkPreset};
3269
3270    match (preset, allow.is_empty()) {
3271        (Some(_), false) => {
3272            anyhow::bail!("--network-preset and --network-allow are mutually exclusive")
3273        }
3274        (Some(name), true) => {
3275            let p: NetworkPreset = name.parse()?;
3276            Ok(NetworkPolicy::preset(p))
3277        }
3278        (None, false) => {
3279            let rules: Vec<HostPort> = allow
3280                .iter()
3281                .map(|s| s.parse())
3282                .collect::<Result<Vec<_>>>()?;
3283            Ok(NetworkPolicy::allow_list(rules))
3284        }
3285        (None, true) => Ok(NetworkPolicy::default()),
3286    }
3287}
3288
3289/// Resolve a flake reference: relative/absolute paths are canonicalized,
3290/// remote refs (containing `:`) pass through unchanged.
3291fn resolve_flake_ref(flake_ref: &str) -> Result<String> {
3292    if flake_ref.contains(':') {
3293        // Remote ref like "github:user/repo" — pass through
3294        return Ok(flake_ref.to_string());
3295    }
3296
3297    // Local path — canonicalize to absolute
3298    let path = std::path::Path::new(flake_ref);
3299    let canonical = path
3300        .canonicalize()
3301        .with_context(|| format!("Flake path '{}' does not exist", flake_ref))?;
3302
3303    Ok(canonical.to_string_lossy().to_string())
3304}
3305
3306struct RunParams<'a> {
3307    flake_ref: Option<&'a str>,
3308    template_name: Option<&'a str>,
3309    name: Option<&'a str>,
3310    profile: Option<&'a str>,
3311    cpus: Option<u32>,
3312    memory: Option<u32>,
3313    config_path: Option<&'a str>,
3314    volumes: &'a [String],
3315    hypervisor: &'a str,
3316    ports: &'a [String],
3317    env_vars: &'a [String],
3318    forward: bool,
3319    metrics_port: u16,
3320    watch_config: bool,
3321    watch: bool,
3322    detach: bool,
3323    network_policy: mvm_core::network_policy::NetworkPolicy,
3324    network_name: &'a str,
3325    seccomp_tier: mvm_security::seccomp::SeccompTier,
3326    secret_bindings: Vec<mvm_core::secret_binding::SecretBinding>,
3327}
3328
3329fn cmd_run(params: RunParams<'_>) -> Result<()> {
3330    let RunParams {
3331        flake_ref,
3332        template_name,
3333        name,
3334        profile,
3335        cpus,
3336        memory,
3337        config_path,
3338        volumes,
3339        hypervisor,
3340        ports,
3341        env_vars,
3342        forward,
3343        metrics_port,
3344        watch_config,
3345        watch,
3346        detach,
3347        network_policy,
3348        network_name,
3349        seccomp_tier,
3350        secret_bindings,
3351    } = params;
3352    let _span =
3353        tracing::info_span!("cmd_run", name = ?name, cpus = ?cpus, memory_mib = ?memory).entered();
3354    if let Some(n) = name {
3355        validate_vm_name(n).with_context(|| format!("Invalid VM name: {:?}", n))?;
3356    }
3357    if let Some(f) = flake_ref {
3358        validate_flake_ref(f).with_context(|| format!("Invalid flake reference: {:?}", f))?;
3359    }
3360    if let Some(t) = template_name {
3361        validate_template_name(t).with_context(|| format!("Invalid template name: {:?}", t))?;
3362    }
3363    // Auto-select backend when no explicit hypervisor is specified.
3364    // Priority: KVM (Firecracker direct) → Apple Container → Lima + Firecracker
3365    let effective_hypervisor = if hypervisor == "firecracker" {
3366        let plat = mvm_core::platform::current();
3367        if plat.has_kvm() {
3368            "firecracker" // native KVM — best option
3369        } else if plat.has_apple_containers() {
3370            "apple-container" // macOS 26+ — no Lima
3371        } else if plat.has_docker() {
3372            "docker" // universal fallback
3373        } else {
3374            "firecracker" // Lima fallback
3375        }
3376    } else {
3377        hypervisor
3378    };
3379
3380    // Apple Container doesn't need Lima — skip the upfront check entirely.
3381    // For Firecracker on macOS, Lima is required for both build and runtime.
3382    let needs_lima = effective_hypervisor != "apple-container"
3383        && effective_hypervisor != "docker"
3384        && bootstrap::is_lima_required();
3385    if needs_lima {
3386        lima::require_running()?;
3387    }
3388    let _metrics_server = if metrics_port > 0 {
3389        Some(crate::metrics_server::MetricsServer::start(metrics_port)?)
3390    } else {
3391        None
3392    };
3393
3394    // Start config watcher so the user is notified if the config file changes
3395    // while the build or boot is in progress.
3396    let _config_watcher = if watch_config {
3397        let config_path = {
3398            let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
3399            std::path::PathBuf::from(home)
3400                .join(".mvm")
3401                .join("config.toml")
3402        };
3403        if config_path.exists() {
3404            match crate::config_watcher::ConfigWatcher::start(&config_path) {
3405                Ok(w) => {
3406                    tracing::info!("Watching ~/.mvm/config.toml for changes");
3407                    Some(w)
3408                }
3409                Err(e) => {
3410                    tracing::warn!("Could not start config watcher: {e}");
3411                    None
3412                }
3413            }
3414        } else {
3415            None
3416        }
3417    } else {
3418        None
3419    };
3420
3421    // Generate a VM name if not provided.
3422    // After codesign re-exec (macOS), the env var preserves the originally
3423    // generated name so we don't produce a second random name.
3424    let vm_name = match name {
3425        Some(n) => n.to_string(),
3426        None => std::env::var("MVM_REEXEC_NAME").unwrap_or_else(|_| {
3427            let mut generator = names::Generator::default();
3428            generator.next().unwrap_or_else(|| "vm-0".to_string())
3429        }),
3430    };
3431
3432    // Register the VM name in the persistent registry (best-effort).
3433    let registry_path = mvm_runtime::vm::name_registry::registry_path();
3434    if let Ok(mut registry) = mvm_runtime::vm::name_registry::VmNameRegistry::load(&registry_path) {
3435        // Deregister stale entry with the same name if it exists
3436        registry.deregister(&vm_name);
3437        let _ = registry.register(&vm_name, "", network_name, None, 0);
3438        let _ = registry.save(&registry_path);
3439    }
3440
3441    // Direct boot mode: launchd agent passes kernel/rootfs via env vars.
3442    // Skip the build/template loading entirely.
3443    if std::env::var("MVM_DIRECT_BOOT").as_deref() == Ok("1") {
3444        let kernel = std::env::var("MVM_KERNEL_PATH")
3445            .map_err(|_| anyhow::anyhow!("MVM_KERNEL_PATH not set"))?;
3446        let rootfs = std::env::var("MVM_ROOTFS_PATH")
3447            .map_err(|_| anyhow::anyhow!("MVM_ROOTFS_PATH not set"))?;
3448
3449        let start_config = mvm_core::vm_backend::VmStartConfig {
3450            name: vm_name.clone(),
3451            rootfs_path: rootfs,
3452            kernel_path: Some(kernel),
3453            cpus: cpus.unwrap_or(2),
3454            memory_mib: memory.unwrap_or(512),
3455            ..Default::default()
3456        };
3457
3458        let backend = AnyBackend::from_hypervisor(effective_hypervisor);
3459        backend.start(&start_config)?;
3460
3461        // Set up port forwarding from MVM_PORTS env var (via vsock)
3462        if let Ok(ports_str) = std::env::var("MVM_PORTS")
3463            && !ports_str.is_empty()
3464        {
3465            ui::info("Waiting for guest agent...");
3466            if wait_for_guest_agent(&vm_name, 30) {
3467                for spec in ports_str.split(',') {
3468                    if let Some((host, guest)) = spec.split_once(':')
3469                        && let (Ok(h), Ok(g)) = (host.parse::<u16>(), guest.parse::<u16>())
3470                    {
3471                        let _ = request_port_forward(&vm_name, g);
3472                        mvm_apple_container::start_port_proxy(&vm_name, h, g);
3473                        ui::info(&format!("Forwarding localhost:{h} → guest tcp/{g} (vsock)"));
3474                    }
3475                }
3476            } else {
3477                ui::warn("Guest agent not reachable — port forwarding unavailable.");
3478            }
3479        }
3480
3481        ui::info(&format!("VM '{}' running. Press Ctrl+C to stop.", vm_name));
3482
3483        // Block until signaled
3484        let pair = std::sync::Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new()));
3485        let pair2 = pair.clone();
3486        let _ = ctrlc::set_handler(move || {
3487            let (lock, cvar) = &*pair2;
3488            *lock.lock().unwrap_or_else(|e| e.into_inner()) = true;
3489            cvar.notify_all();
3490        });
3491        let (lock, cvar) = &*pair;
3492        let mut stopped = lock.lock().unwrap_or_else(|e| e.into_inner());
3493        while !*stopped {
3494            stopped = cvar
3495                .wait_timeout(stopped, std::time::Duration::from_secs(1))
3496                .unwrap_or_else(|e| e.into_inner())
3497                .0;
3498        }
3499        let _ = backend.stop(&mvm_core::vm_backend::VmId(vm_name));
3500        return Ok(());
3501    }
3502
3503    // Resolve artifact paths from either a pre-built template or a flake build.
3504    let (
3505        vmlinux_path,
3506        initrd_path,
3507        rootfs_path,
3508        revision_hash,
3509        source_flake,
3510        source_profile,
3511        tmpl_cpus,
3512        tmpl_mem,
3513        snapshot_info,
3514    ) = if let Some(tmpl) = template_name {
3515        ui::step(
3516            1,
3517            2,
3518            &format!("Loading template '{}' for VM '{}'", tmpl, vm_name),
3519        );
3520        let (spec, vmlinux, initrd, rootfs, rev) =
3521            mvm_runtime::vm::template::lifecycle::template_artifacts(tmpl)?;
3522        ui::info(&format!("Using revision {}", rev));
3523
3524        // Check for pre-built snapshot
3525        let snap_info = mvm_runtime::vm::template::lifecycle::template_snapshot_info(tmpl)?;
3526        if snap_info.is_some() {
3527            ui::info("Snapshot available — will restore instantly");
3528        }
3529
3530        (
3531            vmlinux,
3532            initrd,
3533            rootfs,
3534            rev,
3535            spec.flake_ref.clone(),
3536            Some(spec.profile.clone()),
3537            Some(spec.vcpus as u32),
3538            Some(spec.mem_mib),
3539            snap_info,
3540        )
3541    } else if let Some(flake) = flake_ref {
3542        let resolved = resolve_flake_ref(flake)?;
3543        let profile_display = profile.unwrap_or("default");
3544        ui::step(
3545            1,
3546            2,
3547            &format!(
3548                "Building flake {} (profile={}, name={})",
3549                resolved, profile_display, vm_name
3550            ),
3551        );
3552        let run_build_env = mvm_runtime::build_env::default_build_env();
3553        let env = run_build_env.as_ref();
3554        let result = mvm_build::dev_build::dev_build(env, &resolved, profile)?;
3555        if let Err(e) = mvm_build::dev_build::ensure_guest_agent_if_needed(env, &result) {
3556            ui::warn(&format!(
3557                "Could not verify guest agent ({}). If built with mkGuest, the agent is already included.",
3558                e
3559            ));
3560        }
3561        if result.cached {
3562            ui::info(&format!("Cache hit — revision {}", result.revision_hash));
3563        } else {
3564            ui::info(&format!(
3565                "Build complete — revision {}",
3566                result.revision_hash
3567            ));
3568        }
3569        (
3570            result.vmlinux_path,
3571            result.initrd_path,
3572            result.rootfs_path,
3573            result.revision_hash,
3574            flake.to_string(),
3575            profile.map(|s| s.to_string()),
3576            None,
3577            None,
3578            None, // No snapshot for flake builds
3579        )
3580    } else {
3581        ui::step(
3582            1,
3583            2,
3584            &format!(
3585                "No --flake or --template; using bundled default microVM image for '{}'",
3586                vm_name
3587            ),
3588        );
3589        let (kernel, rootfs) = ensure_default_microvm_image()?;
3590        (
3591            kernel,
3592            None,
3593            rootfs,
3594            String::new(),
3595            "default-microvm".to_string(),
3596            None,
3597            None,
3598            None,
3599            None,
3600        )
3601    };
3602
3603    let backend_label = match effective_hypervisor {
3604        "apple-container" => "Apple Container",
3605        "qemu" => "QEMU (microvm.nix)",
3606        _ => "Firecracker VM",
3607    };
3608    ui::step(2, 2, &format!("Booting {} '{}'", backend_label, vm_name));
3609
3610    let rt_config = match config_path {
3611        Some(p) => image::parse_runtime_config(p)?,
3612        None => image::RuntimeConfig::default(),
3613    };
3614
3615    // Partition --volume specs into dir-inject (config/secrets) and persistent volumes
3616    let mut volume_cfg: Vec<image::RuntimeVolume> = Vec::new();
3617    let mut config_files: Vec<microvm::DriveFile> = Vec::new();
3618    let mut secret_files: Vec<microvm::DriveFile> = Vec::new();
3619
3620    if !volumes.is_empty() {
3621        for v in volumes {
3622            match parse_volume_spec(v)? {
3623                VolumeSpec::DirInject {
3624                    host_dir,
3625                    guest_mount,
3626                } => match guest_mount.as_str() {
3627                    "/mnt/config" => {
3628                        config_files.extend(
3629                            read_dir_to_drive_files(&host_dir, 0o444)
3630                                .with_context(|| format!("reading volume '{}'", v))?,
3631                        );
3632                    }
3633                    "/mnt/secrets" => {
3634                        secret_files.extend(
3635                            read_dir_to_drive_files(&host_dir, 0o400)
3636                                .with_context(|| format!("reading volume '{}'", v))?,
3637                        );
3638                    }
3639                    other => anyhow::bail!(
3640                        "Unsupported guest mount '{}'. Supported: /mnt/config, /mnt/secrets",
3641                        other
3642                    ),
3643                },
3644                VolumeSpec::Persistent(vol) => volume_cfg.push(vol),
3645            }
3646        }
3647    } else {
3648        volume_cfg = rt_config.volumes.clone();
3649    };
3650
3651    let user_cfg = mvm_core::user_config::load(None);
3652    let final_cpus = cpus
3653        .or(rt_config.cpus)
3654        .or(tmpl_cpus)
3655        .unwrap_or(user_cfg.default_cpus);
3656    let final_memory = memory
3657        .or(rt_config.memory)
3658        .or(tmpl_mem)
3659        .unwrap_or(user_cfg.default_memory_mib);
3660
3661    // Parse port mappings and inject as config drive file
3662    let port_mappings = parse_port_specs(ports)?;
3663    if let Some(f) = ports_to_drive_file(&port_mappings) {
3664        config_files.push(f);
3665    }
3666
3667    // Inject env vars as config drive file
3668    if let Some(f) = env_vars_to_drive_file(env_vars) {
3669        config_files.push(f);
3670    }
3671
3672    // Inject seccomp manifest into config drive if not unrestricted
3673    if let Some(manifest) = seccomp_tier.to_manifest() {
3674        let json = serde_json::to_string_pretty(&manifest)
3675            .context("failed to serialize seccomp manifest")?;
3676        config_files.push(microvm::DriveFile {
3677            name: "seccomp.json".to_string(),
3678            content: json,
3679            mode: 0o644,
3680        });
3681    }
3682
3683    // Resolve and inject secret bindings
3684    if !secret_bindings.is_empty() {
3685        let resolved = mvm_core::secret_binding::ResolvedSecrets::resolve(&secret_bindings)
3686            .context("failed to resolve secret bindings")?;
3687
3688        // Write actual secret values to the secrets drive
3689        for (filename, content) in resolved.to_secret_files() {
3690            secret_files.push(microvm::DriveFile {
3691                name: filename,
3692                content,
3693                mode: 0o600,
3694            });
3695        }
3696
3697        // Write secret manifest to config drive (no secret values, just metadata)
3698        config_files.push(microvm::DriveFile {
3699            name: "secrets-manifest.json".to_string(),
3700            content: resolved.manifest_json(),
3701            mode: 0o644,
3702        });
3703
3704        // Write placeholder env vars so tools pass existence checks
3705        let placeholders: Vec<String> = resolved
3706            .placeholder_env_vars()
3707            .iter()
3708            .map(|(k, v)| format!("{}={}", k, v))
3709            .collect();
3710        if let Some(f) = env_vars_to_drive_file(&placeholders) {
3711            config_files.push(microvm::DriveFile {
3712                name: "secret-env.env".to_string(),
3713                content: f.content,
3714                mode: f.mode,
3715            });
3716        }
3717
3718        // Log which secrets are bound (without revealing values)
3719        for b in &secret_bindings {
3720            ui::info(&format!(
3721                "Secret {} bound to {} (header: {})",
3722                b.env_var, b.target_host, b.header
3723            ));
3724        }
3725    }
3726
3727    let vm_name_owned = vm_name.clone();
3728    let has_ports = !port_mappings.is_empty();
3729
3730    // Stash the generated VM name so that if the Apple Container backend
3731    // re-execs after codesigning, the new process reuses the same name.
3732    // SAFETY: called early in single-threaded CLI startup before spawning
3733    // worker threads; no other threads are reading env vars concurrently.
3734    unsafe { std::env::set_var("MVM_REEXEC_NAME", &vm_name) };
3735
3736    // If a template snapshot exists AND the backend supports snapshots,
3737    // restore from it instead of cold-booting.
3738    let backend = AnyBackend::from_hypervisor(effective_hypervisor);
3739    if let Some(ref snap_info) = snapshot_info
3740        && let Some(tmpl) = template_name
3741        && backend.capabilities().snapshots
3742    {
3743        let slot = microvm::allocate_slot(&vm_name)?;
3744        let run_config = microvm::FlakeRunConfig {
3745            name: vm_name,
3746            slot,
3747            vmlinux_path,
3748            initrd_path,
3749            rootfs_path,
3750            revision_hash,
3751            flake_ref: source_flake,
3752            profile: source_profile,
3753            cpus: final_cpus,
3754            memory: final_memory,
3755            volumes: volume_cfg,
3756            config_files,
3757            secret_files,
3758            ports: port_mappings,
3759            network_policy: network_policy.clone(),
3760        };
3761        let rev = mvm_runtime::vm::template::lifecycle::current_revision_id(tmpl)?;
3762        let snap_dir = mvm_core::template::template_snapshot_dir(tmpl, &rev);
3763        ui::step(
3764            2,
3765            2,
3766            &format!("Restoring VM '{}' from snapshot", vm_name_owned),
3767        );
3768        microvm::restore_from_template_snapshot(tmpl, &run_config, &snap_dir, snap_info)?;
3769    } else {
3770        let start_config = VmStartParams {
3771            name: vm_name,
3772            rootfs_path,
3773            vmlinux_path,
3774            initrd_path,
3775            revision_hash,
3776            flake_ref: source_flake,
3777            profile: source_profile,
3778            cpus: final_cpus,
3779            memory_mib: final_memory,
3780            volumes: &volume_cfg,
3781            config_files: &config_files,
3782            secret_files: &secret_files,
3783            port_mappings: &port_mappings,
3784        }
3785        .into_start_config();
3786
3787        // Apple Container with -d: install a launchd agent instead of
3788        // starting the VM in this process. The agent runs as a proper
3789        // macOS service with its own RunLoop.
3790        if detach && effective_hypervisor == "apple-container" {
3791            // Sign the binary before installing the launchd agent so the
3792            // daemon process launches with the entitlement already in place.
3793            mvm_apple_container::ensure_signed();
3794
3795            // Build is already done — install launchd agent with the
3796            // resolved kernel/rootfs paths (no rebuild in the daemon).
3797            // Serialize port mappings for the daemon
3798            let port_specs: Vec<String> = parse_port_specs(ports)
3799                .unwrap_or_default()
3800                .iter()
3801                .map(|p| format!("{}:{}", p.host, p.guest))
3802                .collect();
3803
3804            mvm_apple_container::install_launchd_direct(
3805                &start_config.name,
3806                start_config.kernel_path.as_deref().unwrap_or(""),
3807                &start_config.rootfs_path,
3808                start_config.cpus,
3809                start_config.memory_mib as u64,
3810                &port_specs,
3811            )
3812            .map_err(|e| anyhow::anyhow!("{e}"))?;
3813            println!("{vm_name_owned}");
3814            return Ok(());
3815        }
3816
3817        backend.start(&start_config)?;
3818    }
3819
3820    mvm_core::audit::emit(
3821        mvm_core::audit::LocalAuditKind::VmStart,
3822        Some(&vm_name_owned),
3823        None,
3824    );
3825
3826    // Apple Virtualization VMs live in-process — the process must stay alive.
3827    if effective_hypervisor == "apple-container" && !detach {
3828        // Set up port forwarding via vsock (no guest IP needed).
3829        // 1. Wait for guest agent to be ready on vsock port 52
3830        // 2. Tell the agent to start vsock→TCP forwarders for each port
3831        // 3. Start host-side TCP→vsock proxies
3832        if has_ports {
3833            let pm_list = parse_port_specs(ports).unwrap_or_default();
3834
3835            ui::info("Waiting for guest agent...");
3836            let agent_ready = wait_for_guest_agent(&vm_name_owned, 30);
3837            if !agent_ready {
3838                ui::warn("Guest agent not reachable — port forwarding unavailable.");
3839            } else {
3840                // Tell guest agent to start vsock forwarders
3841                for pm in &pm_list {
3842                    match request_port_forward(&vm_name_owned, pm.guest) {
3843                        Ok(vsock_port) => {
3844                            ui::info(&format!(
3845                                "Guest forwarding vsock:{vsock_port} → tcp/{}",
3846                                pm.guest
3847                            ));
3848                        }
3849                        Err(e) => {
3850                            ui::warn(&format!(
3851                                "Failed to set up guest forwarder for port {}: {e}",
3852                                pm.guest
3853                            ));
3854                        }
3855                    }
3856                }
3857
3858                // Start host-side proxies
3859                for pm in &pm_list {
3860                    mvm_apple_container::start_port_proxy(&vm_name_owned, pm.host, pm.guest);
3861                    ui::info(&format!(
3862                        "Forwarding localhost:{} → guest tcp/{} (vsock)",
3863                        pm.host, pm.guest
3864                    ));
3865                }
3866
3867                // Persist port mappings so `ps` can display them
3868                let ports_str: Vec<String> = pm_list
3869                    .iter()
3870                    .map(|p| format!("{}:{}", p.host, p.guest))
3871                    .collect();
3872                let ports_file = format!(
3873                    "{}/.mvm/vms/{}/ports",
3874                    std::env::var("HOME").unwrap_or_default(),
3875                    vm_name_owned
3876                );
3877                let _ = std::fs::write(&ports_file, ports_str.join(","));
3878            }
3879        }
3880
3881        ui::info(&format!(
3882            "VM '{}' running. Press Ctrl+C to stop.",
3883            vm_name_owned
3884        ));
3885
3886        // Block until signaled (Ctrl+C or SIGTERM)
3887        let pair = std::sync::Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new()));
3888        let pair2 = pair.clone();
3889        let _ = ctrlc::set_handler(move || {
3890            let (lock, cvar) = &*pair2;
3891            *lock.lock().unwrap_or_else(|e| e.into_inner()) = true;
3892            cvar.notify_all();
3893        });
3894
3895        let (lock, cvar) = &*pair;
3896        let mut stopped = lock.lock().unwrap_or_else(|e| e.into_inner());
3897        while !*stopped {
3898            stopped = cvar
3899                .wait_timeout(stopped, std::time::Duration::from_secs(1))
3900                .unwrap_or_else(|e| e.into_inner())
3901                .0;
3902        }
3903
3904        ui::info(&format!("Stopping VM '{}'...", vm_name_owned));
3905        let _ = backend.stop(&mvm_core::vm_backend::VmId(vm_name_owned.clone()));
3906        return Ok(());
3907    }
3908
3909    if forward {
3910        if has_ports {
3911            cmd_forward(&vm_name_owned, &[])?;
3912        } else {
3913            ui::warn("--forward was set but no ports were declared. Use -p to specify ports.");
3914        }
3915    }
3916
3917    // Watch mode: on each .nix / flake.lock change, stop the VM, rebuild, reboot.
3918    if watch {
3919        let Some(flake) = flake_ref else {
3920            // Template mode — watch not supported.
3921            return Ok(());
3922        };
3923        if flake.contains(':') {
3924            ui::warn("--watch requires a local flake; running a single boot instead.");
3925            return Ok(());
3926        }
3927        let flake_dir = resolve_flake_ref(flake)?;
3928        loop {
3929            ui::info("Watching for .nix and .lock changes (Ctrl+C to exit)...");
3930            match crate::watch::wait_for_changes(&flake_dir) {
3931                Ok(trigger) => {
3932                    let display = crate::watch::display_trigger(&trigger, &flake_dir);
3933                    ui::info(&format!("\nChange detected: {display} — rebuilding..."));
3934                }
3935                Err(e) => {
3936                    tracing::warn!("Watch error: {e}");
3937                    break;
3938                }
3939            }
3940
3941            // Stop the running VM.
3942            let backend = AnyBackend::default_backend();
3943            if let Err(e) = backend.stop(&VmId::from(vm_name_owned.as_str())) {
3944                tracing::warn!("Could not stop '{}': {e}", vm_name_owned);
3945            }
3946
3947            // Rebuild the flake.
3948            let env = mvm_runtime::build_env::RuntimeBuildEnv;
3949            let result = match mvm_build::dev_build::dev_build(&env, &flake_dir, profile) {
3950                Ok(r) => r,
3951                Err(e) => {
3952                    ui::warn(&format!("Rebuild failed: {e}; waiting for next change..."));
3953                    continue;
3954                }
3955            };
3956            if let Err(e) = mvm_build::dev_build::ensure_guest_agent_if_needed(&env, &result) {
3957                tracing::warn!("Guest agent check failed: {e}");
3958            }
3959            ui::success(&format!(
3960                "Build complete — revision {}",
3961                result.revision_hash
3962            ));
3963
3964            // Re-parse volumes, ports and env vars for the fresh boot.
3965            let rt_cfg_watch = match config_path {
3966                Some(p) => image::parse_runtime_config(p).unwrap_or_default(),
3967                None => image::RuntimeConfig::default(),
3968            };
3969            let mut w_volume_cfg: Vec<image::RuntimeVolume> = Vec::new();
3970            let mut w_config_files: Vec<microvm::DriveFile> = Vec::new();
3971            let mut w_secret_files: Vec<microvm::DriveFile> = Vec::new();
3972            if !volumes.is_empty() {
3973                for v in volumes {
3974                    match parse_volume_spec(v) {
3975                        Ok(VolumeSpec::DirInject {
3976                            host_dir,
3977                            guest_mount,
3978                        }) => match guest_mount.as_str() {
3979                            "/mnt/config" => {
3980                                if let Ok(files) = read_dir_to_drive_files(&host_dir, 0o444) {
3981                                    w_config_files.extend(files);
3982                                }
3983                            }
3984                            "/mnt/secrets" => {
3985                                if let Ok(files) = read_dir_to_drive_files(&host_dir, 0o400) {
3986                                    w_secret_files.extend(files);
3987                                }
3988                            }
3989                            _ => {}
3990                        },
3991                        Ok(VolumeSpec::Persistent(vol)) => w_volume_cfg.push(vol),
3992                        Err(_) => {}
3993                    }
3994                }
3995            } else {
3996                w_volume_cfg = rt_cfg_watch.volumes.clone();
3997            }
3998            let w_port_mappings = parse_port_specs(ports).unwrap_or_default();
3999            if let Some(f) = ports_to_drive_file(&w_port_mappings) {
4000                w_config_files.push(f);
4001            }
4002            if let Some(f) = env_vars_to_drive_file(env_vars) {
4003                w_config_files.push(f);
4004            }
4005            let w_start_config = VmStartParams {
4006                name: vm_name_owned.clone(),
4007                rootfs_path: result.rootfs_path,
4008                vmlinux_path: result.vmlinux_path,
4009                initrd_path: result.initrd_path,
4010                revision_hash: result.revision_hash,
4011                flake_ref: flake.to_string(),
4012                profile: profile.map(|s| s.to_string()),
4013                cpus: final_cpus,
4014                memory_mib: final_memory,
4015                volumes: &w_volume_cfg,
4016                config_files: &w_config_files,
4017                secret_files: &w_secret_files,
4018                port_mappings: &w_port_mappings,
4019            }
4020            .into_start_config();
4021            let w_backend = AnyBackend::from_hypervisor(effective_hypervisor);
4022            if let Err(e) = w_backend.start(&w_start_config) {
4023                ui::warn(&format!(
4024                    "Could not start VM: {e}; waiting for next change..."
4025                ));
4026            } else {
4027                mvm_core::audit::emit(
4028                    mvm_core::audit::LocalAuditKind::VmStart,
4029                    Some(&vm_name_owned),
4030                    None,
4031                );
4032                ui::success(&format!("VM '{}' rebooted.", vm_name_owned));
4033            }
4034        }
4035    }
4036
4037    Ok(())
4038}
4039
4040/// Read all regular files from a directory into `DriveFile` entries.
4041fn read_dir_to_drive_files(dir: &str, default_mode: u32) -> Result<Vec<microvm::DriveFile>> {
4042    let mut files = Vec::new();
4043    for entry in std::fs::read_dir(dir)? {
4044        let entry = entry?;
4045        if entry.file_type()?.is_file() {
4046            files.push(microvm::DriveFile {
4047                name: entry.file_name().to_string_lossy().to_string(),
4048                content: std::fs::read_to_string(entry.path())?,
4049                mode: default_mode,
4050            });
4051        }
4052    }
4053    Ok(files)
4054}
4055
4056/// Parsed volume specification from the `--volume/-v` CLI flag.
4057enum VolumeSpec {
4058    /// Inject host directory contents onto a drive (2-part: `host_dir:/guest/path`).
4059    DirInject {
4060        host_dir: String,
4061        guest_mount: String,
4062    },
4063    /// Persistent ext4 volume with explicit size (3-part: `host:/guest/path:size`).
4064    Persistent(image::RuntimeVolume),
4065}
4066
4067fn parse_volume_spec(spec: &str) -> Result<VolumeSpec> {
4068    let parts: Vec<&str> = spec.splitn(3, ':').collect();
4069    match parts.len() {
4070        2 => Ok(VolumeSpec::DirInject {
4071            host_dir: parts[0].to_string(),
4072            guest_mount: parts[1].to_string(),
4073        }),
4074        3 => Ok(VolumeSpec::Persistent(image::RuntimeVolume {
4075            host: parts[0].to_string(),
4076            guest: parts[1].to_string(),
4077            size: parts[2].to_string(),
4078            read_only: false,
4079        })),
4080        _ => anyhow::bail!(
4081            "Invalid volume '{}'. Expected host_dir:/guest/path or host:/guest/path:size",
4082            spec
4083        ),
4084    }
4085}
4086
4087/// Load fleet config from an explicit path or auto-discover mvm.toml.
4088fn load_fleet_config(
4089    config_path: Option<&str>,
4090) -> Result<Option<(fleet::FleetConfig, std::path::PathBuf)>> {
4091    match config_path {
4092        Some(path) => {
4093            let content = std::fs::read_to_string(path)
4094                .with_context(|| format!("Failed to read {}", path))?;
4095            let config: fleet::FleetConfig =
4096                toml::from_str(&content).with_context(|| format!("Failed to parse {}", path))?;
4097            let dir = std::path::Path::new(path)
4098                .parent()
4099                .unwrap_or(std::path::Path::new("."))
4100                .to_path_buf();
4101            Ok(Some((config, dir)))
4102        }
4103        None => fleet::find_fleet_config(),
4104    }
4105}
4106
4107fn cmd_down(name: Option<&str>, config_path: Option<&str>) -> Result<()> {
4108    // Use Apple Container backend on macOS 26+, otherwise default (Firecracker).
4109    let backend = if mvm_core::platform::current().has_apple_containers() {
4110        AnyBackend::from_hypervisor("apple-container")
4111    } else {
4112        AnyBackend::default_backend()
4113    };
4114    match name {
4115        Some(n) => {
4116            let result = backend.stop(&VmId::from(n));
4117            // Deregister from the name registry (best-effort)
4118            let registry_path = mvm_runtime::vm::name_registry::registry_path();
4119            if let Ok(mut registry) =
4120                mvm_runtime::vm::name_registry::VmNameRegistry::load(&registry_path)
4121            {
4122                registry.deregister(n);
4123                let _ = registry.save(&registry_path);
4124            }
4125            result
4126        }
4127        None => {
4128            let found = load_fleet_config(config_path)?;
4129            if let Some((fleet_config, _base_dir)) = found {
4130                let mut stopped = 0;
4131                for vm_name in fleet_config.vms.keys() {
4132                    if backend.stop(&VmId::from(vm_name.as_str())).is_ok() {
4133                        stopped += 1;
4134                    }
4135                }
4136
4137                // Clean up bridge if no VMs remain
4138                let remaining = backend.list().unwrap_or_default();
4139                if remaining.is_empty() {
4140                    let _ = mvm_runtime::vm::network::bridge_teardown();
4141                }
4142
4143                ui::success(&format!("Stopped {} VMs", stopped));
4144                Ok(())
4145            } else {
4146                backend.stop_all()
4147            }
4148        }
4149    }
4150}
4151
4152fn cmd_metrics(json: bool) -> Result<()> {
4153    let metrics = mvm_core::observability::metrics::global();
4154    if json {
4155        let snap = metrics.snapshot();
4156        println!("{}", serde_json::to_string_pretty(&snap)?);
4157    } else {
4158        print!("{}", metrics.prometheus_exposition());
4159    }
4160    Ok(())
4161}
4162
4163fn cmd_completions(shell: clap_complete::Shell) -> Result<()> {
4164    let mut cmd = Cli::command();
4165    clap_complete::generate(shell, &mut cmd, "mvmctl", &mut std::io::stdout());
4166    Ok(())
4167}
4168
4169fn cmd_uninstall(yes: bool, all: bool, dry_run: bool) -> Result<()> {
4170    // Build the action plan. Dry-run avoids any external process calls so it
4171    // stays fast even when Lima/limactl is unresponsive.
4172    let mut actions: Vec<String> = vec![
4173        "Destroy Lima VM 'mvm' (if present)".to_string(),
4174        "Remove /var/lib/mvm/ (VM state, volumes, run-info)".to_string(),
4175    ];
4176    if all {
4177        actions.push("Remove ~/.mvm/ (config, signing keys)".to_string());
4178        actions.push("Remove /usr/local/bin/mvmctl (binary)".to_string());
4179    }
4180
4181    if dry_run {
4182        ui::info("Dry run — the following would be removed:");
4183        for a in &actions {
4184            println!("  • {a}");
4185        }
4186        return Ok(());
4187    }
4188
4189    // Confirmation prompt — also avoids any external calls.
4190    if !yes {
4191        ui::info("The following will be removed:");
4192        for a in &actions {
4193            println!("  • {a}");
4194        }
4195        if !ui::confirm("Proceed with uninstall?") {
4196            ui::info("Cancelled.");
4197            return Ok(());
4198        }
4199    }
4200
4201    // Now query Lima — only when actually performing the uninstall.
4202    let lima_status = lima::get_status().unwrap_or(lima::LimaStatus::NotFound);
4203
4204    // Stop running microVMs first (best-effort).
4205    if matches!(lima_status, lima::LimaStatus::Running)
4206        && let Err(e) = microvm::stop()
4207    {
4208        tracing::warn!("failed to stop microVMs before uninstall: {e}");
4209    }
4210
4211    // Destroy Lima VM.
4212    if !matches!(lima_status, lima::LimaStatus::NotFound) {
4213        ui::info("Destroying Lima VM...");
4214        if let Err(e) = lima::destroy() {
4215            tracing::warn!("failed to destroy Lima VM: {e}");
4216        }
4217    }
4218
4219    // Remove /var/lib/mvm/.
4220    let state_dir = std::path::Path::new("/var/lib/mvm");
4221    if state_dir.exists() {
4222        ui::info("Removing /var/lib/mvm/...");
4223        let status = std::process::Command::new("sudo")
4224            .args(["rm", "-rf", "/var/lib/mvm"])
4225            .status();
4226        match status {
4227            Ok(s) if s.success() => {}
4228            Ok(s) => tracing::warn!("sudo rm /var/lib/mvm exited with status {s}"),
4229            Err(e) => tracing::warn!("failed to remove /var/lib/mvm: {e}"),
4230        }
4231    }
4232
4233    if all {
4234        // Remove ~/.mvm/.
4235        if let Ok(home) = std::env::var("HOME") {
4236            let config_dir = std::path::PathBuf::from(home).join(".mvm");
4237            if config_dir.exists() {
4238                ui::info("Removing ~/.mvm/...");
4239                if let Err(e) = std::fs::remove_dir_all(&config_dir) {
4240                    tracing::warn!("failed to remove ~/.mvm/: {e}");
4241                }
4242            }
4243        }
4244
4245        // Remove /usr/local/bin/mvmctl.
4246        let bin = std::path::Path::new("/usr/local/bin/mvmctl");
4247        if bin.exists() {
4248            ui::info("Removing /usr/local/bin/mvmctl...");
4249            let status = std::process::Command::new("sudo")
4250                .args(["rm", "-f", "/usr/local/bin/mvmctl"])
4251                .status();
4252            match status {
4253                Ok(s) if s.success() => {}
4254                Ok(s) => tracing::warn!("sudo rm mvmctl exited with status {s}"),
4255                Err(e) => tracing::warn!("failed to remove /usr/local/bin/mvmctl: {e}"),
4256            }
4257        }
4258    }
4259
4260    mvm_core::audit::emit(mvm_core::audit::LocalAuditKind::Uninstall, None, None);
4261    ui::success("Uninstall complete.");
4262    Ok(())
4263}
4264
4265// ============================================================================
4266// Audit commands
4267// ============================================================================
4268
4269fn cmd_audit(action: AuditCmd) -> Result<()> {
4270    match action {
4271        AuditCmd::Tail { lines, follow } => cmd_audit_tail(lines, follow),
4272    }
4273}
4274
4275// ============================================================================
4276// Flake commands
4277// ============================================================================
4278
4279fn cmd_flake(action: FlakeCmd) -> Result<()> {
4280    match action {
4281        FlakeCmd::Check { flake, json } => cmd_flake_check(&flake, json),
4282    }
4283}
4284
4285fn cmd_flake_check(flake: &str, json: bool) -> Result<()> {
4286    let resolved = resolve_flake_ref(flake)?;
4287
4288    if bootstrap::is_lima_required() {
4289        lima::require_running()?;
4290    }
4291
4292    let script = format!("nix flake check {resolved}");
4293
4294    if json {
4295        // Capture combined stdout+stderr so we can embed it in JSON.
4296        let output = shell::run_in_vm_capture(&script);
4297        match output {
4298            Ok(out) => {
4299                let combined = format!(
4300                    "{}{}",
4301                    String::from_utf8_lossy(&out.stdout),
4302                    String::from_utf8_lossy(&out.stderr)
4303                );
4304                if out.status.success() {
4305                    println!("{{\"valid\":true}}");
4306                } else {
4307                    let msg = combined.trim().replace('"', "'");
4308                    println!("{{\"valid\":false,\"error\":\"{msg}\"}}");
4309                    std::process::exit(1);
4310                }
4311                Ok(())
4312            }
4313            Err(e) => {
4314                let msg = e.to_string().replace('"', "'");
4315                println!("{{\"valid\":false,\"error\":\"{msg}\"}}");
4316                std::process::exit(1);
4317            }
4318        }
4319    } else {
4320        // Stream output directly so the user sees nix progress in real time.
4321        match shell::run_in_vm_visible(&script) {
4322            Ok(()) => {
4323                ui::success("Flake is valid.");
4324                Ok(())
4325            }
4326            Err(e) => Err(e.context("Flake check failed")),
4327        }
4328    }
4329}
4330
4331fn cmd_audit_tail(lines: usize, follow: bool) -> Result<()> {
4332    let log_path = mvm_core::audit::default_audit_log();
4333    let path = std::path::Path::new(&log_path);
4334
4335    if !path.exists() {
4336        ui::info(&format!(
4337            "No audit log found. Events are recorded at {log_path}."
4338        ));
4339        return Ok(());
4340    }
4341
4342    print_last_n_lines(path, lines)?;
4343
4344    if !follow {
4345        return Ok(());
4346    }
4347
4348    // Tail -f: track file position and poll for new content.
4349    let mut pos = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
4350
4351    loop {
4352        std::thread::sleep(std::time::Duration::from_millis(500));
4353        if !path.exists() {
4354            continue;
4355        }
4356        let new_len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
4357        if new_len > pos {
4358            let mut file = std::fs::File::open(path)?;
4359            use std::io::{BufRead, Seek, SeekFrom};
4360            file.seek(SeekFrom::Start(pos))?;
4361            let reader = std::io::BufReader::new(&file);
4362            for line in reader.lines() {
4363                let line = line?;
4364                print_audit_line(&line);
4365            }
4366            pos = new_len;
4367        }
4368    }
4369}
4370
4371fn print_last_n_lines(path: &std::path::Path, n: usize) -> Result<()> {
4372    use std::io::BufRead;
4373    let file =
4374        std::fs::File::open(path).with_context(|| format!("Failed to open {}", path.display()))?;
4375    let reader = std::io::BufReader::new(file);
4376    let lines: Vec<String> = reader.lines().map_while(Result::ok).collect();
4377    let start = lines.len().saturating_sub(n);
4378    for line in &lines[start..] {
4379        print_audit_line(line);
4380    }
4381    Ok(())
4382}
4383
4384fn print_audit_line(line: &str) {
4385    match serde_json::from_str::<mvm_core::audit::LocalAuditEvent>(line) {
4386        Ok(event) => {
4387            let kind = serde_json::to_string(&event.kind)
4388                .unwrap_or_default()
4389                .trim_matches('"')
4390                .to_string();
4391            let vm = event
4392                .vm_name
4393                .as_deref()
4394                .map(|n| format!("  [{n}]"))
4395                .unwrap_or_default();
4396            let detail = event
4397                .detail
4398                .as_deref()
4399                .map(|d| format!("  {d}"))
4400                .unwrap_or_default();
4401            println!("{ts}  {kind}{vm}{detail}", ts = event.timestamp);
4402        }
4403        Err(_) => {
4404            // Non-local-audit line — print as-is (fleet AuditEntry, etc.)
4405            println!("{line}");
4406        }
4407    }
4408}
4409
4410// ============================================================================
4411// Template commands
4412// ============================================================================
4413
4414fn cmd_template(action: TemplateCmd) -> Result<()> {
4415    match action {
4416        TemplateCmd::Create {
4417            name,
4418            flake,
4419            profile,
4420            role,
4421            cpus,
4422            mem,
4423            data_disk,
4424        } => {
4425            validate_template_name(&name)
4426                .with_context(|| format!("Invalid template name: {:?}", name))?;
4427            validate_flake_ref(&flake)
4428                .with_context(|| format!("Invalid flake reference: {:?}", flake))?;
4429            let mem_mb = parse_human_size(&mem).context("Invalid memory size")?;
4430            let data_disk_mb = parse_human_size(&data_disk).context("Invalid data disk size")?;
4431            template_cmd::create_single(&name, &flake, &profile, &role, cpus, mem_mb, data_disk_mb)
4432        }
4433        TemplateCmd::CreateMulti {
4434            base,
4435            flake,
4436            profile,
4437            roles,
4438            cpus,
4439            mem,
4440            data_disk,
4441        } => {
4442            validate_template_name(&base)
4443                .with_context(|| format!("Invalid template base name: {:?}", base))?;
4444            validate_flake_ref(&flake)
4445                .with_context(|| format!("Invalid flake reference: {:?}", flake))?;
4446            let mem_mb = parse_human_size(&mem).context("Invalid memory size")?;
4447            let data_disk_mb = parse_human_size(&data_disk).context("Invalid data disk size")?;
4448            let role_list: Vec<String> = roles.split(',').map(|s| s.trim().to_string()).collect();
4449            template_cmd::create_multi(
4450                &base,
4451                &flake,
4452                &profile,
4453                &role_list,
4454                cpus,
4455                mem_mb,
4456                data_disk_mb,
4457            )
4458        }
4459        TemplateCmd::Build {
4460            name,
4461            force,
4462            snapshot,
4463            config,
4464            update_hash,
4465        } => {
4466            validate_template_name(&name)
4467                .with_context(|| format!("Invalid template name: {:?}", name))?;
4468            template_cmd::build(&name, force, snapshot, config.as_deref(), update_hash)
4469        }
4470        TemplateCmd::Push { name, revision } => {
4471            validate_template_name(&name)
4472                .with_context(|| format!("Invalid template name: {:?}", name))?;
4473            template_cmd::push(&name, revision.as_deref())
4474        }
4475        TemplateCmd::Pull { name, revision } => {
4476            validate_template_name(&name)
4477                .with_context(|| format!("Invalid template name: {:?}", name))?;
4478            template_cmd::pull(&name, revision.as_deref())
4479        }
4480        TemplateCmd::Verify { name, revision } => {
4481            validate_template_name(&name)
4482                .with_context(|| format!("Invalid template name: {:?}", name))?;
4483            template_cmd::verify(&name, revision.as_deref())
4484        }
4485        TemplateCmd::List { json } => template_cmd::list(json),
4486        TemplateCmd::Info { name, json } => {
4487            validate_template_name(&name)
4488                .with_context(|| format!("Invalid template name: {:?}", name))?;
4489            template_cmd::info(&name, json)
4490        }
4491        TemplateCmd::Edit {
4492            name,
4493            flake,
4494            profile,
4495            role,
4496            cpus,
4497            mem,
4498            data_disk,
4499        } => {
4500            validate_template_name(&name)
4501                .with_context(|| format!("Invalid template name: {:?}", name))?;
4502            if let Some(ref f) = flake {
4503                validate_flake_ref(f)
4504                    .with_context(|| format!("Invalid flake reference: {:?}", f))?;
4505            }
4506            let mem_mb = mem
4507                .as_ref()
4508                .map(|s| parse_human_size(s))
4509                .transpose()
4510                .context("Invalid memory size")?;
4511            let data_disk_mb = data_disk
4512                .as_ref()
4513                .map(|s| parse_human_size(s))
4514                .transpose()
4515                .context("Invalid data disk size")?;
4516            template_cmd::edit(
4517                &name,
4518                flake.as_deref(),
4519                profile.as_deref(),
4520                role.as_deref(),
4521                cpus,
4522                mem_mb,
4523                data_disk_mb,
4524            )
4525        }
4526        TemplateCmd::Delete { name, force } => {
4527            validate_template_name(&name)
4528                .with_context(|| format!("Invalid template name: {:?}", name))?;
4529            template_cmd::delete(&name, force)
4530        }
4531        TemplateCmd::Init {
4532            name,
4533            local,
4534            vm,
4535            dir,
4536            preset,
4537            prompt,
4538        } => {
4539            validate_template_name(&name)
4540                .with_context(|| format!("Invalid template name: {:?}", name))?;
4541            let use_local = local && !vm;
4542            template_cmd::init(&name, use_local, &dir, preset.as_deref(), prompt.as_deref())
4543        }
4544    }
4545}
4546
4547/// Resolve a VM name to its absolute directory path inside the Lima VM
4548/// and verify it is running.
4549fn resolve_running_vm(name: &str) -> Result<String> {
4550    if bootstrap::is_lima_required() {
4551        lima::require_running()?;
4552    }
4553
4554    let abs_vms = shell::run_in_vm_stdout(&format!("echo {}", config::VMS_DIR))?;
4555    let abs_dir = format!("{}/{}", abs_vms, name);
4556    let pid_file = format!("{}/fc.pid", abs_dir);
4557
4558    if !firecracker::is_vm_running(&pid_file)? {
4559        anyhow::bail!(
4560            "VM '{}' is not running. Use 'mvmctl status' to list running VMs.",
4561            name
4562        );
4563    }
4564
4565    Ok(abs_dir)
4566}
4567
4568// ============================================================================
4569// Config commands
4570// ============================================================================
4571
4572fn cmd_config(action: ConfigAction) -> Result<()> {
4573    match action {
4574        ConfigAction::Show => cmd_config_show(),
4575        ConfigAction::Edit => cmd_config_edit(),
4576        ConfigAction::Set { key, value } => cmd_config_set(&key, &value),
4577    }
4578}
4579
4580fn cmd_config_show() -> Result<()> {
4581    let cfg = mvm_core::user_config::load(None);
4582    let text = toml::to_string_pretty(&cfg).context("Failed to serialize config")?;
4583    print!("{}", text);
4584    Ok(())
4585}
4586
4587fn cmd_config_edit() -> Result<()> {
4588    // Ensure config file exists (load creates it with defaults if absent).
4589    let _ = mvm_core::user_config::load(None);
4590    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
4591    let config_path = std::path::PathBuf::from(home)
4592        .join(".mvm")
4593        .join("config.toml");
4594    let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".to_string());
4595    let status = std::process::Command::new(&editor)
4596        .arg(&config_path)
4597        .status()
4598        .with_context(|| format!("Failed to launch editor {:?}", editor))?;
4599    if !status.success() {
4600        anyhow::bail!("Editor exited with status {}", status);
4601    }
4602    Ok(())
4603}
4604
4605fn cmd_config_set(key: &str, value: &str) -> Result<()> {
4606    let mut cfg = mvm_core::user_config::load(None);
4607    mvm_core::user_config::set_key(&mut cfg, key, value)?;
4608    mvm_core::user_config::save(&cfg, None)?;
4609    println!("Set {} = {}", key, value);
4610    Ok(())
4611}
4612
4613// ============================================================================
4614// Network management
4615// ============================================================================
4616
4617fn cmd_network(action: NetworkCmd) -> Result<()> {
4618    use mvm_core::dev_network::{DevNetwork, network_path, networks_dir, validate_network_name};
4619
4620    match action {
4621        NetworkCmd::Create { name, subnet: _ } => {
4622            validate_network_name(&name)?;
4623            let dir = networks_dir();
4624            std::fs::create_dir_all(&dir)?;
4625
4626            let path = network_path(&name);
4627            if std::path::Path::new(&path).exists() {
4628                anyhow::bail!("Network {:?} already exists", name);
4629            }
4630
4631            // Find the next available slot by scanning existing networks
4632            let mut max_slot: u8 = 0;
4633            if let Ok(entries) = std::fs::read_dir(&dir) {
4634                for entry in entries.flatten() {
4635                    if let Ok(text) = std::fs::read_to_string(entry.path())
4636                        && let Ok(net) = serde_json::from_str::<DevNetwork>(&text)
4637                    {
4638                        let parts: Vec<&str> = net.subnet.split('.').collect();
4639                        if parts.len() >= 3
4640                            && let Ok(s) = parts[2].parse::<u8>()
4641                        {
4642                            max_slot = max_slot.max(s);
4643                        }
4644                    }
4645                }
4646            }
4647
4648            let net = if name == "default" {
4649                DevNetwork::default_network()
4650            } else {
4651                DevNetwork::new(&name, max_slot + 1)?
4652            };
4653
4654            let json = serde_json::to_string_pretty(&net)?;
4655            std::fs::write(&path, json)?;
4656
4657            mvm_core::audit::emit(
4658                mvm_core::audit::LocalAuditKind::NetworkCreate,
4659                None,
4660                Some(&name),
4661            );
4662
4663            ui::success(&format!(
4664                "Created network {:?} (bridge={}, subnet={})",
4665                net.name, net.bridge_name, net.subnet
4666            ));
4667            Ok(())
4668        }
4669        NetworkCmd::List => {
4670            let dir = networks_dir();
4671            if !std::path::Path::new(&dir).exists() {
4672                ui::info("No networks configured.");
4673                return Ok(());
4674            }
4675
4676            let mut networks: Vec<DevNetwork> = Vec::new();
4677            for entry in std::fs::read_dir(&dir)?.flatten() {
4678                if entry.path().extension().is_some_and(|e| e == "json")
4679                    && let Ok(text) = std::fs::read_to_string(entry.path())
4680                    && let Ok(net) = serde_json::from_str::<DevNetwork>(&text)
4681                {
4682                    networks.push(net);
4683                }
4684            }
4685
4686            if networks.is_empty() {
4687                ui::info("No networks configured.");
4688            } else {
4689                println!("{:<15} {:<15} {:<20}", "NAME", "BRIDGE", "SUBNET");
4690                for net in &networks {
4691                    println!(
4692                        "{:<15} {:<15} {:<20}",
4693                        net.name, net.bridge_name, net.subnet
4694                    );
4695                }
4696            }
4697            Ok(())
4698        }
4699        NetworkCmd::Inspect { name } => {
4700            let path = network_path(&name);
4701            if !std::path::Path::new(&path).exists() {
4702                anyhow::bail!("Network {:?} not found", name);
4703            }
4704            let text = std::fs::read_to_string(&path)?;
4705            let net: DevNetwork = serde_json::from_str(&text)?;
4706            println!("{}", serde_json::to_string_pretty(&net)?);
4707            Ok(())
4708        }
4709        NetworkCmd::Remove { name } => {
4710            if name == "default" {
4711                anyhow::bail!("Cannot remove the default network");
4712            }
4713            let path = network_path(&name);
4714            if !std::path::Path::new(&path).exists() {
4715                anyhow::bail!("Network {:?} not found", name);
4716            }
4717            std::fs::remove_file(&path)?;
4718
4719            mvm_core::audit::emit(
4720                mvm_core::audit::LocalAuditKind::NetworkRemove,
4721                None,
4722                Some(&name),
4723            );
4724
4725            ui::success(&format!("Removed network {:?}", name));
4726            Ok(())
4727        }
4728    }
4729}
4730
4731// ============================================================================
4732// Image catalog
4733// ============================================================================
4734
4735fn cmd_image(action: ImageCmd) -> Result<()> {
4736    let catalog = load_bundled_catalog();
4737
4738    match action {
4739        ImageCmd::List => {
4740            if catalog.entries.is_empty() {
4741                ui::info("No images in catalog.");
4742            } else {
4743                println!(
4744                    "{:<20} {:<40} {:<6} {:<8}",
4745                    "NAME", "DESCRIPTION", "CPUS", "MEM"
4746                );
4747                for entry in &catalog.entries {
4748                    println!(
4749                        "{:<20} {:<40} {:<6} {:<8}",
4750                        entry.name,
4751                        entry.description,
4752                        entry.default_cpus,
4753                        format!("{}M", entry.default_memory_mib),
4754                    );
4755                }
4756            }
4757            Ok(())
4758        }
4759        ImageCmd::Search { query } => {
4760            let results = catalog.search(&query);
4761            if results.is_empty() {
4762                ui::info(&format!("No images matching {:?}", query));
4763            } else {
4764                println!("{:<20} {:<40} {:<30}", "NAME", "DESCRIPTION", "TAGS");
4765                for entry in results {
4766                    println!(
4767                        "{:<20} {:<40} {:<30}",
4768                        entry.name,
4769                        entry.description,
4770                        entry.tags.join(", "),
4771                    );
4772                }
4773            }
4774            Ok(())
4775        }
4776        ImageCmd::Fetch { name } => {
4777            let entry = catalog
4778                .find(&name)
4779                .ok_or_else(|| anyhow::anyhow!("Image {:?} not found in catalog", name))?;
4780
4781            ui::info(&format!(
4782                "Fetching image {:?} from {}...",
4783                entry.name, entry.flake_ref
4784            ));
4785            ui::info("This will create a template and build it via Nix.");
4786            ui::info(&format!(
4787                "Equivalent to: mvmctl template create {} --flake {} --profile {} && mvmctl template build {}",
4788                entry.name, entry.flake_ref, entry.profile, entry.name
4789            ));
4790
4791            mvm_core::audit::emit(
4792                mvm_core::audit::LocalAuditKind::ImageFetch,
4793                None,
4794                Some(&name),
4795            );
4796
4797            // Create a template from the catalog entry, then build it
4798            template_cmd::create_single(
4799                &entry.name,
4800                &entry.flake_ref,
4801                &entry.profile,
4802                "worker",
4803                entry.default_cpus,
4804                entry.default_memory_mib,
4805                0, // no data disk
4806            )?;
4807            ui::success(&format!("Created template {:?} from catalog.", entry.name));
4808
4809            ui::info(&format!("Building template {:?}...", entry.name));
4810            template_cmd::build(&entry.name, false, false, None, false)?;
4811            ui::success(&format!(
4812                "Image {:?} is ready. Run with: mvmctl up --template {}",
4813                entry.name, entry.name
4814            ));
4815            Ok(())
4816        }
4817        ImageCmd::Info { name } => {
4818            let entry = catalog
4819                .find(&name)
4820                .ok_or_else(|| anyhow::anyhow!("Image {:?} not found in catalog", name))?;
4821            println!("{}", serde_json::to_string_pretty(entry)?);
4822            Ok(())
4823        }
4824    }
4825}
4826
4827/// Load the bundled image catalog with built-in presets.
4828fn load_bundled_catalog() -> mvm_core::catalog::Catalog {
4829    mvm_core::catalog::Catalog {
4830        schema_version: 1,
4831        entries: vec![
4832            mvm_core::catalog::CatalogEntry {
4833                name: "minimal".to_string(),
4834                description: "Bare-bones microVM with init only".to_string(),
4835                flake_ref: ".".to_string(),
4836                profile: "minimal".to_string(),
4837                default_cpus: 1,
4838                default_memory_mib: 256,
4839                tags: vec!["base".to_string(), "minimal".to_string()],
4840            },
4841            mvm_core::catalog::CatalogEntry {
4842                name: "http".to_string(),
4843                description: "HTTP server (Nginx or custom)".to_string(),
4844                flake_ref: ".".to_string(),
4845                profile: "http".to_string(),
4846                default_cpus: 2,
4847                default_memory_mib: 512,
4848                tags: vec!["web".to_string(), "http".to_string(), "nginx".to_string()],
4849            },
4850            mvm_core::catalog::CatalogEntry {
4851                name: "postgres".to_string(),
4852                description: "PostgreSQL database server".to_string(),
4853                flake_ref: ".".to_string(),
4854                profile: "postgres".to_string(),
4855                default_cpus: 2,
4856                default_memory_mib: 1024,
4857                tags: vec![
4858                    "database".to_string(),
4859                    "sql".to_string(),
4860                    "postgres".to_string(),
4861                ],
4862            },
4863            mvm_core::catalog::CatalogEntry {
4864                name: "worker".to_string(),
4865                description: "Background job worker".to_string(),
4866                flake_ref: ".".to_string(),
4867                profile: "worker".to_string(),
4868                default_cpus: 2,
4869                default_memory_mib: 512,
4870                tags: vec!["worker".to_string(), "background".to_string()],
4871            },
4872            mvm_core::catalog::CatalogEntry {
4873                name: "python".to_string(),
4874                description: "Python runtime environment".to_string(),
4875                flake_ref: ".".to_string(),
4876                profile: "python".to_string(),
4877                default_cpus: 2,
4878                default_memory_mib: 512,
4879                tags: vec!["python".to_string(), "runtime".to_string()],
4880            },
4881        ],
4882    }
4883}
4884
4885// ============================================================================
4886// Cache management
4887// ============================================================================
4888
4889fn cmd_cache(action: CacheCmd) -> Result<()> {
4890    let cache_dir = mvm_core::config::mvm_cache_dir();
4891
4892    match action {
4893        CacheCmd::Info => {
4894            println!("Cache directory: {cache_dir}");
4895            let path = std::path::Path::new(&cache_dir);
4896            if path.exists() {
4897                let size = dir_size(path);
4898                println!("Disk usage: {}", human_bytes(size));
4899            } else {
4900                println!("(not yet created)");
4901            }
4902            Ok(())
4903        }
4904        CacheCmd::Prune { dry_run } => {
4905            let path = std::path::Path::new(&cache_dir);
4906            if !path.exists() {
4907                ui::info("Cache directory does not exist. Nothing to prune.");
4908                return Ok(());
4909            }
4910
4911            // Prune: remove empty subdirectories and temp files
4912            let mut removed = 0u64;
4913            let mut freed = 0u64;
4914            for entry in walkdir(path)? {
4915                let entry_path = entry.path();
4916                // Remove temp files (mvm-lima-*, .tmp)
4917                if let Some(name) = entry_path.file_name().and_then(|n| n.to_str())
4918                    && (name.starts_with("mvm-lima-") || name.ends_with(".tmp"))
4919                {
4920                    let size = entry_path.metadata().map(|m| m.len()).unwrap_or(0);
4921                    if dry_run {
4922                        println!(
4923                            "Would remove: {} ({})",
4924                            entry_path.display(),
4925                            human_bytes(size)
4926                        );
4927                    } else if entry_path.is_dir() {
4928                        let _ = std::fs::remove_dir_all(entry_path);
4929                    } else {
4930                        let _ = std::fs::remove_file(entry_path);
4931                    }
4932                    removed += 1;
4933                    freed += size;
4934                }
4935            }
4936
4937            if removed == 0 {
4938                ui::info("Nothing to prune.");
4939            } else if dry_run {
4940                ui::info(&format!(
4941                    "Would remove {} items, freeing {}",
4942                    removed,
4943                    human_bytes(freed)
4944                ));
4945            } else {
4946                ui::success(&format!(
4947                    "Pruned {} items, freed {}",
4948                    removed,
4949                    human_bytes(freed)
4950                ));
4951            }
4952            Ok(())
4953        }
4954    }
4955}
4956
4957/// Recursively calculate directory size in bytes.
4958fn dir_size(path: &std::path::Path) -> u64 {
4959    walkdir(path)
4960        .unwrap_or_default()
4961        .iter()
4962        .filter(|e| e.path().is_file())
4963        .map(|e| e.path().metadata().map(|m| m.len()).unwrap_or(0))
4964        .sum()
4965}
4966
4967/// Simple recursive directory walker.
4968fn walkdir(path: &std::path::Path) -> Result<Vec<std::fs::DirEntry>> {
4969    let mut entries = Vec::new();
4970    if path.is_dir() {
4971        for entry in std::fs::read_dir(path)? {
4972            let entry = entry?;
4973            let epath = entry.path();
4974            let is_dir = epath.is_dir();
4975            entries.push(entry);
4976            if is_dir && let Ok(sub) = walkdir(&epath) {
4977                entries.extend(sub);
4978            }
4979        }
4980    }
4981    Ok(entries)
4982}
4983
4984// ============================================================================
4985// Init wizard
4986// ============================================================================
4987
4988fn cmd_init(non_interactive: bool, lima_cpus: u32, lima_mem: u32) -> Result<()> {
4989    use mvm_core::dev_network::{DevNetwork, network_path, networks_dir};
4990
4991    ui::info("Welcome to mvmctl! Running first-time setup...\n");
4992
4993    // Step 1: Platform detection
4994    let plat = mvm_core::platform::current();
4995    ui::info(&format!("Platform: {}", platform_label(plat)));
4996
4997    if plat.has_apple_containers() {
4998        ui::info("Apple Container support detected (macOS 26+).");
4999    }
5000
5001    // Step 2: Check and install dependencies
5002    ui::info("\nChecking dependencies...");
5003    match bootstrap::check_package_manager() {
5004        Ok(()) => {}
5005        Err(e) => {
5006            if non_interactive {
5007                return Err(e);
5008            }
5009            ui::warn(&format!("Package manager issue: {e}"));
5010            ui::info("Please install a package manager and retry.");
5011            return Err(e);
5012        }
5013    }
5014
5015    if plat.needs_lima() {
5016        ui::info("Ensuring Lima is installed...");
5017        bootstrap::ensure_lima()?;
5018    }
5019
5020    // Step 3: Run setup steps (create Lima VM, install Firecracker, Nix)
5021    ui::info("\nSetting up development environment...");
5022    run_setup_steps(false, lima_cpus, lima_mem)?;
5023
5024    // Step 4: Create default network if it doesn't exist
5025    let dir = networks_dir();
5026    let default_path = network_path("default");
5027    if !std::path::Path::new(&default_path).exists() {
5028        ui::info("\nCreating default network...");
5029        std::fs::create_dir_all(&dir)?;
5030        let net = DevNetwork::default_network();
5031        let json = serde_json::to_string_pretty(&net)?;
5032        std::fs::write(&default_path, json)?;
5033        ui::success(&format!(
5034            "Created default network (bridge={}, subnet={})",
5035            net.bridge_name, net.subnet
5036        ));
5037    } else {
5038        ui::info("\nDefault network already configured.");
5039    }
5040
5041    // Step 5: Create XDG directories
5042    ui::info("\nCreating data directories...");
5043    let dirs = [
5044        mvm_core::config::mvm_cache_dir(),
5045        mvm_core::config::mvm_config_dir(),
5046        mvm_core::config::mvm_state_dir(),
5047        mvm_core::config::mvm_share_dir(),
5048    ];
5049    for d in &dirs {
5050        std::fs::create_dir_all(d)?;
5051    }
5052
5053    // Step 6: Show available images
5054    ui::info("\nAvailable images in catalog:");
5055    let catalog = load_bundled_catalog();
5056    for entry in &catalog.entries {
5057        ui::info(&format!("  {} — {}", entry.name, entry.description));
5058    }
5059
5060    ui::success("\nSetup complete!");
5061    ui::info("Next steps:");
5062    ui::info("  mvmctl dev              # Enter development environment");
5063    ui::info("  mvmctl image list       # Browse available images");
5064    ui::info("  mvmctl doctor           # Verify everything is working");
5065    ui::info("  mvmctl up --flake .     # Build and run a VM from a Nix flake");
5066
5067    Ok(())
5068}
5069
5070fn platform_label(plat: mvm_core::platform::Platform) -> &'static str {
5071    match plat {
5072        mvm_core::platform::Platform::MacOS => "macOS (Lima + Firecracker)",
5073        mvm_core::platform::Platform::LinuxNative => "Linux (native KVM)",
5074        mvm_core::platform::Platform::LinuxNoKvm => "Linux (no KVM — limited)",
5075        mvm_core::platform::Platform::Wsl2 => "WSL2 (Linux via Windows)",
5076        mvm_core::platform::Platform::Windows => "Windows (experimental)",
5077    }
5078}
5079
5080// ============================================================================
5081// Security status
5082// ============================================================================
5083
5084fn cmd_security(action: SecurityCmd) -> Result<()> {
5085    match action {
5086        SecurityCmd::Status { json } => cmd_security_status(json),
5087    }
5088}
5089
5090fn cmd_security_status(json: bool) -> Result<()> {
5091    use mvm_core::security::{PostureCheck, SecurityLayer};
5092    use mvm_security::posture::SecurityPosture;
5093
5094    let mut checks = Vec::new();
5095
5096    // Check audit logging
5097    let audit_path = mvm_core::audit::default_audit_log();
5098    let audit_exists = std::path::Path::new(&audit_path).exists();
5099    checks.push(PostureCheck {
5100        layer: SecurityLayer::AuditLogging,
5101        name: "Local audit log".to_string(),
5102        passed: audit_exists,
5103        detail: if audit_exists {
5104            format!("Active at {audit_path}")
5105        } else {
5106            format!("Not found at {audit_path}")
5107        },
5108    });
5109
5110    // Check XDG directory structure
5111    let share_dir = mvm_core::config::mvm_share_dir();
5112    let xdg_exists = std::path::Path::new(&share_dir).exists();
5113    checks.push(PostureCheck {
5114        layer: SecurityLayer::ConfigImmutability,
5115        name: "XDG data directory".to_string(),
5116        passed: xdg_exists,
5117        detail: if xdg_exists {
5118            format!("Present at {share_dir}")
5119        } else {
5120            "Not yet created — run `mvmctl init`".to_string()
5121        },
5122    });
5123
5124    // Check default network
5125    let net_path = mvm_core::dev_network::network_path("default");
5126    let net_exists = std::path::Path::new(&net_path).exists();
5127    checks.push(PostureCheck {
5128        layer: SecurityLayer::NetworkIsolation,
5129        name: "Default dev network".to_string(),
5130        passed: net_exists,
5131        detail: if net_exists {
5132            "Configured".to_string()
5133        } else {
5134            "Not configured — run `mvmctl init` or `mvmctl network create default`".to_string()
5135        },
5136    });
5137
5138    // Check seccomp availability
5139    checks.push(PostureCheck {
5140        layer: SecurityLayer::SeccompFilter,
5141        name: "Seccomp profiles".to_string(),
5142        passed: true,
5143        detail: "5-tier profiles available (essential → unrestricted)".to_string(),
5144    });
5145
5146    // Check vsock auth
5147    checks.push(PostureCheck {
5148        layer: SecurityLayer::VsockAuth,
5149        name: "Vsock authentication".to_string(),
5150        passed: true,
5151        detail: "Ed25519 signing with replay protection".to_string(),
5152    });
5153
5154    // Check guest hardening (no SSH)
5155    checks.push(PostureCheck {
5156        layer: SecurityLayer::GuestHardening,
5157        name: "No SSH policy".to_string(),
5158        passed: true,
5159        detail: "Vsock-only guest communication (no sshd)".to_string(),
5160    });
5161
5162    // Check supply chain
5163    checks.push(PostureCheck {
5164        layer: SecurityLayer::SupplyChainIntegrity,
5165        name: "Nix-based builds".to_string(),
5166        passed: true,
5167        detail: "All images built from Nix flakes (content-addressed)".to_string(),
5168    });
5169
5170    let timestamp = mvm_core::time::utc_now();
5171    let report = SecurityPosture::evaluate(checks, &timestamp);
5172
5173    if json {
5174        println!("{}", serde_json::to_string_pretty(&report)?);
5175    } else {
5176        print!("{}", SecurityPosture::summary(&report));
5177
5178        let uncovered = SecurityPosture::uncovered_layers(&report.checks);
5179        if !uncovered.is_empty() {
5180            println!("\nUncovered layers (no checks):");
5181            for layer in uncovered {
5182                println!("  - {:?}", layer);
5183            }
5184        }
5185    }
5186
5187    Ok(())
5188}
5189
5190// ============================================================================
5191// One-shot exec (boot transient microVM, run argv, tear down)
5192// ============================================================================
5193
5194struct OneshotParams<'a> {
5195    template: Option<String>,
5196    cpus: u32,
5197    memory: &'a str,
5198    add_dir: &'a [String],
5199    env: &'a [String],
5200    timeout: u64,
5201    launch_plan: Option<String>,
5202    argv: Vec<String>,
5203}
5204
5205fn run_oneshot(p: OneshotParams<'_>) -> Result<()> {
5206    let OneshotParams {
5207        template,
5208        cpus,
5209        memory,
5210        add_dir,
5211        env,
5212        timeout,
5213        launch_plan,
5214        argv,
5215    } = p;
5216    let target = match (launch_plan.as_ref(), argv.is_empty()) {
5217        (Some(_), false) => {
5218            anyhow::bail!("--launch-plan and a trailing argv are mutually exclusive");
5219        }
5220        (Some(path), true) => {
5221            let entrypoint = crate::exec::load_launch_plan(std::path::Path::new(path))?;
5222            crate::exec::ExecTarget::LaunchPlan { entrypoint }
5223        }
5224        (None, true) => {
5225            anyhow::bail!("`mvmctl exec` requires a command (after `--`) or `--launch-plan <PATH>`")
5226        }
5227        (None, false) => crate::exec::ExecTarget::Inline { argv },
5228    };
5229    let memory_mib = parse_human_size(memory).context("Invalid --memory")?;
5230    let mut add_dirs = Vec::with_capacity(add_dir.len());
5231    for spec in add_dir {
5232        add_dirs.push(crate::exec::AddDir::parse(spec)?);
5233    }
5234    let mut env_pairs = Vec::with_capacity(env.len());
5235    for kv in env {
5236        let (k, v) = kv
5237            .split_once('=')
5238            .ok_or_else(|| anyhow::anyhow!("--env '{kv}': expected KEY=VALUE"))?;
5239        if k.is_empty() {
5240            anyhow::bail!("--env '{kv}': KEY must not be empty");
5241        }
5242        if !k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
5243            || k.starts_with(|c: char| c.is_ascii_digit())
5244        {
5245            anyhow::bail!("--env '{kv}': KEY must match [A-Za-z_][A-Za-z0-9_]* (got '{k}')");
5246        }
5247        env_pairs.push((k.to_string(), v.to_string()));
5248    }
5249    let image = match template {
5250        Some(name) => crate::exec::ImageSource::Template(name),
5251        None => {
5252            ui::info("No --template specified; using bundled default microVM image.");
5253            let (kernel_path, rootfs_path) = ensure_default_microvm_image()?;
5254            crate::exec::ImageSource::Prebuilt {
5255                kernel_path,
5256                rootfs_path,
5257                initrd_path: None,
5258                label: "default-microvm".to_string(),
5259            }
5260        }
5261    };
5262    let req = crate::exec::ExecRequest {
5263        image,
5264        cpus,
5265        memory_mib,
5266        add_dirs,
5267        env: env_pairs,
5268        target,
5269        timeout_secs: timeout,
5270    };
5271    let exit_code = crate::exec::run(req)?;
5272    if exit_code != 0 {
5273        std::process::exit(exit_code);
5274    }
5275    Ok(())
5276}
5277
5278// ============================================================================
5279// Console (PTY-over-vsock)
5280// ============================================================================
5281
5282fn cmd_console(name: &str, command: Option<&str>) -> Result<()> {
5283    validate_vm_name(name).with_context(|| format!("Invalid VM name: {:?}", name))?;
5284
5285    if let Some(cmd) = command {
5286        // One-shot command execution — detect backend for both Firecracker and Apple Container
5287        let resp = if let Ok(mut stream) =
5288            mvm_apple_container::vsock_connect(name, mvm_guest::vsock::GUEST_AGENT_PORT)
5289        {
5290            mvm_guest::vsock::send_request(
5291                &mut stream,
5292                &mvm_guest::vsock::GuestRequest::Exec {
5293                    command: cmd.to_string(),
5294                    stdin: None,
5295                    timeout_secs: Some(30),
5296                },
5297            )?
5298        } else {
5299            let instance_dir = microvm::resolve_running_vm_dir(name)?;
5300            mvm_guest::vsock::exec_at(
5301                &mvm_guest::vsock::vsock_uds_path(&instance_dir),
5302                cmd,
5303                None,
5304                30,
5305            )?
5306        };
5307        match resp {
5308            mvm_guest::vsock::GuestResponse::ExecResult {
5309                exit_code,
5310                stdout,
5311                stderr,
5312            } => {
5313                if !stdout.is_empty() {
5314                    print!("{stdout}");
5315                }
5316                if !stderr.is_empty() {
5317                    eprint!("{stderr}");
5318                }
5319                if exit_code != 0 {
5320                    std::process::exit(exit_code);
5321                }
5322                Ok(())
5323            }
5324            mvm_guest::vsock::GuestResponse::Error { message } => {
5325                anyhow::bail!("Console exec error: {message}")
5326            }
5327            other => anyhow::bail!("Unexpected response: {other:?}"),
5328        }
5329    } else {
5330        // Interactive PTY session
5331        console_interactive(name)
5332    }
5333}
5334
5335/// Open an interactive PTY console to a running VM.
5336///
5337/// Backend type for console connections.
5338enum ConsoleBackend {
5339    AppleContainer(String),
5340    /// Connect via the daemon's vsock proxy Unix socket.
5341    VsockProxy(String),
5342    Firecracker(String),
5343}
5344
5345/// Connect to a vsock port via the daemon's Unix socket proxy.
5346fn vsock_proxy_connect(proxy_path: &str, port: u32) -> Result<std::os::unix::net::UnixStream> {
5347    use std::io::Write;
5348    let mut stream = std::os::unix::net::UnixStream::connect(proxy_path)
5349        .with_context(|| format!("Failed to connect to vsock proxy at {proxy_path}"))?;
5350    stream.write_all(&port.to_le_bytes())?;
5351    Ok(stream)
5352}
5353
5354/// Open an interactive PTY console to a running VM.
5355///
5356/// Supports Firecracker (via UDS vsock), Apple Container (via direct vsock),
5357/// and vsock proxy (via daemon Unix socket for cross-process access).
5358fn console_interactive(name: &str) -> Result<()> {
5359    // Get terminal size
5360    let (cols, rows) = get_terminal_size();
5361
5362    // Send ConsoleOpen request via the control channel
5363    ui::info(&format!(
5364        "Opening console to VM {:?} ({}x{})...",
5365        name, cols, rows
5366    ));
5367
5368    // Determine backend: try in-process Apple Container, then vsock proxy, then Firecracker UDS
5369    let backend =
5370        if mvm_apple_container::vsock_connect(name, mvm_guest::vsock::GUEST_AGENT_PORT).is_ok() {
5371            ConsoleBackend::AppleContainer(name.to_string())
5372        } else if std::path::Path::new(&dev_vsock_proxy_path()).exists() {
5373            ConsoleBackend::VsockProxy(dev_vsock_proxy_path())
5374        } else {
5375            let instance_dir = microvm::resolve_running_vm_dir(name)?;
5376            ConsoleBackend::Firecracker(instance_dir)
5377        };
5378
5379    // Send ConsoleOpen on the control channel
5380    let (resp, connect_data) = match &backend {
5381        ConsoleBackend::AppleContainer(vm_id) => {
5382            let mut stream =
5383                mvm_apple_container::vsock_connect(vm_id, mvm_guest::vsock::GUEST_AGENT_PORT)
5384                    .map_err(|e| anyhow::anyhow!("{e}"))?;
5385            let resp = mvm_guest::vsock::send_request(
5386                &mut stream,
5387                &mvm_guest::vsock::GuestRequest::ConsoleOpen { cols, rows },
5388            )?;
5389            (resp, backend)
5390        }
5391        ConsoleBackend::VsockProxy(proxy_path) => {
5392            let mut stream = vsock_proxy_connect(proxy_path, mvm_guest::vsock::GUEST_AGENT_PORT)?;
5393            let resp = mvm_guest::vsock::send_request(
5394                &mut stream,
5395                &mvm_guest::vsock::GuestRequest::ConsoleOpen { cols, rows },
5396            )?;
5397            (resp, backend)
5398        }
5399        ConsoleBackend::Firecracker(instance_dir) => {
5400            let uds = mvm_guest::vsock::vsock_uds_path(instance_dir);
5401            let mut stream = mvm_guest::vsock::connect_to(&uds, 10)?;
5402            let resp = mvm_guest::vsock::send_request(
5403                &mut stream,
5404                &mvm_guest::vsock::GuestRequest::ConsoleOpen { cols, rows },
5405            )?;
5406            (resp, backend)
5407        }
5408    };
5409
5410    let (session_id, data_port) = match resp {
5411        mvm_guest::vsock::GuestResponse::ConsoleOpened {
5412            session_id,
5413            data_port,
5414        } => (session_id, data_port),
5415        mvm_guest::vsock::GuestResponse::Error { message } => {
5416            anyhow::bail!("Console open failed: {message}");
5417        }
5418        other => {
5419            anyhow::bail!("Unexpected response: {other:?}");
5420        }
5421    };
5422
5423    ui::info(&format!(
5424        "Console session {} opened, connecting to data port {}...",
5425        session_id, data_port
5426    ));
5427
5428    // Small delay to let the guest agent bind the data port
5429    std::thread::sleep(std::time::Duration::from_millis(200));
5430
5431    // Connect to the data port for raw I/O
5432    let data_stream = match &connect_data {
5433        ConsoleBackend::AppleContainer(vm_id) => {
5434            mvm_apple_container::vsock_connect(vm_id, data_port)
5435                .map_err(|e| anyhow::anyhow!("Failed to connect to console data port: {e}"))?
5436        }
5437        ConsoleBackend::VsockProxy(proxy_path) => vsock_proxy_connect(proxy_path, data_port)?,
5438        ConsoleBackend::Firecracker(instance_dir) => {
5439            // Firecracker vsock multiplexes all ports on the same UDS
5440            let uds = mvm_guest::vsock::vsock_uds_path(instance_dir);
5441            mvm_guest::vsock::connect_to(&uds, 10)
5442                .context("Failed to connect to console data port")?
5443        }
5444    };
5445
5446    mvm_core::audit::emit(
5447        mvm_core::audit::LocalAuditKind::ConsoleSessionStart,
5448        Some(name),
5449        Some(&format!("session_id={session_id}")),
5450    );
5451
5452    // Set up SIGWINCH handler to forward terminal resizes
5453    let resize_sender = setup_sigwinch_handler(&connect_data, session_id);
5454
5455    // Enter raw terminal mode and suppress the Ctrl-C handler so that
5456    // Ctrl+C is forwarded as a raw byte (\x03) to the guest shell
5457    // instead of killing mvmctl.
5458    IN_CONSOLE_MODE.store(true, std::sync::atomic::Ordering::SeqCst);
5459    let orig_termios = enter_raw_mode()?;
5460    let result = run_console_relay(data_stream);
5461
5462    // Restore terminal and clean up
5463    restore_terminal(&orig_termios);
5464    IN_CONSOLE_MODE.store(false, std::sync::atomic::Ordering::SeqCst);
5465    drop(resize_sender);
5466
5467    mvm_core::audit::emit(
5468        mvm_core::audit::LocalAuditKind::ConsoleSessionEnd,
5469        Some(name),
5470        Some(&format!("session_id={session_id}")),
5471    );
5472
5473    println!("\nConsole session ended.");
5474    result.map(|_| ())
5475}
5476
5477/// Flag set by the SIGWINCH signal handler.
5478static SIGWINCH_RECEIVED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
5479
5480extern "C" fn sigwinch_handler(_sig: libc::c_int) {
5481    SIGWINCH_RECEIVED.store(true, std::sync::atomic::Ordering::SeqCst);
5482}
5483
5484/// Set up a SIGWINCH signal handler that forwards terminal resizes to the guest.
5485///
5486/// Returns a sender that keeps the background thread alive. Drop it to stop.
5487fn setup_sigwinch_handler(
5488    backend: &ConsoleBackend,
5489    session_id: u32,
5490) -> Option<std::sync::mpsc::Sender<()>> {
5491    use std::sync::atomic::Ordering;
5492
5493    // Clone backend info for the resize thread
5494    let backend_info = match backend {
5495        ConsoleBackend::AppleContainer(vm_id) => ConsoleBackend::AppleContainer(vm_id.clone()),
5496        ConsoleBackend::VsockProxy(path) => ConsoleBackend::VsockProxy(path.clone()),
5497        ConsoleBackend::Firecracker(dir) => ConsoleBackend::Firecracker(dir.clone()),
5498    };
5499
5500    let (tx, rx) = std::sync::mpsc::channel::<()>();
5501
5502    // Install SIGWINCH handler
5503    unsafe {
5504        libc::signal(
5505            libc::SIGWINCH,
5506            sigwinch_handler as *const () as libc::sighandler_t,
5507        );
5508    }
5509
5510    // Background thread polls for resize signals
5511    std::thread::spawn(move || {
5512        loop {
5513            std::thread::sleep(std::time::Duration::from_millis(250));
5514
5515            // Stop if session ended (sender dropped)
5516            if let Err(std::sync::mpsc::TryRecvError::Disconnected) = rx.try_recv() {
5517                break;
5518            }
5519
5520            if !SIGWINCH_RECEIVED.swap(false, Ordering::SeqCst) {
5521                continue;
5522            }
5523
5524            let (cols, rows) = get_terminal_size();
5525
5526            // Send ConsoleResize via the control channel (best-effort)
5527            let _ = match &backend_info {
5528                ConsoleBackend::AppleContainer(vm_id) => {
5529                    mvm_apple_container::vsock_connect(vm_id, mvm_guest::vsock::GUEST_AGENT_PORT)
5530                        .ok()
5531                        .and_then(|mut stream| {
5532                            mvm_guest::vsock::send_request(
5533                                &mut stream,
5534                                &mvm_guest::vsock::GuestRequest::ConsoleResize {
5535                                    session_id,
5536                                    cols,
5537                                    rows,
5538                                },
5539                            )
5540                            .ok()
5541                        })
5542                }
5543                ConsoleBackend::VsockProxy(proxy_path) => {
5544                    vsock_proxy_connect(proxy_path, mvm_guest::vsock::GUEST_AGENT_PORT)
5545                        .ok()
5546                        .and_then(|mut stream| {
5547                            mvm_guest::vsock::send_request(
5548                                &mut stream,
5549                                &mvm_guest::vsock::GuestRequest::ConsoleResize {
5550                                    session_id,
5551                                    cols,
5552                                    rows,
5553                                },
5554                            )
5555                            .ok()
5556                        })
5557                }
5558                ConsoleBackend::Firecracker(instance_dir) => {
5559                    let uds = mvm_guest::vsock::vsock_uds_path(instance_dir);
5560                    mvm_guest::vsock::connect_to(&uds, 5)
5561                        .ok()
5562                        .and_then(|mut stream| {
5563                            mvm_guest::vsock::send_request(
5564                                &mut stream,
5565                                &mvm_guest::vsock::GuestRequest::ConsoleResize {
5566                                    session_id,
5567                                    cols,
5568                                    rows,
5569                                },
5570                            )
5571                            .ok()
5572                        })
5573                }
5574            };
5575        }
5576    });
5577
5578    Some(tx)
5579}
5580
5581/// Get the current terminal size.
5582fn get_terminal_size() -> (u16, u16) {
5583    // SAFETY: ioctl with valid fd (stdout)
5584    unsafe {
5585        let mut ws: libc::winsize = std::mem::zeroed();
5586        if libc::ioctl(1, libc::TIOCGWINSZ, &mut ws) == 0 && ws.ws_col > 0 && ws.ws_row > 0 {
5587            (ws.ws_col, ws.ws_row)
5588        } else {
5589            (80, 24)
5590        }
5591    }
5592}
5593
5594/// Put the terminal in raw mode and return the original termios for restoration.
5595fn enter_raw_mode() -> Result<libc::termios> {
5596    unsafe {
5597        let mut orig: libc::termios = std::mem::zeroed();
5598        if libc::tcgetattr(0, &mut orig) != 0 {
5599            anyhow::bail!("Failed to get terminal attributes");
5600        }
5601
5602        let mut raw = orig;
5603        libc::cfmakeraw(&mut raw);
5604        if libc::tcsetattr(0, libc::TCSANOW, &raw) != 0 {
5605            anyhow::bail!("Failed to set raw terminal mode");
5606        }
5607
5608        Ok(orig)
5609    }
5610}
5611
5612/// Restore the terminal to its original mode.
5613fn restore_terminal(orig: &libc::termios) {
5614    unsafe {
5615        libc::tcsetattr(0, libc::TCSANOW, orig);
5616    }
5617}
5618
5619/// Relay raw bytes between stdin/stdout and a vsock data stream.
5620///
5621/// Exits when the guest closes the connection (e.g. `exit` or Ctrl+D
5622/// in the shell) or when the user types the `~.` escape sequence
5623/// (Enter, then `~.`, same as SSH).
5624///
5625fn run_console_relay(data_stream: std::os::unix::net::UnixStream) -> Result<()> {
5626    use std::io::{Read, Write};
5627    use std::os::unix::io::AsRawFd;
5628
5629    let read_stream = data_stream
5630        .try_clone()
5631        .context("Failed to clone data stream")?;
5632    let write_stream = data_stream;
5633    let stdin_fd = std::io::stdin().as_raw_fd();
5634    let vsock_fd = read_stream.as_raw_fd();
5635
5636    // Save original flags so we can restore stdin after the relay exits.
5637    let orig_stdin_flags = unsafe { libc::fcntl(stdin_fd, libc::F_GETFL) };
5638    unsafe {
5639        libc::fcntl(stdin_fd, libc::F_SETFL, orig_stdin_flags | libc::O_NONBLOCK);
5640        libc::fcntl(vsock_fd, libc::F_SETFL, libc::O_NONBLOCK);
5641    }
5642
5643    let mut stdout = std::io::stdout();
5644    let mut writer = write_stream;
5645    let mut buf = [0u8; 4096];
5646
5647    loop {
5648        let mut fds = [
5649            libc::pollfd {
5650                fd: stdin_fd,
5651                events: libc::POLLIN,
5652                revents: 0,
5653            },
5654            libc::pollfd {
5655                fd: vsock_fd,
5656                events: libc::POLLIN,
5657                revents: 0,
5658            },
5659        ];
5660        let ret = unsafe { libc::poll(fds.as_mut_ptr(), 2, 500) };
5661        if ret < 0 {
5662            if std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted {
5663                continue;
5664            }
5665            break;
5666        }
5667
5668        // vsock → stdout (guest output)
5669        if fds[1].revents & libc::POLLIN != 0 {
5670            match (&read_stream).read(&mut buf) {
5671                Ok(0) => break,
5672                Ok(n) => {
5673                    let _ = stdout.write_all(&buf[..n]);
5674                    let _ = stdout.flush();
5675                }
5676                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
5677                Err(_) => break,
5678            }
5679        }
5680        if fds[1].revents & (libc::POLLHUP | libc::POLLERR) != 0
5681            && fds[1].revents & libc::POLLIN == 0
5682        {
5683            break;
5684        }
5685
5686        // stdin → vsock (host input)
5687        if fds[0].revents & (libc::POLLIN | libc::POLLHUP) != 0 {
5688            let mut inbuf = [0u8; 1024];
5689            match std::io::stdin().read(&mut inbuf) {
5690                Ok(0) => break,
5691                Ok(n) => {
5692                    if writer.write_all(&inbuf[..n]).is_err() {
5693                        break;
5694                    }
5695                    let _ = writer.flush();
5696                }
5697                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
5698                Err(_) => break,
5699            }
5700        }
5701    }
5702
5703    // Restore stdin to its original blocking mode
5704    unsafe {
5705        libc::fcntl(stdin_fd, libc::F_SETFL, orig_stdin_flags);
5706    }
5707
5708    Ok(())
5709}
5710
5711// ============================================================================
5712// Utilities
5713// ============================================================================
5714
5715#[cfg(test)]
5716mod tests {
5717    use super::*;
5718    use clap::Parser;
5719
5720    #[test]
5721    fn test_cleanup_defaults() {
5722        let cli = Cli::try_parse_from(["mvmctl", "cleanup"]).unwrap();
5723        match cli.command {
5724            Commands::Cleanup { keep, all, verbose } => {
5725                assert_eq!(keep, None);
5726                assert!(!all);
5727                assert!(!verbose);
5728            }
5729            _ => panic!("Expected Cleanup command"),
5730        }
5731    }
5732
5733    #[test]
5734    fn test_cleanup_keep_flag() {
5735        let cli = Cli::try_parse_from(["mvmctl", "cleanup", "--keep", "9"]).unwrap();
5736        match cli.command {
5737            Commands::Cleanup { keep, all, verbose } => {
5738                assert_eq!(keep, Some(9));
5739                assert!(!all);
5740                assert!(!verbose);
5741            }
5742            _ => panic!("Expected Cleanup command"),
5743        }
5744    }
5745
5746    #[test]
5747    fn test_cleanup_all_flag() {
5748        let cli = Cli::try_parse_from(["mvmctl", "cleanup", "--all"]).unwrap();
5749        match cli.command {
5750            Commands::Cleanup { keep, all, verbose } => {
5751                assert_eq!(keep, None);
5752                assert!(all);
5753                assert!(!verbose);
5754            }
5755            _ => panic!("Expected Cleanup command"),
5756        }
5757    }
5758
5759    #[test]
5760    fn test_cleanup_verbose_flag() {
5761        let cli = Cli::try_parse_from(["mvmctl", "cleanup", "--verbose"]).unwrap();
5762        match cli.command {
5763            Commands::Cleanup { keep, all, verbose } => {
5764                assert_eq!(keep, None);
5765                assert!(!all);
5766                assert!(verbose);
5767            }
5768            _ => panic!("Expected Cleanup command"),
5769        }
5770    }
5771
5772    // ---- Build --flake tests ----
5773
5774    #[test]
5775    fn test_build_flake_with_profile() {
5776        let cli = Cli::try_parse_from(["mvmctl", "build", "--flake", ".", "--profile", "gateway"])
5777            .unwrap();
5778        match cli.command {
5779            Commands::Build { flake, profile, .. } => {
5780                assert_eq!(flake.as_deref(), Some("."));
5781                assert_eq!(profile.as_deref(), Some("gateway"));
5782            }
5783            _ => panic!("Expected Build command"),
5784        }
5785    }
5786
5787    #[test]
5788    fn test_build_flake_defaults_to_no_profile() {
5789        let cli = Cli::try_parse_from(["mvmctl", "build", "--flake", "."]).unwrap();
5790        match cli.command {
5791            Commands::Build { flake, profile, .. } => {
5792                assert_eq!(flake.as_deref(), Some("."));
5793                assert!(profile.is_none(), "profile should be None when omitted");
5794            }
5795            _ => panic!("Expected Build command"),
5796        }
5797    }
5798
5799    #[test]
5800    fn test_build_mvmfile_mode_still_works() {
5801        let cli = Cli::try_parse_from(["mvmctl", "build", "myimage"]).unwrap();
5802        match cli.command {
5803            Commands::Build { path, flake, .. } => {
5804                assert_eq!(path, "myimage");
5805                assert!(flake.is_none(), "Mvmfile mode should have no --flake");
5806            }
5807            _ => panic!("Expected Build command"),
5808        }
5809    }
5810
5811    #[test]
5812    fn test_resolve_flake_ref_remote_passthrough() {
5813        let resolved = resolve_flake_ref("github:user/repo").unwrap();
5814        assert_eq!(resolved, "github:user/repo");
5815    }
5816
5817    #[test]
5818    fn test_resolve_flake_ref_remote_with_path() {
5819        let resolved = resolve_flake_ref("github:user/repo#attr").unwrap();
5820        assert_eq!(resolved, "github:user/repo#attr");
5821    }
5822
5823    #[test]
5824    fn test_resolve_flake_ref_absolute_path() {
5825        let resolved = resolve_flake_ref("/tmp").unwrap();
5826        // /tmp may be a symlink on macOS to /private/tmp
5827        assert!(
5828            resolved == "/tmp" || resolved == "/private/tmp",
5829            "unexpected resolved path: {}",
5830            resolved
5831        );
5832    }
5833
5834    #[test]
5835    fn test_resolve_flake_ref_nonexistent_fails() {
5836        let result = resolve_flake_ref("/nonexistent/path/that/does/not/exist");
5837        assert!(result.is_err());
5838    }
5839
5840    // ---- Run command tests ----
5841
5842    #[test]
5843    fn test_run_parses_all_flags() {
5844        let cli = Cli::try_parse_from([
5845            "mvmctl",
5846            "run",
5847            "--flake",
5848            ".",
5849            "--profile",
5850            "full",
5851            "--cpus",
5852            "4",
5853            "--memory",
5854            "2048",
5855        ])
5856        .unwrap();
5857        match cli.command {
5858            Commands::Up {
5859                flake,
5860                profile,
5861                cpus,
5862                memory,
5863                ..
5864            } => {
5865                assert_eq!(flake, Some(".".to_string()));
5866                assert_eq!(profile.as_deref(), Some("full"));
5867                assert_eq!(cpus, Some(4));
5868                assert_eq!(memory, Some("2048".to_string()));
5869            }
5870            _ => panic!("Expected Run command"),
5871        }
5872    }
5873
5874    #[test]
5875    fn test_run_defaults() {
5876        let cli = Cli::try_parse_from(["mvmctl", "run", "--flake", "."]).unwrap();
5877        match cli.command {
5878            Commands::Up {
5879                flake,
5880                template,
5881                name,
5882                profile,
5883                cpus,
5884                memory,
5885                volume,
5886                hypervisor,
5887                ..
5888            } => {
5889                assert_eq!(flake, Some(".".to_string()));
5890                assert!(template.is_none(), "template should be None when omitted");
5891                assert!(name.is_none(), "name should be None when omitted");
5892                assert!(profile.is_none(), "profile should be None when omitted");
5893                assert!(cpus.is_none(), "cpus should be None when omitted");
5894                assert!(memory.is_none(), "memory should be None when omitted");
5895                assert_eq!(volume.len(), 0);
5896                assert_eq!(hypervisor, "firecracker");
5897            }
5898            _ => panic!("Expected Run command"),
5899        }
5900    }
5901
5902    #[test]
5903    fn test_run_without_source_uses_default_microvm() {
5904        // No --flake / --template: the dispatcher falls back to the bundled
5905        // default microVM image. Clap should accept the bare invocation; the
5906        // dispatcher then resolves the image at runtime.
5907        let cli = Cli::try_parse_from(["mvmctl", "run"]).expect("parse");
5908        match cli.command {
5909            Commands::Up {
5910                flake, template, ..
5911            } => {
5912                assert!(flake.is_none(), "no --flake should be parsed");
5913                assert!(template.is_none(), "no --template should be parsed");
5914            }
5915            _ => panic!("Expected Run command"),
5916        }
5917    }
5918
5919    #[test]
5920    fn test_run_template_flag() {
5921        let cli = Cli::try_parse_from(["mvmctl", "run", "--template", "openclaw"]).unwrap();
5922        match cli.command {
5923            Commands::Up {
5924                flake, template, ..
5925            } => {
5926                assert!(flake.is_none());
5927                assert_eq!(template, Some("openclaw".to_string()));
5928            }
5929            _ => panic!("Expected Run command"),
5930        }
5931    }
5932
5933    #[test]
5934    fn test_run_flake_and_template_conflict() {
5935        let result =
5936            Cli::try_parse_from(["mvmctl", "run", "--flake", ".", "--template", "openclaw"]);
5937        assert!(
5938            result.is_err(),
5939            "--flake and --template should be mutually exclusive"
5940        );
5941    }
5942
5943    #[test]
5944    fn test_run_volume_dir_inject() {
5945        let cli = Cli::try_parse_from([
5946            "mvmctl",
5947            "run",
5948            "--flake",
5949            ".",
5950            "-v",
5951            "/tmp/config:/mnt/config",
5952            "-v",
5953            "/tmp/secrets:/mnt/secrets",
5954        ])
5955        .unwrap();
5956        match cli.command {
5957            Commands::Up { volume, .. } => {
5958                assert_eq!(volume.len(), 2);
5959                assert_eq!(volume[0], "/tmp/config:/mnt/config");
5960                assert_eq!(volume[1], "/tmp/secrets:/mnt/secrets");
5961            }
5962            _ => panic!("Expected Run command"),
5963        }
5964    }
5965
5966    #[test]
5967    fn test_run_volume_persistent() {
5968        let cli =
5969            Cli::try_parse_from(["mvmctl", "run", "--flake", ".", "-v", "/data:/mnt/data:4G"])
5970                .unwrap();
5971        match cli.command {
5972            Commands::Up { volume, .. } => {
5973                assert_eq!(volume.len(), 1);
5974                assert_eq!(volume[0], "/data:/mnt/data:4G");
5975            }
5976            _ => panic!("Expected Run command"),
5977        }
5978    }
5979
5980    #[test]
5981    fn test_parse_volume_spec_dir_inject() {
5982        let spec = parse_volume_spec("/tmp/config:/mnt/config").unwrap();
5983        match spec {
5984            VolumeSpec::DirInject {
5985                host_dir,
5986                guest_mount,
5987            } => {
5988                assert_eq!(host_dir, "/tmp/config");
5989                assert_eq!(guest_mount, "/mnt/config");
5990            }
5991            _ => panic!("Expected DirInject"),
5992        }
5993    }
5994
5995    #[test]
5996    fn test_parse_volume_spec_persistent() {
5997        let spec = parse_volume_spec("/data:/mnt/data:4G").unwrap();
5998        match spec {
5999            VolumeSpec::Persistent(vol) => {
6000                assert_eq!(vol.host, "/data");
6001                assert_eq!(vol.guest, "/mnt/data");
6002                assert_eq!(vol.size, "4G");
6003            }
6004            _ => panic!("Expected Persistent"),
6005        }
6006    }
6007
6008    #[test]
6009    fn test_parse_volume_spec_invalid() {
6010        let result = parse_volume_spec("just-a-path");
6011        assert!(result.is_err());
6012    }
6013
6014    #[test]
6015    fn test_parse_volume_spec_unsupported_mount() {
6016        let spec = parse_volume_spec("/tmp/foo:/mnt/custom").unwrap();
6017        // The spec itself parses fine — the error happens at routing time in cmd_run
6018        match spec {
6019            VolumeSpec::DirInject { guest_mount, .. } => {
6020                assert_eq!(guest_mount, "/mnt/custom");
6021            }
6022            _ => panic!("Expected DirInject"),
6023        }
6024    }
6025
6026    #[test]
6027    fn test_run_port_and_env_flags() {
6028        let cli = Cli::try_parse_from([
6029            "mvmctl",
6030            "run",
6031            "--flake",
6032            ".",
6033            "-p",
6034            "3333:3000",
6035            "-p",
6036            "3334:3002",
6037            "-e",
6038            "NODE_ENV=production",
6039            "-e",
6040            "DEBUG=true",
6041        ])
6042        .unwrap();
6043        match cli.command {
6044            Commands::Up { port, env, .. } => {
6045                assert_eq!(port, vec!["3333:3000", "3334:3002"]);
6046                assert_eq!(env, vec!["NODE_ENV=production", "DEBUG=true"]);
6047            }
6048            _ => panic!("Expected Run command"),
6049        }
6050    }
6051
6052    #[test]
6053    fn test_run_port_and_env_default_empty() {
6054        let cli = Cli::try_parse_from(["mvmctl", "run", "--flake", "."]).unwrap();
6055        match cli.command {
6056            Commands::Up { port, env, .. } => {
6057                assert!(port.is_empty());
6058                assert!(env.is_empty());
6059            }
6060            _ => panic!("Expected Run command"),
6061        }
6062    }
6063
6064    #[test]
6065    fn test_run_forward_flag() {
6066        let cli = Cli::try_parse_from([
6067            "mvmctl",
6068            "run",
6069            "--flake",
6070            ".",
6071            "-p",
6072            "3333:3000",
6073            "--forward",
6074        ])
6075        .unwrap();
6076        match cli.command {
6077            Commands::Up { forward, port, .. } => {
6078                assert!(forward);
6079                assert_eq!(port, vec!["3333:3000"]);
6080            }
6081            _ => panic!("Expected Run command"),
6082        }
6083    }
6084
6085    #[test]
6086    fn test_run_forward_default_false() {
6087        let cli = Cli::try_parse_from(["mvmctl", "run", "--flake", "."]).unwrap();
6088        match cli.command {
6089            Commands::Up { forward, .. } => {
6090                assert!(!forward);
6091            }
6092            _ => panic!("Expected Run command"),
6093        }
6094    }
6095
6096    #[test]
6097    fn test_parse_port_specs_multiple() {
6098        let specs = vec!["3333:3000".to_string(), "8080".to_string()];
6099        let result = parse_port_specs(&specs).unwrap();
6100        assert_eq!(result.len(), 2);
6101        assert_eq!(result[0].host, 3333);
6102        assert_eq!(result[0].guest, 3000);
6103        assert_eq!(result[1].host, 8080);
6104        assert_eq!(result[1].guest, 8080);
6105    }
6106
6107    #[test]
6108    fn test_parse_port_specs_empty() {
6109        let specs: Vec<String> = vec![];
6110        let result = parse_port_specs(&specs).unwrap();
6111        assert!(result.is_empty());
6112    }
6113
6114    #[test]
6115    fn test_ports_to_drive_file() {
6116        use mvm_runtime::config::PortMapping;
6117        let ports = vec![
6118            PortMapping {
6119                host: 3333,
6120                guest: 3000,
6121            },
6122            PortMapping {
6123                host: 3334,
6124                guest: 3002,
6125            },
6126        ];
6127        let f = ports_to_drive_file(&ports).unwrap();
6128        assert_eq!(f.name, "mvm-ports.env");
6129        assert!(f.content.contains("MVM_PORT_MAP=\"3333:3000,3334:3002\""));
6130        assert_eq!(f.mode, 0o444);
6131    }
6132
6133    #[test]
6134    fn test_ports_to_drive_file_empty() {
6135        assert!(ports_to_drive_file(&[]).is_none());
6136    }
6137
6138    #[test]
6139    fn test_env_vars_to_drive_file() {
6140        let vars = vec!["NODE_ENV=production".to_string(), "DEBUG=true".to_string()];
6141        let f = env_vars_to_drive_file(&vars).unwrap();
6142        assert_eq!(f.name, "mvm-env.env");
6143        assert!(f.content.contains("export NODE_ENV=production"));
6144        assert!(f.content.contains("export DEBUG=true"));
6145        assert_eq!(f.mode, 0o444);
6146    }
6147
6148    #[test]
6149    fn test_env_vars_to_drive_file_empty() {
6150        let vars: Vec<String> = vec![];
6151        assert!(env_vars_to_drive_file(&vars).is_none());
6152    }
6153
6154    // ---- VM subcommand tests ----
6155
6156    // ---- Up/Down command tests ----
6157
6158    #[test]
6159    fn test_down_parses_no_args() {
6160        let cli = Cli::try_parse_from(["mvmctl", "down"]).unwrap();
6161        match cli.command {
6162            Commands::Down { name, config } => {
6163                assert!(name.is_none());
6164                assert!(config.is_none());
6165            }
6166            _ => panic!("Expected Down command"),
6167        }
6168    }
6169
6170    #[test]
6171    fn test_down_parses_with_name() {
6172        let cli = Cli::try_parse_from(["mvmctl", "down", "gw"]).unwrap();
6173        match cli.command {
6174            Commands::Down { name, config } => {
6175                assert_eq!(name.as_deref(), Some("gw"));
6176                assert!(config.is_none());
6177            }
6178            _ => panic!("Expected Down command"),
6179        }
6180    }
6181
6182    #[test]
6183    fn test_down_parses_with_config() {
6184        let cli = Cli::try_parse_from(["mvmctl", "down", "-f", "my-fleet.toml"]).unwrap();
6185        match cli.command {
6186            Commands::Down { name, config } => {
6187                assert!(name.is_none());
6188                assert_eq!(config.as_deref(), Some("my-fleet.toml"));
6189            }
6190            _ => panic!("Expected Down command"),
6191        }
6192    }
6193
6194    // ---- read_dir_to_drive_files tests ----
6195
6196    #[test]
6197    fn test_read_dir_to_drive_files_reads_files() {
6198        let dir = tempfile::tempdir().unwrap();
6199        std::fs::write(dir.path().join("a.txt"), "hello").unwrap();
6200        std::fs::write(dir.path().join("b.env"), "KEY=val").unwrap();
6201
6202        let files = read_dir_to_drive_files(dir.path().to_str().unwrap(), 0o444).unwrap();
6203        assert_eq!(files.len(), 2);
6204
6205        let names: Vec<&str> = files.iter().map(|f| f.name.as_str()).collect();
6206        assert!(names.contains(&"a.txt"));
6207        assert!(names.contains(&"b.env"));
6208
6209        for f in &files {
6210            assert_eq!(f.mode, 0o444);
6211        }
6212    }
6213
6214    #[test]
6215    fn test_read_dir_to_drive_files_skips_directories() {
6216        let dir = tempfile::tempdir().unwrap();
6217        std::fs::write(dir.path().join("file.txt"), "content").unwrap();
6218        std::fs::create_dir(dir.path().join("subdir")).unwrap();
6219
6220        let files = read_dir_to_drive_files(dir.path().to_str().unwrap(), 0o400).unwrap();
6221        assert_eq!(files.len(), 1);
6222        assert_eq!(files[0].name, "file.txt");
6223        assert_eq!(files[0].mode, 0o400);
6224    }
6225
6226    #[test]
6227    fn test_read_dir_to_drive_files_empty_dir() {
6228        let dir = tempfile::tempdir().unwrap();
6229        let files = read_dir_to_drive_files(dir.path().to_str().unwrap(), 0o444).unwrap();
6230        assert!(files.is_empty());
6231    }
6232
6233    #[test]
6234    fn test_read_dir_to_drive_files_nonexistent_dir() {
6235        let result = read_dir_to_drive_files("/nonexistent/path/abc123", 0o444);
6236        assert!(result.is_err());
6237    }
6238
6239    // ---- Forward command tests ----
6240
6241    #[test]
6242    fn test_forward_parses() {
6243        let cli = Cli::try_parse_from(["mvmctl", "forward", "swift", "3000"]).unwrap();
6244        match cli.command {
6245            Commands::Forward { name, port, ports } => {
6246                assert_eq!(name, "swift");
6247                // Positional ports land in `ports`, flag ports in `port`.
6248                assert!(port.is_empty());
6249                assert_eq!(ports, vec!["3000"]);
6250            }
6251            _ => panic!("Expected Forward command"),
6252        }
6253    }
6254
6255    #[test]
6256    fn test_forward_with_port_mapping() {
6257        let cli = Cli::try_parse_from(["mvmctl", "forward", "swift", "8080:3000"]).unwrap();
6258        match cli.command {
6259            Commands::Forward { name, port, ports } => {
6260                assert_eq!(name, "swift");
6261                assert!(port.is_empty());
6262                assert_eq!(ports, vec!["8080:3000"]);
6263            }
6264            _ => panic!("Expected Forward command"),
6265        }
6266    }
6267
6268    #[test]
6269    fn test_forward_with_flag() {
6270        let cli = Cli::try_parse_from(["mvmctl", "forward", "swift", "-p", "3000"]).unwrap();
6271        match cli.command {
6272            Commands::Forward { name, port, ports } => {
6273                assert_eq!(name, "swift");
6274                assert_eq!(port, vec!["3000"]);
6275                assert!(ports.is_empty());
6276            }
6277            _ => panic!("Expected Forward command"),
6278        }
6279    }
6280
6281    #[test]
6282    fn test_forward_multiple_ports() {
6283        let cli =
6284            Cli::try_parse_from(["mvmctl", "forward", "swift", "-p", "3000", "-p", "8080:443"])
6285                .unwrap();
6286        match cli.command {
6287            Commands::Forward { name, port, ports } => {
6288                assert_eq!(name, "swift");
6289                assert_eq!(port, vec!["3000", "8080:443"]);
6290                assert!(ports.is_empty());
6291            }
6292            _ => panic!("Expected Forward command"),
6293        }
6294    }
6295
6296    #[test]
6297    fn test_forward_multiple_positional() {
6298        let cli = Cli::try_parse_from(["mvmctl", "forward", "swift", "3000", "8080:443"]).unwrap();
6299        match cli.command {
6300            Commands::Forward { name, port, ports } => {
6301                assert_eq!(name, "swift");
6302                assert!(port.is_empty());
6303                assert_eq!(ports, vec!["3000", "8080:443"]);
6304            }
6305            _ => panic!("Expected Forward command"),
6306        }
6307    }
6308
6309    #[test]
6310    fn test_forward_no_ports_parses() {
6311        // forward with no ports should parse successfully — cmd_forward
6312        // falls back to persisted ports from run-info.json
6313        let cli = Cli::try_parse_from(["mvmctl", "forward", "swift"]).unwrap();
6314        match cli.command {
6315            Commands::Forward { name, port, ports } => {
6316                assert_eq!(name, "swift");
6317                assert!(port.is_empty());
6318                assert!(ports.is_empty());
6319            }
6320            _ => panic!("Expected Forward command"),
6321        }
6322    }
6323
6324    #[test]
6325    fn test_parse_port_spec_single() {
6326        let (local, guest) = parse_port_spec("3000").unwrap();
6327        assert_eq!(local, 3000);
6328        assert_eq!(guest, 3000);
6329    }
6330
6331    #[test]
6332    fn test_parse_port_spec_mapping() {
6333        let (local, guest) = parse_port_spec("8080:3000").unwrap();
6334        assert_eq!(local, 8080);
6335        assert_eq!(guest, 3000);
6336    }
6337
6338    #[test]
6339    fn test_parse_port_spec_invalid() {
6340        assert!(parse_port_spec("abc").is_err());
6341        assert!(parse_port_spec("abc:3000").is_err());
6342        assert!(parse_port_spec("3000:abc").is_err());
6343        assert!(parse_port_spec("99999").is_err());
6344    }
6345
6346    // -------------------------------------------------------------------------
6347    // Alias tests (Phase 4)
6348    // -------------------------------------------------------------------------
6349
6350    #[test]
6351    fn test_ls_alias_for_ps() {
6352        let cli = Cli::try_parse_from(["mvmctl", "ls"]).unwrap();
6353        assert!(matches!(cli.command, Commands::Ps { .. }));
6354    }
6355
6356    #[test]
6357    fn test_ps_command() {
6358        let cli = Cli::try_parse_from(["mvmctl", "ps"]).unwrap();
6359        assert!(matches!(cli.command, Commands::Ps { .. }));
6360    }
6361
6362    #[test]
6363    fn test_start_alias_for_run() {
6364        // 'start' is already an alias on Run — verify it still works
6365        assert!(Cli::try_parse_from(["mvmctl", "start", "--flake", "."]).is_ok());
6366    }
6367
6368    // -------------------------------------------------------------------------
6369    // Metrics tests (Phase 1)
6370    // -------------------------------------------------------------------------
6371
6372    #[test]
6373    fn test_metrics_command_parses() {
6374        let cli = Cli::try_parse_from(["mvmctl", "metrics"]).unwrap();
6375        assert!(matches!(cli.command, Commands::Metrics { json: false }));
6376    }
6377
6378    #[test]
6379    fn test_metrics_json_flag_parses() {
6380        let cli = Cli::try_parse_from(["mvmctl", "metrics", "--json"]).unwrap();
6381        assert!(matches!(cli.command, Commands::Metrics { json: true }));
6382    }
6383
6384    #[test]
6385    fn test_metrics_snapshot_serializes_to_json() {
6386        let snap = mvm_core::observability::metrics::global().snapshot();
6387        let json = serde_json::to_string(&snap).expect("snapshot must serialize");
6388        assert!(json.contains("requests_total"));
6389        assert!(json.contains("instances_created"));
6390    }
6391
6392    #[test]
6393    fn test_prometheus_exposition_has_expected_metrics() {
6394        let prom = mvm_core::observability::metrics::global().prometheus_exposition();
6395        assert!(prom.contains("mvm_requests_total"));
6396        assert!(prom.contains("mvm_instances_created_total"));
6397        assert!(prom.contains("# HELP"));
6398        assert!(prom.contains("# TYPE"));
6399    }
6400
6401    // ---- Config command tests ----
6402
6403    #[test]
6404    fn test_config_show_parses() {
6405        let cli = Cli::try_parse_from(["mvmctl", "config", "show"]).unwrap();
6406        assert!(matches!(
6407            cli.command,
6408            Commands::Config {
6409                action: ConfigAction::Show
6410            }
6411        ));
6412    }
6413
6414    #[test]
6415    fn test_config_set_parses() {
6416        let cli = Cli::try_parse_from(["mvmctl", "config", "set", "lima_cpus", "4"]).unwrap();
6417        match cli.command {
6418            Commands::Config {
6419                action: ConfigAction::Set { key, value },
6420            } => {
6421                assert_eq!(key, "lima_cpus");
6422                assert_eq!(value, "4");
6423            }
6424            _ => panic!("Expected Config Set command"),
6425        }
6426    }
6427
6428    #[test]
6429    fn test_config_show_output_contains_lima_cpus() {
6430        let tmp = tempfile::tempdir().unwrap();
6431        let cfg = mvm_core::user_config::MvmConfig::default();
6432        mvm_core::user_config::save(&cfg, Some(tmp.path())).unwrap();
6433        let loaded = mvm_core::user_config::load(Some(tmp.path()));
6434        let text = toml::to_string_pretty(&loaded).unwrap();
6435        assert!(text.contains("lima_cpus"));
6436    }
6437
6438    #[test]
6439    fn test_config_set_persists() {
6440        let tmp = tempfile::tempdir().unwrap();
6441        let mut cfg = mvm_core::user_config::load(Some(tmp.path()));
6442        mvm_core::user_config::set_key(&mut cfg, "lima_cpus", "4").unwrap();
6443        mvm_core::user_config::save(&cfg, Some(tmp.path())).unwrap();
6444        let reloaded = mvm_core::user_config::load(Some(tmp.path()));
6445        assert_eq!(reloaded.lima_cpus, 4);
6446    }
6447
6448    #[test]
6449    fn test_config_set_unknown_key_fails() {
6450        let mut cfg = mvm_core::user_config::MvmConfig::default();
6451        let err = mvm_core::user_config::set_key(&mut cfg, "nonexistent_key", "5").unwrap_err();
6452        assert!(err.to_string().contains("Unknown config key"));
6453    }
6454
6455    // ---- Uninstall command tests ----
6456
6457    #[test]
6458    fn test_uninstall_parses_defaults() {
6459        let cli = Cli::try_parse_from(["mvmctl", "uninstall", "--yes"]).unwrap();
6460        assert!(matches!(
6461            cli.command,
6462            Commands::Uninstall {
6463                yes: true,
6464                all: false,
6465                dry_run: false,
6466            }
6467        ));
6468    }
6469
6470    #[test]
6471    fn test_uninstall_dry_run_parses() {
6472        let cli = Cli::try_parse_from(["mvmctl", "uninstall", "--dry-run", "--yes"]).unwrap();
6473        assert!(matches!(
6474            cli.command,
6475            Commands::Uninstall {
6476                yes: true,
6477                all: false,
6478                dry_run: true,
6479            }
6480        ));
6481    }
6482
6483    #[test]
6484    fn test_uninstall_all_flag_parses() {
6485        let cli = Cli::try_parse_from(["mvmctl", "uninstall", "--all", "--yes"]).unwrap();
6486        assert!(matches!(
6487            cli.command,
6488            Commands::Uninstall {
6489                yes: true,
6490                all: true,
6491                dry_run: false,
6492            }
6493        ));
6494    }
6495
6496    // ---- Audit command tests ----
6497
6498    #[test]
6499    fn test_audit_tail_parses() {
6500        let cli = Cli::try_parse_from(["mvmctl", "audit", "tail"]).unwrap();
6501        assert!(matches!(
6502            cli.command,
6503            Commands::Audit {
6504                action: AuditCmd::Tail {
6505                    lines: 20,
6506                    follow: false,
6507                }
6508            }
6509        ));
6510    }
6511
6512    #[test]
6513    fn test_audit_tail_follow_parses() {
6514        let cli =
6515            Cli::try_parse_from(["mvmctl", "audit", "tail", "--follow", "--lines", "50"]).unwrap();
6516        assert!(matches!(
6517            cli.command,
6518            Commands::Audit {
6519                action: AuditCmd::Tail {
6520                    lines: 50,
6521                    follow: true,
6522                }
6523            }
6524        ));
6525    }
6526
6527    #[test]
6528    fn test_audit_tail_no_log_prints_message() {
6529        // When no audit log exists, cmd_audit_tail should succeed with a
6530        // helpful message rather than an error.
6531        let tmp = tempfile::tempdir().unwrap();
6532        let nonexistent = tmp.path().join("audit.jsonl");
6533        // Path doesn't exist — simulate the early-return path.
6534        assert!(!nonexistent.exists());
6535    }
6536
6537    // ---- Clap value parser tests ----
6538
6539    #[test]
6540    fn test_clap_port_spec_valid() {
6541        assert!(clap_port_spec("8080").is_ok());
6542        assert!(clap_port_spec("8080:80").is_ok());
6543        assert!(clap_port_spec("443:443").is_ok());
6544        assert!(clap_port_spec("0:0").is_ok());
6545    }
6546
6547    #[test]
6548    fn test_clap_port_spec_invalid() {
6549        assert!(clap_port_spec("").is_err());
6550        assert!(clap_port_spec("abc").is_err());
6551        assert!(clap_port_spec("8080:abc").is_err());
6552        assert!(clap_port_spec("abc:80").is_err());
6553        assert!(clap_port_spec("99999").is_err()); // out of u16 range
6554    }
6555
6556    #[test]
6557    fn test_clap_volume_spec_valid() {
6558        assert!(clap_volume_spec("/host:/guest").is_ok());
6559        assert!(clap_volume_spec("/host/path:/guest/mount").is_ok());
6560        assert!(clap_volume_spec("/host:/guest:1G").is_ok());
6561        assert!(clap_volume_spec("./local:/app").is_ok());
6562    }
6563
6564    #[test]
6565    fn test_clap_volume_spec_invalid() {
6566        assert!(clap_volume_spec("").is_err());
6567        assert!(clap_volume_spec("nocolon").is_err());
6568        assert!(clap_volume_spec(":/guest").is_err()); // empty host
6569    }
6570
6571    #[test]
6572    fn test_clap_vm_name_valid() {
6573        assert!(clap_vm_name("my-vm").is_ok());
6574        assert!(clap_vm_name("vm1").is_ok());
6575        assert!(clap_vm_name("a").is_ok());
6576    }
6577
6578    #[test]
6579    fn test_clap_vm_name_invalid() {
6580        assert!(clap_vm_name("").is_err());
6581        assert!(clap_vm_name("UPPER").is_err());
6582        assert!(clap_vm_name("has space").is_err());
6583        assert!(clap_vm_name("-leading").is_err());
6584    }
6585
6586    #[test]
6587    fn test_clap_flake_ref_valid() {
6588        assert!(clap_flake_ref(".").is_ok());
6589        assert!(clap_flake_ref("github:org/repo").is_ok());
6590        assert!(clap_flake_ref("/absolute/path").is_ok());
6591    }
6592
6593    #[test]
6594    fn test_clap_flake_ref_invalid() {
6595        assert!(clap_flake_ref("").is_err());
6596        assert!(clap_flake_ref(". ; rm -rf /").is_err());
6597        assert!(clap_flake_ref("$(evil)").is_err());
6598    }
6599
6600    #[test]
6601    fn test_run_rejects_invalid_vm_name_at_parse_time() {
6602        // Clap should reject bad --name values before any command runs.
6603        let result = Cli::try_parse_from(["mvmctl", "run", "--flake", ".", "--name", "INVALID"]);
6604        assert!(
6605            result.is_err(),
6606            "uppercase VM name should fail at parse time"
6607        );
6608    }
6609
6610    #[test]
6611    fn test_run_rejects_invalid_flake_at_parse_time() {
6612        let result =
6613            Cli::try_parse_from(["mvmctl", "run", "--flake", ". ; rm -rf /", "--name", "vm1"]);
6614        assert!(
6615            result.is_err(),
6616            "shell-injection flake ref should fail at parse time"
6617        );
6618    }
6619
6620    #[test]
6621    fn test_run_rejects_invalid_port_at_parse_time() {
6622        let result = Cli::try_parse_from(["mvmctl", "run", "--flake", ".", "--port", "notaport"]);
6623        assert!(result.is_err(), "invalid port should fail at parse time");
6624    }
6625
6626    // ---- Config defaults wired into cmd_run ----
6627
6628    #[test]
6629    fn test_run_uses_config_default_cpus() {
6630        // When --cpus is omitted, the config default should be applied.
6631        let cfg = mvm_core::user_config::MvmConfig {
6632            default_cpus: 4,
6633            ..mvm_core::user_config::MvmConfig::default()
6634        };
6635
6636        // Simulate the resolution logic from the Commands::Up dispatch.
6637        let cli_cpus: Option<u32> = None;
6638        let effective = cli_cpus.or(Some(cfg.default_cpus));
6639        assert_eq!(effective, Some(4));
6640    }
6641
6642    #[test]
6643    fn test_run_cli_flag_overrides_config_cpus() {
6644        // When --cpus is provided, it takes precedence over config.
6645        let cfg = mvm_core::user_config::MvmConfig {
6646            default_cpus: 4,
6647            ..mvm_core::user_config::MvmConfig::default()
6648        };
6649
6650        let cli_cpus: Option<u32> = Some(8);
6651        let effective = cli_cpus.or(Some(cfg.default_cpus));
6652        assert_eq!(effective, Some(8));
6653    }
6654
6655    #[test]
6656    fn test_run_uses_config_default_memory() {
6657        let cfg = mvm_core::user_config::MvmConfig {
6658            default_memory_mib: 2048,
6659            ..mvm_core::user_config::MvmConfig::default()
6660        };
6661
6662        let cli_memory: Option<u32> = None;
6663        let effective = cli_memory.or(Some(cfg.default_memory_mib));
6664        assert_eq!(effective, Some(2048));
6665    }
6666
6667    #[test]
6668    fn test_run_cli_flag_overrides_config_memory() {
6669        let cfg = mvm_core::user_config::MvmConfig {
6670            default_memory_mib: 2048,
6671            ..mvm_core::user_config::MvmConfig::default()
6672        };
6673
6674        let cli_memory: Option<u32> = Some(512);
6675        let effective = cli_memory.or(Some(cfg.default_memory_mib));
6676        assert_eq!(effective, Some(512));
6677    }
6678
6679    #[test]
6680    fn test_resolve_network_policy_default() {
6681        let policy = resolve_network_policy(None, &[]).unwrap();
6682        assert!(policy.is_unrestricted());
6683    }
6684
6685    #[test]
6686    fn test_resolve_network_policy_preset() {
6687        let policy = resolve_network_policy(Some("dev"), &[]).unwrap();
6688        assert!(!policy.is_unrestricted());
6689        let rules = policy.resolve_rules().unwrap();
6690        assert!(rules.iter().any(|r| r.host == "github.com"));
6691    }
6692
6693    #[test]
6694    fn test_resolve_network_policy_allow_list() {
6695        let allow = vec![
6696            "github.com:443".to_string(),
6697            "api.openai.com:443".to_string(),
6698        ];
6699        let policy = resolve_network_policy(None, &allow).unwrap();
6700        let rules = policy.resolve_rules().unwrap();
6701        assert_eq!(rules.len(), 2);
6702    }
6703
6704    #[test]
6705    fn test_resolve_network_policy_mutual_exclusion() {
6706        let allow = vec!["github.com:443".to_string()];
6707        let result = resolve_network_policy(Some("dev"), &allow);
6708        assert!(result.is_err());
6709    }
6710
6711    #[test]
6712    fn test_resolve_network_policy_invalid_preset() {
6713        let result = resolve_network_policy(Some("bogus"), &[]);
6714        assert!(result.is_err());
6715    }
6716
6717    #[test]
6718    fn test_resolve_network_policy_invalid_allow_entry() {
6719        let allow = vec!["not-a-host-port".to_string()];
6720        let result = resolve_network_policy(None, &allow);
6721        assert!(result.is_err());
6722    }
6723
6724    // --- Network CLI tests ---
6725
6726    #[test]
6727    fn test_network_list_help() {
6728        let cli = Cli::try_parse_from(["mvmctl", "network", "list"]);
6729        assert!(cli.is_ok());
6730    }
6731
6732    #[test]
6733    fn test_network_create_help() {
6734        let cli = Cli::try_parse_from(["mvmctl", "network", "create", "mynet"]);
6735        assert!(cli.is_ok());
6736    }
6737
6738    #[test]
6739    fn test_network_inspect_help() {
6740        let cli = Cli::try_parse_from(["mvmctl", "network", "inspect", "mynet"]);
6741        assert!(cli.is_ok());
6742    }
6743
6744    #[test]
6745    fn test_network_remove_help() {
6746        let cli = Cli::try_parse_from(["mvmctl", "network", "rm", "mynet"]);
6747        assert!(cli.is_ok());
6748    }
6749
6750    // --- Image CLI tests ---
6751
6752    #[test]
6753    fn test_image_list_help() {
6754        let cli = Cli::try_parse_from(["mvmctl", "image", "list"]);
6755        assert!(cli.is_ok());
6756    }
6757
6758    #[test]
6759    fn test_image_search_help() {
6760        let cli = Cli::try_parse_from(["mvmctl", "image", "search", "http"]);
6761        assert!(cli.is_ok());
6762    }
6763
6764    #[test]
6765    fn test_image_fetch_help() {
6766        let cli = Cli::try_parse_from(["mvmctl", "image", "fetch", "minimal"]);
6767        assert!(cli.is_ok());
6768    }
6769
6770    #[test]
6771    fn test_image_info_help() {
6772        let cli = Cli::try_parse_from(["mvmctl", "image", "info", "postgres"]);
6773        assert!(cli.is_ok());
6774    }
6775
6776    // --- Console CLI tests ---
6777
6778    #[test]
6779    fn test_console_help() {
6780        let cli = Cli::try_parse_from(["mvmctl", "console", "myvm"]);
6781        assert!(cli.is_ok());
6782    }
6783
6784    #[test]
6785    fn test_console_with_command() {
6786        let cli = Cli::try_parse_from(["mvmctl", "console", "myvm", "--command", "ls"]);
6787        assert!(cli.is_ok());
6788        match cli.unwrap().command {
6789            Commands::Console { name, command } => {
6790                assert_eq!(name, "myvm");
6791                assert_eq!(command.as_deref(), Some("ls"));
6792            }
6793            _ => panic!("Expected Console command"),
6794        }
6795    }
6796
6797    // --- Exec CLI tests ---
6798
6799    #[test]
6800    fn exec_default_template_argv_only() {
6801        let cli = Cli::try_parse_from(["mvmctl", "exec", "--", "uname", "-a"]).expect("parse");
6802        match cli.command {
6803            Commands::Exec {
6804                template,
6805                cpus,
6806                memory,
6807                add_dir,
6808                env,
6809                timeout,
6810                launch_plan,
6811                argv,
6812            } => {
6813                assert!(template.is_none(), "template should default to None");
6814                assert_eq!(cpus, 2);
6815                assert_eq!(memory, "512M");
6816                assert!(add_dir.is_empty());
6817                assert!(env.is_empty());
6818                assert_eq!(timeout, 60);
6819                assert!(launch_plan.is_none(), "launch_plan should default to None");
6820                assert_eq!(argv, vec!["uname".to_string(), "-a".to_string()]);
6821            }
6822            _ => panic!("Expected Exec command"),
6823        }
6824    }
6825
6826    #[test]
6827    fn exec_with_launch_plan_no_argv() {
6828        let cli =
6829            Cli::try_parse_from(["mvmctl", "exec", "--launch-plan", "./plan.json"]).expect("parse");
6830        match cli.command {
6831            Commands::Exec {
6832                launch_plan, argv, ..
6833            } => {
6834                assert_eq!(launch_plan.as_deref(), Some("./plan.json"));
6835                assert!(argv.is_empty());
6836            }
6837            _ => panic!("Expected Exec command"),
6838        }
6839    }
6840
6841    #[test]
6842    fn exec_launch_plan_conflicts_with_argv() {
6843        let cli = Cli::try_parse_from([
6844            "mvmctl",
6845            "exec",
6846            "--launch-plan",
6847            "./plan.json",
6848            "--",
6849            "echo",
6850            "hi",
6851        ]);
6852        assert!(
6853            cli.is_err(),
6854            "--launch-plan and trailing argv must be mutually exclusive"
6855        );
6856    }
6857
6858    #[test]
6859    fn exec_with_template_and_resources() {
6860        let cli = Cli::try_parse_from([
6861            "mvmctl",
6862            "exec",
6863            "--template",
6864            "my-tpl",
6865            "--cpus",
6866            "4",
6867            "--memory",
6868            "1G",
6869            "--",
6870            "/bin/true",
6871        ])
6872        .expect("parse");
6873        match cli.command {
6874            Commands::Exec {
6875                template,
6876                cpus,
6877                memory,
6878                argv,
6879                ..
6880            } => {
6881                assert_eq!(template.as_deref(), Some("my-tpl"));
6882                assert_eq!(cpus, 4);
6883                assert_eq!(memory, "1G");
6884                assert_eq!(argv, vec!["/bin/true".to_string()]);
6885            }
6886            _ => panic!("Expected Exec command"),
6887        }
6888    }
6889
6890    #[test]
6891    fn exec_with_add_dir_and_env() {
6892        let cli = Cli::try_parse_from([
6893            "mvmctl",
6894            "exec",
6895            "--add-dir",
6896            "/tmp:/work",
6897            "--add-dir",
6898            "/etc:/host-etc",
6899            "--env",
6900            "FOO=bar",
6901            "--env",
6902            "BAZ=qux",
6903            "--",
6904            "ls",
6905            "/work",
6906        ])
6907        .expect("parse");
6908        match cli.command {
6909            Commands::Exec {
6910                add_dir, env, argv, ..
6911            } => {
6912                assert_eq!(
6913                    add_dir,
6914                    vec!["/tmp:/work".to_string(), "/etc:/host-etc".to_string()]
6915                );
6916                assert_eq!(env, vec!["FOO=bar".to_string(), "BAZ=qux".to_string()]);
6917                assert_eq!(argv, vec!["ls".to_string(), "/work".to_string()]);
6918            }
6919            _ => panic!("Expected Exec command"),
6920        }
6921    }
6922
6923    #[test]
6924    fn exec_requires_argv() {
6925        // Without trailing argv, Clap should reject because `argv` is required.
6926        let cli = Cli::try_parse_from(["mvmctl", "exec"]);
6927        assert!(cli.is_err());
6928    }
6929
6930    // --- Init CLI tests ---
6931
6932    #[test]
6933    fn test_init_defaults() {
6934        let cli = Cli::try_parse_from(["mvmctl", "init"]).unwrap();
6935        match cli.command {
6936            Commands::Init {
6937                non_interactive,
6938                lima_cpus,
6939                lima_mem,
6940            } => {
6941                assert!(!non_interactive);
6942                assert_eq!(lima_cpus, 8);
6943                assert_eq!(lima_mem, 16);
6944            }
6945            _ => panic!("Expected Init command"),
6946        }
6947    }
6948
6949    #[test]
6950    fn test_init_non_interactive() {
6951        let cli = Cli::try_parse_from(["mvmctl", "init", "--non-interactive", "--lima-cpus", "4"])
6952            .unwrap();
6953        match cli.command {
6954            Commands::Init {
6955                non_interactive,
6956                lima_cpus,
6957                ..
6958            } => {
6959                assert!(non_interactive);
6960                assert_eq!(lima_cpus, 4);
6961            }
6962            _ => panic!("Expected Init command"),
6963        }
6964    }
6965
6966    // --- Security CLI tests ---
6967
6968    #[test]
6969    fn test_security_status_help() {
6970        let cli = Cli::try_parse_from(["mvmctl", "security", "status"]);
6971        assert!(cli.is_ok());
6972    }
6973
6974    #[test]
6975    fn test_security_status_json() {
6976        let cli = Cli::try_parse_from(["mvmctl", "security", "status", "--json"]).unwrap();
6977        match cli.command {
6978            Commands::Security {
6979                action: SecurityCmd::Status { json },
6980            } => {
6981                assert!(json);
6982            }
6983            _ => panic!("Expected Security Status command"),
6984        }
6985    }
6986
6987    // --- Cache CLI tests ---
6988
6989    #[test]
6990    fn test_cache_info() {
6991        let cli = Cli::try_parse_from(["mvmctl", "cache", "info"]);
6992        assert!(cli.is_ok());
6993    }
6994
6995    #[test]
6996    fn test_cache_prune() {
6997        let cli = Cli::try_parse_from(["mvmctl", "cache", "prune"]);
6998        assert!(cli.is_ok());
6999    }
7000
7001    #[test]
7002    fn test_cache_prune_dry_run() {
7003        let cli = Cli::try_parse_from(["mvmctl", "cache", "prune", "--dry-run"]).unwrap();
7004        match cli.command {
7005            Commands::Cache {
7006                action: CacheCmd::Prune { dry_run },
7007            } => {
7008                assert!(dry_run);
7009            }
7010            _ => panic!("Expected Cache Prune command"),
7011        }
7012    }
7013
7014    // --- Up --network flag tests ---
7015
7016    #[test]
7017    fn test_up_network_default() {
7018        let cli = Cli::try_parse_from(["mvmctl", "up", "--flake", "."]).unwrap();
7019        match cli.command {
7020            Commands::Up { network, .. } => {
7021                assert_eq!(network, "default");
7022            }
7023            _ => panic!("Expected Up command"),
7024        }
7025    }
7026
7027    #[test]
7028    fn test_up_network_custom() {
7029        let cli =
7030            Cli::try_parse_from(["mvmctl", "up", "--flake", ".", "--network", "isolated"]).unwrap();
7031        match cli.command {
7032            Commands::Up { network, .. } => {
7033                assert_eq!(network, "isolated");
7034            }
7035            _ => panic!("Expected Up command"),
7036        }
7037    }
7038
7039    #[test]
7040    fn test_template_init_defaults_to_no_preset_or_prompt() {
7041        let cli = Cli::try_parse_from(["mvmctl", "template", "init", "demo", "--local"]).unwrap();
7042        match cli.command {
7043            Commands::Template {
7044                action: TemplateCmd::Init { preset, prompt, .. },
7045            } => {
7046                assert!(preset.is_none(), "preset should be None when omitted");
7047                assert!(prompt.is_none(), "prompt should be None when omitted");
7048            }
7049            _ => panic!("Expected Template Init command"),
7050        }
7051    }
7052
7053    #[test]
7054    fn test_template_init_parses_prompt_flag() {
7055        let cli = Cli::try_parse_from([
7056            "mvmctl",
7057            "template",
7058            "init",
7059            "demo",
7060            "--local",
7061            "--prompt",
7062            "python worker that polls an API",
7063        ])
7064        .unwrap();
7065        match cli.command {
7066            Commands::Template {
7067                action: TemplateCmd::Init { prompt, preset, .. },
7068            } => {
7069                assert_eq!(prompt.as_deref(), Some("python worker that polls an API"));
7070                assert!(preset.is_none(), "preset should remain None when omitted");
7071            }
7072            _ => panic!("Expected Template Init command"),
7073        }
7074    }
7075
7076    // --- Apple Container dev tests ---
7077
7078    #[test]
7079    fn test_dev_up_with_lima_flag() {
7080        let cli = Cli::try_parse_from(["mvmctl", "dev", "up", "--lima"]).unwrap();
7081        match cli.command {
7082            Commands::Dev {
7083                action: Some(DevCmd::Up { lima, .. }),
7084            } => {
7085                assert!(lima);
7086            }
7087            _ => panic!("Expected Dev Up command"),
7088        }
7089    }
7090
7091    #[test]
7092    fn test_dev_down_parses() {
7093        let cli = Cli::try_parse_from(["mvmctl", "dev", "down"]);
7094        assert!(cli.is_ok());
7095    }
7096
7097    #[test]
7098    fn test_dev_shell_parses() {
7099        let cli = Cli::try_parse_from(["mvmctl", "dev", "shell"]);
7100        assert!(cli.is_ok());
7101    }
7102
7103    #[test]
7104    fn test_dev_status_parses() {
7105        let cli = Cli::try_parse_from(["mvmctl", "dev", "status"]);
7106        assert!(cli.is_ok());
7107    }
7108
7109    #[test]
7110    fn test_is_apple_container_dev_running_returns_bool() {
7111        // Just verify it doesn't panic — actual result depends on platform
7112        let _ = is_apple_container_dev_running();
7113    }
7114}