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