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 };
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#[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#[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}