1mod 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#[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 #[arg(short, long, global = true, action = clap::ArgAction::Count)]
38 verbose: u8,
39
40 #[arg(short, long, global = true)]
42 quiet: bool,
43
44 #[arg(long, global = true)]
46 no_color: bool,
47
48 #[arg(long, global = true, conflicts_with = "local")]
50 remote_host: Option<String>,
51
52 #[arg(long, global = true, conflicts_with = "remote_host")]
54 local: bool,
55
56 #[arg(long, global = true, value_enum)]
58 runtime: Option<RuntimeChoice>,
59
60 #[arg(long, global = true, value_name = "NAME|auto")]
62 sandbox_instance: Option<String>,
63}
64
65#[derive(Subcommand)]
66enum Commands {
67 Start(commands::StartArgs),
69 Stop(commands::StopArgs),
71 Restart(commands::RestartArgs),
73 Status(commands::StatusArgs),
75 Logs(commands::LogsArgs),
77 Install(commands::InstallArgs),
79 Uninstall(commands::UninstallArgs),
81 Config(commands::ConfigArgs),
83 Setup(commands::SetupArgs),
85 User(commands::UserArgs),
87 Mount(commands::MountArgs),
89 Reset(commands::ResetArgs),
91 Update(commands::UpdateArgs),
93 #[command(hide = true)]
95 Cockpit(commands::CockpitArgs),
96 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
120fn get_banner() -> &'static str {
122 r#"
123 ___ _ __ ___ _ __ ___ ___ __| | ___
124 / _ \| '_ \ / _ \ '_ \ / __/ _ \ / _` |/ _ \
125| (_) | |_) | __/ | | | (_| (_) | (_| | __/
126 \___/| .__/ \___|_| |_|\___\___/ \__,_|\___|
127 |_| cloud
128"#
129}
130
131pub 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
151pub 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 let target_host = maybe_host.map(String::from);
161
162 match target_host {
163 Some(name) => {
164 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 let client = DockerClient::new()?;
177 Ok((client, None))
178 }
179 }
180}
181
182pub 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 tracing_subscriber::fmt::init();
302
303 let cli = Cli::parse();
304
305 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 let config = match load_config_or_default() {
384 Ok(config) => {
385 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 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 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 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#[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#[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}