Skip to main content

opencode_cloud/
lib.rs

1//! opencode-cloud CLI - Manage your opencode cloud service
2//!
3//! This module contains the shared CLI implementation used by all binaries.
4
5mod cli_platform;
6mod commands;
7mod constants;
8mod output;
9mod passwords;
10mod sandbox_profile;
11pub mod wizard;
12
13use crate::commands::runtime_shared::drift::{
14    RuntimeAssetDrift, detect_runtime_asset_drift, stale_container_warning_lines,
15};
16use anyhow::{Result, anyhow};
17use clap::{Parser, Subcommand, ValueEnum};
18use console::style;
19use dialoguer::Confirm;
20use opencode_cloud_core::{
21    DockerClient, InstanceLock, SingletonError, config, get_version, load_config_or_default,
22    load_hosts, save_config,
23};
24use std::path::Path;
25
26/// Manage your opencode cloud service
27#[derive(Parser)]
28#[command(name = "opencode-cloud")]
29#[command(version = env!("CARGO_PKG_VERSION"))]
30#[command(about = "Manage your opencode cloud service", long_about = None)]
31#[command(after_help = get_banner())]
32struct Cli {
33    #[command(subcommand)]
34    command: Option<Commands>,
35
36    /// Increase verbosity level
37    #[arg(short, long, global = true, action = clap::ArgAction::Count)]
38    verbose: u8,
39
40    /// Suppress non-error output
41    #[arg(short, long, global = true)]
42    quiet: bool,
43
44    /// Disable colored output
45    #[arg(long, global = true)]
46    no_color: bool,
47
48    /// Target remote host (overrides default_host)
49    #[arg(long, global = true, conflicts_with = "local")]
50    remote_host: Option<String>,
51
52    /// Force local Docker (ignores default_host)
53    #[arg(long, global = true, conflicts_with = "remote_host")]
54    local: bool,
55
56    /// Runtime mode (auto-detect container vs host)
57    #[arg(long, global = true, value_enum)]
58    runtime: Option<RuntimeChoice>,
59
60    /// Optional sandbox instance profile for worktree-isolated resources
61    #[arg(long, global = true, value_name = "NAME|auto")]
62    sandbox_instance: Option<String>,
63}
64
65#[derive(Subcommand)]
66enum Commands {
67    /// Start the opencode service
68    Start(commands::StartArgs),
69    /// Stop the opencode service
70    Stop(commands::StopArgs),
71    /// Restart the opencode service
72    Restart(commands::RestartArgs),
73    /// Show service status
74    Status(commands::StatusArgs),
75    /// View service logs
76    Logs(commands::LogsArgs),
77    /// Register service to start on boot/login
78    Install(commands::InstallArgs),
79    /// Remove service registration
80    Uninstall(commands::UninstallArgs),
81    /// Manage configuration
82    Config(commands::ConfigArgs),
83    /// Run interactive setup wizard
84    Setup(commands::SetupArgs),
85    /// Manage container users
86    User(commands::UserArgs),
87    /// Manage bind mounts
88    Mount(commands::MountArgs),
89    /// Reset containers, mounts, and host data
90    Reset(commands::ResetArgs),
91    /// Update to the latest version or rollback (interactive when no subcommand is provided)
92    Update(commands::UpdateArgs),
93    /// Open Cockpit web console
94    #[command(hide = true)]
95    Cockpit(commands::CockpitArgs),
96    /// Manage remote hosts
97    Host(commands::HostArgs),
98}
99
100#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
101enum RuntimeChoice {
102    Auto,
103    Host,
104    Container,
105}
106
107#[derive(Clone, Copy, Debug, PartialEq, Eq)]
108enum RuntimeMode {
109    Host,
110    Container,
111}
112
113#[derive(Clone, Copy, Debug, PartialEq, Eq)]
114enum CommandKind {
115    None,
116    Status,
117    Other,
118}
119
120/// Get the ASCII banner for help display
121fn get_banner() -> &'static str {
122    r#"
123  ___  _ __   ___ _ __   ___ ___   __| | ___
124 / _ \| '_ \ / _ \ '_ \ / __/ _ \ / _` |/ _ \
125| (_) | |_) |  __/ | | | (_| (_) | (_| |  __/
126 \___/| .__/ \___|_| |_|\___\___/ \__,_|\___|
127      |_|                            cloud
128"#
129}
130
131/// Resolve the target host name based on flags and hosts.json
132///
133/// Resolution order:
134/// 1. --local (force local Docker)
135/// 2. --remote-host flag (explicit)
136/// 3. default_host from hosts.json
137/// 4. Local Docker (no host_name)
138pub fn resolve_target_host(remote_host: Option<&str>, force_local: bool) -> Option<String> {
139    if force_local {
140        return None;
141    }
142
143    if let Some(name) = remote_host {
144        return Some(name.to_string());
145    }
146
147    let hosts = load_hosts().unwrap_or_default();
148    hosts.default_host.clone()
149}
150
151/// Resolve which Docker client to use based on an explicit target host name
152///
153/// Returns (DockerClient, Option<host_name>) where host_name is Some for remote connections.
154pub async fn resolve_docker_client(
155    maybe_host: Option<&str>,
156) -> anyhow::Result<(DockerClient, Option<String>)> {
157    let hosts = load_hosts().unwrap_or_default();
158
159    // Determine target host
160    let target_host = maybe_host.map(String::from);
161
162    match target_host {
163        Some(name) => {
164            // Remote host requested
165            let host_config = hosts.get_host(&name).ok_or_else(|| {
166                anyhow::anyhow!(
167                    "Host '{name}' not found. Run 'occ host list' to see available hosts."
168                )
169            })?;
170
171            let client = DockerClient::connect_remote(host_config, &name).await?;
172            Ok((client, Some(name)))
173        }
174        None => {
175            // Local Docker
176            let client = DockerClient::new()?;
177            Ok((client, None))
178        }
179    }
180}
181
182/// Format a message with optional host prefix
183///
184/// For remote hosts: "[prod-1] Starting container..."
185/// For local: "Starting container..."
186pub fn format_host_message(host_name: Option<&str>, message: &str) -> String {
187    match host_name {
188        Some(name) => format!("[{}] {}", style(name).cyan(), message),
189        None => message.to_string(),
190    }
191}
192
193fn container_runtime_from_markers(is_container: bool, is_opencode_image: bool) -> bool {
194    is_container && is_opencode_image
195}
196
197fn detect_container_runtime() -> bool {
198    let is_container =
199        Path::new("/.dockerenv").exists() || Path::new("/run/.containerenv").exists();
200    let is_opencode_image = Path::new("/etc/opencode-cloud-version").exists()
201        || Path::new("/opt/opencode/COMMIT").exists();
202    container_runtime_from_markers(is_container, is_opencode_image)
203}
204
205fn runtime_choice_from_env() -> Option<RuntimeChoice> {
206    let value = std::env::var("OPENCODE_RUNTIME").ok()?;
207    match value.to_lowercase().as_str() {
208        "auto" => Some(RuntimeChoice::Auto),
209        "host" => Some(RuntimeChoice::Host),
210        "container" => Some(RuntimeChoice::Container),
211        _ => None,
212    }
213}
214
215fn resolve_runtime(choice: RuntimeChoice) -> (RuntimeMode, bool) {
216    let auto_container = detect_container_runtime();
217    resolve_runtime_with_autodetect(choice, auto_container)
218}
219
220fn resolve_runtime_with_autodetect(
221    choice: RuntimeChoice,
222    auto_container: bool,
223) -> (RuntimeMode, bool) {
224    match choice {
225        RuntimeChoice::Host => (RuntimeMode::Host, false),
226        RuntimeChoice::Container => (RuntimeMode::Container, false),
227        RuntimeChoice::Auto => {
228            let mode = if auto_container {
229                RuntimeMode::Container
230            } else {
231                RuntimeMode::Host
232            };
233            (mode, auto_container)
234        }
235    }
236}
237
238fn container_mode_unsupported_error() -> anyhow::Error {
239    anyhow!(
240        "Command not supported in container runtime.\n\
241Supported commands:\n  occ status\n  occ logs\n  occ user\n  occ update opencode\n\
242To force host runtime:\n  occ --runtime host <command>\n  OPENCODE_RUNTIME=host occ <command>"
243    )
244}
245
246fn command_kind(command: Option<&Commands>) -> CommandKind {
247    match command {
248        None => CommandKind::None,
249        Some(Commands::Status(_)) => CommandKind::Status,
250        Some(_) => CommandKind::Other,
251    }
252}
253
254fn should_run_runtime_asset_preflight(
255    kind: CommandKind,
256    target_host: Option<&str>,
257    quiet: bool,
258) -> bool {
259    if quiet || target_host.is_some() {
260        return false;
261    }
262    matches!(kind, CommandKind::Other)
263}
264
265fn run_container_mode(cli: &Cli) -> Result<()> {
266    let rt = tokio::runtime::Runtime::new()?;
267
268    match cli.command {
269        Some(Commands::Status(ref args)) => rt.block_on(commands::container::cmd_status_container(
270            args,
271            cli.quiet,
272            cli.verbose,
273        )),
274        Some(Commands::Logs(ref args)) => {
275            rt.block_on(commands::container::cmd_logs_container(args, cli.quiet))
276        }
277        Some(Commands::User(ref args)) => rt.block_on(commands::container::cmd_user_container(
278            args,
279            cli.quiet,
280            cli.verbose,
281        )),
282        Some(Commands::Update(ref args)) => rt.block_on(commands::container::cmd_update_container(
283            args,
284            cli.quiet,
285            cli.verbose,
286        )),
287        Some(_) => Err(container_mode_unsupported_error()),
288        None => {
289            let status_args = commands::StatusArgs {};
290            rt.block_on(commands::container::cmd_status_container(
291                &status_args,
292                cli.quiet,
293                cli.verbose,
294            ))
295        }
296    }
297}
298
299pub fn run() -> Result<()> {
300    // Initialize tracing
301    tracing_subscriber::fmt::init();
302
303    let cli = Cli::parse();
304
305    // Configure color output
306    if cli.no_color {
307        console::set_colors_enabled(false);
308    }
309
310    let sandbox_profile =
311        sandbox_profile::resolve_sandbox_profile(cli.sandbox_instance.as_deref())?;
312    sandbox_profile::apply_active_profile_env(&sandbox_profile);
313    if cli.verbose > 0
314        && let Some(instance) = sandbox_profile.instance_id.as_deref()
315    {
316        eprintln!(
317            "{} Using sandbox instance profile: {}",
318            style("[info]").cyan(),
319            style(instance).cyan()
320        );
321    }
322
323    eprintln!(
324        "{} This tool is still a work in progress and is rapidly evolving. Expect frequent updates and breaking changes. Follow updates at https://github.com/pRizz/opencode-cloud. Stability will be announced at some point. Use with caution.",
325        style("Warning:").yellow().bold()
326    );
327    eprintln!();
328
329    let runtime_choice = cli
330        .runtime
331        .or_else(runtime_choice_from_env)
332        .unwrap_or(RuntimeChoice::Auto);
333    let (runtime_mode, auto_container) = resolve_runtime(runtime_choice);
334
335    if runtime_mode == RuntimeMode::Container {
336        if cli.remote_host.is_some() || cli.local {
337            return Err(anyhow!(
338                "Remote and local Docker flags are not supported in container runtime.\n\
339Use host mode instead:\n  occ --runtime host <command>"
340            ));
341        }
342
343        if auto_container && runtime_choice == RuntimeChoice::Auto && !cli.quiet {
344            eprintln!(
345                "{} Detected opencode container; using container runtime. Override with {} or {}.",
346                style("Info:").cyan(),
347                style("--runtime host").green(),
348                style("OPENCODE_RUNTIME=host").green()
349            );
350            eprintln!();
351        }
352
353        return run_container_mode(&cli);
354    }
355
356    let config_path = config::paths::get_config_path()
357        .ok_or_else(|| anyhow!("Could not determine config path"))?;
358    let config_exists = config_path.exists();
359
360    let skip_wizard = matches!(
361        cli.command,
362        Some(Commands::Setup(ref args)) if args.bootstrap || args.yes
363    );
364
365    if !config_exists && !skip_wizard {
366        eprintln!(
367            "{} First-time setup required. Running wizard...",
368            style("Note:").cyan()
369        );
370        eprintln!();
371        let rt = tokio::runtime::Runtime::new()?;
372        let new_config = rt.block_on(wizard::run_wizard(None))?;
373        save_config(&new_config)?;
374        eprintln!();
375        eprintln!(
376            "{} Setup complete! Run your command again, or use 'occ start' to begin.",
377            style("Success:").green().bold()
378        );
379        return Ok(());
380    }
381
382    // Load config
383    let config = match load_config_or_default() {
384        Ok(config) => {
385            // If config was just created, inform the user
386            if cli.verbose > 0 {
387                eprintln!(
388                    "{} Config loaded from: {}",
389                    style("[info]").cyan(),
390                    config_path.display()
391                );
392            }
393            config
394        }
395        Err(e) => {
396            // Display rich error for invalid config
397            eprintln!("{} Configuration error", style("Error:").red().bold());
398            eprintln!();
399            eprintln!("  {e}");
400            eprintln!();
401            eprintln!("  Config file: {}", style(config_path.display()).yellow());
402            eprintln!();
403            eprintln!(
404                "  {} Check the config file for syntax errors or unknown fields.",
405                style("Tip:").cyan()
406            );
407            eprintln!(
408                "  {} See schemas/config.example.jsonc for valid configuration.",
409                style("Tip:").cyan()
410            );
411            std::process::exit(1);
412        }
413    };
414
415    // Show verbose info if requested
416    if cli.verbose > 0 {
417        let data_dir = config::paths::get_data_dir()
418            .map(|p| p.display().to_string())
419            .unwrap_or_else(|| "unknown".to_string());
420        eprintln!(
421            "{} Config: {}",
422            style("[info]").cyan(),
423            config_path.display()
424        );
425        eprintln!("{} Data: {}", style("[info]").cyan(), data_dir);
426    }
427
428    // Store target host for command handlers
429    let target_host = resolve_target_host(cli.remote_host.as_deref(), cli.local);
430    let dispatch_kind = command_kind(cli.command.as_ref());
431
432    if should_run_runtime_asset_preflight(dispatch_kind, target_host.as_deref(), cli.quiet) {
433        match tokio::runtime::Runtime::new() {
434            Ok(rt) => {
435                if let Err(err) = rt.block_on(maybe_print_runtime_asset_preflight(
436                    target_host.as_deref(),
437                    cli.verbose,
438                )) && cli.verbose > 0
439                {
440                    eprintln!(
441                        "{} Runtime drift preflight failed: {err}",
442                        style("[warn]").yellow()
443                    );
444                }
445            }
446            Err(err) => {
447                if cli.verbose > 0 {
448                    eprintln!(
449                        "{} Failed to initialize runtime drift preflight: {err}",
450                        style("[warn]").yellow()
451                    );
452                }
453            }
454        }
455    }
456
457    match cli.command {
458        Some(Commands::Start(args)) => {
459            let rt = tokio::runtime::Runtime::new()?;
460            rt.block_on(commands::cmd_start(
461                &args,
462                target_host.as_deref(),
463                cli.quiet,
464                cli.verbose,
465            ))
466        }
467        Some(Commands::Stop(args)) => {
468            let rt = tokio::runtime::Runtime::new()?;
469            rt.block_on(commands::cmd_stop(&args, target_host.as_deref(), cli.quiet))
470        }
471        Some(Commands::Restart(args)) => {
472            let rt = tokio::runtime::Runtime::new()?;
473            rt.block_on(commands::cmd_restart(
474                &args,
475                target_host.as_deref(),
476                cli.quiet,
477                cli.verbose,
478            ))
479        }
480        Some(Commands::Status(args)) => {
481            let rt = tokio::runtime::Runtime::new()?;
482            rt.block_on(commands::cmd_status(
483                &args,
484                target_host.as_deref(),
485                cli.quiet,
486                cli.verbose,
487            ))
488        }
489        Some(Commands::Logs(args)) => {
490            let rt = tokio::runtime::Runtime::new()?;
491            rt.block_on(commands::cmd_logs(&args, target_host.as_deref(), cli.quiet))
492        }
493        Some(Commands::Install(args)) => {
494            let rt = tokio::runtime::Runtime::new()?;
495            rt.block_on(commands::cmd_install(&args, cli.quiet, cli.verbose))
496        }
497        Some(Commands::Uninstall(args)) => {
498            let rt = tokio::runtime::Runtime::new()?;
499            rt.block_on(commands::cmd_uninstall(&args, cli.quiet, cli.verbose))
500        }
501        Some(Commands::Config(cmd)) => commands::cmd_config(cmd, &config, cli.quiet),
502        Some(Commands::Setup(args)) => {
503            let rt = tokio::runtime::Runtime::new()?;
504            rt.block_on(commands::cmd_setup(&args, cli.quiet))
505        }
506        Some(Commands::User(args)) => {
507            let rt = tokio::runtime::Runtime::new()?;
508            rt.block_on(commands::cmd_user(
509                &args,
510                target_host.as_deref(),
511                cli.quiet,
512                cli.verbose,
513            ))
514        }
515        Some(Commands::Mount(args)) => {
516            let rt = tokio::runtime::Runtime::new()?;
517            rt.block_on(commands::cmd_mount(
518                &args,
519                target_host.as_deref(),
520                cli.quiet,
521                cli.verbose,
522            ))
523        }
524        Some(Commands::Reset(args)) => {
525            let rt = tokio::runtime::Runtime::new()?;
526            rt.block_on(commands::cmd_reset(
527                &args,
528                target_host.as_deref(),
529                cli.quiet,
530                cli.verbose,
531            ))
532        }
533        Some(Commands::Update(args)) => {
534            let rt = tokio::runtime::Runtime::new()?;
535            rt.block_on(commands::cmd_update(
536                &args,
537                target_host.as_deref(),
538                cli.quiet,
539                cli.verbose,
540            ))
541        }
542        Some(Commands::Cockpit(args)) => {
543            let rt = tokio::runtime::Runtime::new()?;
544            rt.block_on(commands::cmd_cockpit(
545                &args,
546                target_host.as_deref(),
547                cli.quiet,
548            ))
549        }
550        Some(Commands::Host(args)) => {
551            let rt = tokio::runtime::Runtime::new()?;
552            rt.block_on(commands::cmd_host(&args, cli.quiet, cli.verbose))
553        }
554        None => {
555            let rt = tokio::runtime::Runtime::new()?;
556            rt.block_on(handle_no_command(
557                target_host.as_deref(),
558                cli.quiet,
559                cli.verbose,
560            ))
561        }
562    }
563}
564
565async fn handle_no_command(target_host: Option<&str>, quiet: bool, verbose: u8) -> Result<()> {
566    if quiet {
567        return Ok(());
568    }
569
570    let (client, host_name) = resolve_docker_client(target_host).await?;
571    client
572        .verify_connection()
573        .await
574        .map_err(|e| anyhow!("Docker connection error: {e}"))?;
575
576    let running = opencode_cloud_core::docker::container_is_running(
577        &client,
578        opencode_cloud_core::docker::CONTAINER_NAME,
579    )
580    .await
581    .map_err(|e| anyhow!("Docker error: {e}"))?;
582
583    if running {
584        let status_args = commands::StatusArgs {};
585        return commands::cmd_status(&status_args, host_name.as_deref(), quiet, verbose).await;
586    }
587
588    eprintln!("{} Service is not running.", style("Note:").yellow());
589
590    let confirmed = Confirm::new()
591        .with_prompt("Start the service now?")
592        .default(true)
593        .interact()?;
594
595    if confirmed {
596        let start_args = commands::StartArgs {
597            port: None,
598            open: false,
599            no_daemon: false,
600            pull_sandbox_image: false,
601            cached_rebuild_sandbox_image: false,
602            full_rebuild_sandbox_image: false,
603            local_opencode_submodule: false,
604            ignore_version: false,
605            no_update_check: false,
606            mounts: Vec::new(),
607            no_mounts: false,
608            yes: false,
609        };
610        commands::cmd_start(&start_args, host_name.as_deref(), quiet, verbose).await?;
611        let status_args = commands::StatusArgs {};
612        return commands::cmd_status(&status_args, host_name.as_deref(), quiet, verbose).await;
613    }
614
615    print_help_hint();
616    Ok(())
617}
618
619async fn maybe_print_runtime_asset_preflight(target_host: Option<&str>, verbose: u8) -> Result<()> {
620    let (client, host_name) = resolve_docker_client(target_host).await?;
621    if host_name.is_some() {
622        return Ok(());
623    }
624
625    let report = detect_runtime_asset_drift(&client).await;
626    print_runtime_asset_preflight_warning(&report, verbose);
627    Ok(())
628}
629
630fn print_runtime_asset_preflight_warning(report: &RuntimeAssetDrift, verbose: u8) {
631    if !report.drift_detected {
632        return;
633    }
634
635    eprintln!(
636        "{} {}",
637        style("Warning:").yellow().bold(),
638        style("Local container drift detected.").yellow()
639    );
640    for line in render_runtime_asset_preflight_lines(report, verbose) {
641        eprintln!("  {line}");
642    }
643    eprintln!();
644}
645
646fn render_runtime_asset_preflight_lines(report: &RuntimeAssetDrift, verbose: u8) -> Vec<String> {
647    let mut lines = stale_container_warning_lines(report);
648    if verbose > 0 {
649        for detail in &report.diagnostics {
650            lines.push(format!("diagnostic: {detail}"));
651        }
652    }
653    lines
654}
655
656fn print_help_hint() {
657    println!(
658        "{} {}",
659        style("opencode-cloud").cyan().bold(),
660        style(get_version()).dim()
661    );
662    println!();
663    println!("Run {} for available commands.", style("--help").green());
664}
665
666/// Acquire the singleton lock for service management commands
667///
668/// This should be called before any command that manages the service
669/// (start, stop, restart, status, etc.) to ensure only one instance runs.
670/// Config commands don't need the lock as they're read-only or file-based.
671#[allow(dead_code)]
672fn acquire_singleton_lock() -> Result<InstanceLock, SingletonError> {
673    let pid_path = config::paths::get_data_dir()
674        .ok_or(SingletonError::InvalidPath)?
675        .join("opencode-cloud.pid");
676
677    InstanceLock::acquire(pid_path)
678}
679
680/// Display a rich error message when another instance is already running
681#[allow(dead_code)]
682fn display_singleton_error(err: &SingletonError) {
683    match err {
684        SingletonError::AlreadyRunning(pid) => {
685            eprintln!(
686                "{} Another instance is already running",
687                style("Error:").red().bold()
688            );
689            eprintln!();
690            eprintln!("  Process ID: {}", style(pid).yellow());
691            eprintln!();
692            eprintln!(
693                "  {} Stop the existing instance first:",
694                style("Tip:").cyan()
695            );
696            eprintln!("       {} stop", style("opencode-cloud").green());
697            eprintln!();
698            eprintln!(
699                "  {} If the process is stuck, kill it manually:",
700                style("Tip:").cyan()
701            );
702            eprintln!("       {} {}", style("kill").green(), pid);
703        }
704        SingletonError::CreateDirFailed(msg) => {
705            eprintln!(
706                "{} Failed to create data directory",
707                style("Error:").red().bold()
708            );
709            eprintln!();
710            eprintln!("  {msg}");
711            eprintln!();
712            if let Some(data_dir) = config::paths::get_data_dir() {
713                eprintln!("  {} Check permissions for:", style("Tip:").cyan());
714                eprintln!("       {}", style(data_dir.display()).yellow());
715            }
716        }
717        SingletonError::LockFailed(msg) => {
718            eprintln!("{} Failed to acquire lock", style("Error:").red().bold());
719            eprintln!();
720            eprintln!("  {msg}");
721        }
722        SingletonError::InvalidPath => {
723            eprintln!(
724                "{} Could not determine lock file path",
725                style("Error:").red().bold()
726            );
727            eprintln!();
728            eprintln!(
729                "  {} Ensure XDG_DATA_HOME or HOME is set.",
730                style("Tip:").cyan()
731            );
732        }
733    }
734}
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739
740    #[test]
741    fn container_marker_logic_requires_both_markers() {
742        assert!(container_runtime_from_markers(true, true));
743        assert!(!container_runtime_from_markers(true, false));
744        assert!(!container_runtime_from_markers(false, true));
745        assert!(!container_runtime_from_markers(false, false));
746    }
747
748    #[test]
749    fn runtime_precedence_respects_explicit_choice() {
750        let (mode, auto) = resolve_runtime_with_autodetect(RuntimeChoice::Host, true);
751        assert_eq!(mode, RuntimeMode::Host);
752        assert!(!auto);
753
754        let (mode, auto) = resolve_runtime_with_autodetect(RuntimeChoice::Container, false);
755        assert_eq!(mode, RuntimeMode::Container);
756        assert!(!auto);
757    }
758
759    #[test]
760    fn runtime_auto_uses_detection() {
761        let (mode, auto) = resolve_runtime_with_autodetect(RuntimeChoice::Auto, true);
762        assert_eq!(mode, RuntimeMode::Container);
763        assert!(auto);
764
765        let (mode, auto) = resolve_runtime_with_autodetect(RuntimeChoice::Auto, false);
766        assert_eq!(mode, RuntimeMode::Host);
767        assert!(!auto);
768    }
769
770    #[test]
771    fn command_kind_maps_none_status_and_other() {
772        assert_eq!(command_kind(None), CommandKind::None);
773
774        let status = Commands::Status(commands::StatusArgs {});
775        assert_eq!(command_kind(Some(&status)), CommandKind::Status);
776
777        let start = Commands::Start(commands::StartArgs::default());
778        assert_eq!(command_kind(Some(&start)), CommandKind::Other);
779    }
780
781    #[test]
782    fn should_run_runtime_asset_preflight_gating() {
783        assert!(should_run_runtime_asset_preflight(
784            CommandKind::Other,
785            None,
786            false
787        ));
788        assert!(!should_run_runtime_asset_preflight(
789            CommandKind::Status,
790            None,
791            false
792        ));
793        assert!(!should_run_runtime_asset_preflight(
794            CommandKind::None,
795            None,
796            false
797        ));
798        assert!(!should_run_runtime_asset_preflight(
799            CommandKind::Other,
800            Some("prod-host"),
801            false
802        ));
803        assert!(!should_run_runtime_asset_preflight(
804            CommandKind::Other,
805            None,
806            true
807        ));
808    }
809
810    #[test]
811    fn render_runtime_asset_preflight_lines_include_rebuild_suggestions() {
812        let report = RuntimeAssetDrift {
813            drift_detected: true,
814            mismatched_assets: vec!["bootstrap helper".to_string()],
815            diagnostics: vec![],
816        };
817        let lines = render_runtime_asset_preflight_lines(&report, 0);
818        assert!(
819            lines
820                .iter()
821                .any(|line| line.contains("--cached-rebuild-sandbox-image"))
822        );
823        assert!(
824            lines
825                .iter()
826                .any(|line| line.contains("--full-rebuild-sandbox-image"))
827        );
828    }
829
830    #[test]
831    fn render_runtime_asset_preflight_lines_appends_diagnostics_in_verbose() {
832        let report = RuntimeAssetDrift {
833            drift_detected: true,
834            mismatched_assets: vec!["entrypoint".to_string()],
835            diagnostics: vec!["entrypoint: exit status 1".to_string()],
836        };
837        let lines = render_runtime_asset_preflight_lines(&report, 1);
838        assert!(
839            lines
840                .iter()
841                .any(|line| line.contains("diagnostic: entrypoint"))
842        );
843    }
844}