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        };
609        commands::cmd_start(&start_args, host_name.as_deref(), quiet, verbose).await?;
610        let status_args = commands::StatusArgs {};
611        return commands::cmd_status(&status_args, host_name.as_deref(), quiet, verbose).await;
612    }
613
614    print_help_hint();
615    Ok(())
616}
617
618async fn maybe_print_runtime_asset_preflight(target_host: Option<&str>, verbose: u8) -> Result<()> {
619    let (client, host_name) = resolve_docker_client(target_host).await?;
620    if host_name.is_some() {
621        return Ok(());
622    }
623
624    let report = detect_runtime_asset_drift(&client).await;
625    print_runtime_asset_preflight_warning(&report, verbose);
626    Ok(())
627}
628
629fn print_runtime_asset_preflight_warning(report: &RuntimeAssetDrift, verbose: u8) {
630    if !report.drift_detected {
631        return;
632    }
633
634    eprintln!(
635        "{} {}",
636        style("Warning:").yellow().bold(),
637        style("Local container drift detected.").yellow()
638    );
639    for line in render_runtime_asset_preflight_lines(report, verbose) {
640        eprintln!("  {line}");
641    }
642    eprintln!();
643}
644
645fn render_runtime_asset_preflight_lines(report: &RuntimeAssetDrift, verbose: u8) -> Vec<String> {
646    let mut lines = stale_container_warning_lines(report);
647    if verbose > 0 {
648        for detail in &report.diagnostics {
649            lines.push(format!("diagnostic: {detail}"));
650        }
651    }
652    lines
653}
654
655fn print_help_hint() {
656    println!(
657        "{} {}",
658        style("opencode-cloud").cyan().bold(),
659        style(get_version()).dim()
660    );
661    println!();
662    println!("Run {} for available commands.", style("--help").green());
663}
664
665/// Acquire the singleton lock for service management commands
666///
667/// This should be called before any command that manages the service
668/// (start, stop, restart, status, etc.) to ensure only one instance runs.
669/// Config commands don't need the lock as they're read-only or file-based.
670#[allow(dead_code)]
671fn acquire_singleton_lock() -> Result<InstanceLock, SingletonError> {
672    let pid_path = config::paths::get_data_dir()
673        .ok_or(SingletonError::InvalidPath)?
674        .join("opencode-cloud.pid");
675
676    InstanceLock::acquire(pid_path)
677}
678
679/// Display a rich error message when another instance is already running
680#[allow(dead_code)]
681fn display_singleton_error(err: &SingletonError) {
682    match err {
683        SingletonError::AlreadyRunning(pid) => {
684            eprintln!(
685                "{} Another instance is already running",
686                style("Error:").red().bold()
687            );
688            eprintln!();
689            eprintln!("  Process ID: {}", style(pid).yellow());
690            eprintln!();
691            eprintln!(
692                "  {} Stop the existing instance first:",
693                style("Tip:").cyan()
694            );
695            eprintln!("       {} stop", style("opencode-cloud").green());
696            eprintln!();
697            eprintln!(
698                "  {} If the process is stuck, kill it manually:",
699                style("Tip:").cyan()
700            );
701            eprintln!("       {} {}", style("kill").green(), pid);
702        }
703        SingletonError::CreateDirFailed(msg) => {
704            eprintln!(
705                "{} Failed to create data directory",
706                style("Error:").red().bold()
707            );
708            eprintln!();
709            eprintln!("  {msg}");
710            eprintln!();
711            if let Some(data_dir) = config::paths::get_data_dir() {
712                eprintln!("  {} Check permissions for:", style("Tip:").cyan());
713                eprintln!("       {}", style(data_dir.display()).yellow());
714            }
715        }
716        SingletonError::LockFailed(msg) => {
717            eprintln!("{} Failed to acquire lock", style("Error:").red().bold());
718            eprintln!();
719            eprintln!("  {msg}");
720        }
721        SingletonError::InvalidPath => {
722            eprintln!(
723                "{} Could not determine lock file path",
724                style("Error:").red().bold()
725            );
726            eprintln!();
727            eprintln!(
728                "  {} Ensure XDG_DATA_HOME or HOME is set.",
729                style("Tip:").cyan()
730            );
731        }
732    }
733}
734
735#[cfg(test)]
736mod tests {
737    use super::*;
738
739    #[test]
740    fn container_marker_logic_requires_both_markers() {
741        assert!(container_runtime_from_markers(true, true));
742        assert!(!container_runtime_from_markers(true, false));
743        assert!(!container_runtime_from_markers(false, true));
744        assert!(!container_runtime_from_markers(false, false));
745    }
746
747    #[test]
748    fn runtime_precedence_respects_explicit_choice() {
749        let (mode, auto) = resolve_runtime_with_autodetect(RuntimeChoice::Host, true);
750        assert_eq!(mode, RuntimeMode::Host);
751        assert!(!auto);
752
753        let (mode, auto) = resolve_runtime_with_autodetect(RuntimeChoice::Container, false);
754        assert_eq!(mode, RuntimeMode::Container);
755        assert!(!auto);
756    }
757
758    #[test]
759    fn runtime_auto_uses_detection() {
760        let (mode, auto) = resolve_runtime_with_autodetect(RuntimeChoice::Auto, true);
761        assert_eq!(mode, RuntimeMode::Container);
762        assert!(auto);
763
764        let (mode, auto) = resolve_runtime_with_autodetect(RuntimeChoice::Auto, false);
765        assert_eq!(mode, RuntimeMode::Host);
766        assert!(!auto);
767    }
768
769    #[test]
770    fn command_kind_maps_none_status_and_other() {
771        assert_eq!(command_kind(None), CommandKind::None);
772
773        let status = Commands::Status(commands::StatusArgs {});
774        assert_eq!(command_kind(Some(&status)), CommandKind::Status);
775
776        let start = Commands::Start(commands::StartArgs::default());
777        assert_eq!(command_kind(Some(&start)), CommandKind::Other);
778    }
779
780    #[test]
781    fn should_run_runtime_asset_preflight_gating() {
782        assert!(should_run_runtime_asset_preflight(
783            CommandKind::Other,
784            None,
785            false
786        ));
787        assert!(!should_run_runtime_asset_preflight(
788            CommandKind::Status,
789            None,
790            false
791        ));
792        assert!(!should_run_runtime_asset_preflight(
793            CommandKind::None,
794            None,
795            false
796        ));
797        assert!(!should_run_runtime_asset_preflight(
798            CommandKind::Other,
799            Some("prod-host"),
800            false
801        ));
802        assert!(!should_run_runtime_asset_preflight(
803            CommandKind::Other,
804            None,
805            true
806        ));
807    }
808
809    #[test]
810    fn render_runtime_asset_preflight_lines_include_rebuild_suggestions() {
811        let report = RuntimeAssetDrift {
812            drift_detected: true,
813            mismatched_assets: vec!["bootstrap helper".to_string()],
814            diagnostics: vec![],
815        };
816        let lines = render_runtime_asset_preflight_lines(&report, 0);
817        assert!(
818            lines
819                .iter()
820                .any(|line| line.contains("--cached-rebuild-sandbox-image"))
821        );
822        assert!(
823            lines
824                .iter()
825                .any(|line| line.contains("--full-rebuild-sandbox-image"))
826        );
827    }
828
829    #[test]
830    fn render_runtime_asset_preflight_lines_appends_diagnostics_in_verbose() {
831        let report = RuntimeAssetDrift {
832            drift_detected: true,
833            mismatched_assets: vec!["entrypoint".to_string()],
834            diagnostics: vec!["entrypoint: exit status 1".to_string()],
835        };
836        let lines = render_runtime_asset_preflight_lines(&report, 1);
837        assert!(
838            lines
839                .iter()
840                .any(|line| line.contains("diagnostic: entrypoint"))
841        );
842    }
843}