Skip to main content

systemprompt_cli/commands/cloud/deploy/
progress.rs

1//! CLI rendering for the deploy pipeline.
2//!
3//! [`CliDeployProgress`] implements the orchestrator's
4//! [`DeployProgress`] seam over `CliService` spinners and message sinks:
5//! sequencing lives in `systemprompt-sync`, presentation lives here. A single
6//! spinner slot is cleared at every event boundary so each long-running step
7//! replaces the previous indicator.
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use std::sync::Mutex;
13
14use indicatif::ProgressBar;
15use systemprompt_logging::CliService;
16use systemprompt_sync::deploy::{DeployEvent, DeployProgress, DeployPrompt};
17use systemprompt_sync::{
18    FileDiffStatus, SyncDiffResult, SyncError, SyncOperationResult, SyncResult,
19};
20
21use crate::cli_settings::CliConfig;
22use crate::interactive::{Prompter, confirm_optional};
23
24pub struct CliDeployProgress<'a> {
25    config: Option<(&'a dyn Prompter, &'a CliConfig)>,
26    spinner: Mutex<Option<ProgressBar>>,
27}
28
29impl std::fmt::Debug for CliDeployProgress<'_> {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        f.debug_struct("CliDeployProgress")
32            .field("interactive", &self.config.is_some())
33            .finish_non_exhaustive()
34    }
35}
36
37impl<'a> CliDeployProgress<'a> {
38    pub const fn new(prompter: &'a dyn Prompter, config: &'a CliConfig) -> Self {
39        Self {
40            config: Some((prompter, config)),
41            spinner: Mutex::new(None),
42        }
43    }
44
45    pub const fn non_interactive() -> Self {
46        Self {
47            config: None,
48            spinner: Mutex::new(None),
49        }
50    }
51
52    fn start_spinner(&self, message: &str) {
53        if let Ok(mut slot) = self.spinner.lock() {
54            *slot = Some(CliService::spinner(message));
55        }
56    }
57
58    fn clear_spinner(&self) {
59        if let Ok(mut slot) = self.spinner.lock()
60            && let Some(spinner) = slot.take()
61        {
62            spinner.finish_and_clear();
63        }
64    }
65}
66
67impl DeployProgress for CliDeployProgress<'_> {
68    fn event(&self, event: &DeployEvent<'_>) {
69        self.clear_spinner();
70        if let Some(message) = spinner_message(event) {
71            self.start_spinner(message);
72            return;
73        }
74        render_event(event);
75    }
76
77    fn confirm(&self, prompt: &DeployPrompt) -> SyncResult<bool> {
78        self.clear_spinner();
79        let (message, default) = match prompt {
80            DeployPrompt::PreSync => ("Sync files from cloud before deploying?".to_owned(), true),
81            DeployPrompt::ApplyChanges { count } => (
82                format!(
83                    "Apply {} change{} from cloud? (backup saved)",
84                    count,
85                    if *count == 1 { "" } else { "s" }
86                ),
87                true,
88            ),
89        };
90        self.config.map_or(Ok(default), |(prompter, config)| {
91            confirm_optional(prompter, &message, default, config).map_err(SyncError::internal)
92        })
93    }
94}
95
96pub const fn spinner_message(event: &DeployEvent<'_>) -> Option<&'static str> {
97    match event {
98        DeployEvent::SyncDryRunStarted => Some("Syncing files from cloud..."),
99        DeployEvent::SyncDownloadStarted => Some("Downloading files from cloud..."),
100        DeployEvent::SyncBackupStarted => Some("Backing up local services..."),
101        DeployEvent::RegistryAuthStarted => Some("Fetching registry credentials..."),
102        DeployEvent::BuildStarted => Some("Building Docker image..."),
103        DeployEvent::PushStarted => Some("Pushing to registry..."),
104        DeployEvent::SecretsSyncStarted => Some("Syncing secrets..."),
105        DeployEvent::CredentialsSyncStarted => Some("Syncing cloud credentials..."),
106        DeployEvent::DeployStarted => Some("Deploying..."),
107        _ => None,
108    }
109}
110
111fn render_event(event: &DeployEvent<'_>) {
112    match event {
113        DeployEvent::PreSyncSkippedByFlag => {
114            CliService::warning("Pre-deploy sync skipped (--no-sync)");
115            CliService::warning("Runtime files on the container will be LOST");
116        },
117        DeployEvent::PreSyncStarted => {
118            CliService::section("Pre-Deploy Sync");
119            display_destructive_warning();
120        },
121        DeployEvent::PreSyncDeclined => {
122            CliService::warning("Pre-deploy sync skipped by user");
123            CliService::warning("Runtime files on the container will be LOST");
124        },
125        DeployEvent::SyncDryRunFinished(result) => display_dry_run_result(result),
126        DeployEvent::SyncErrors(errors) => {
127            for err in *errors {
128                CliService::error(&format!("Sync error: {}", err));
129            }
130        },
131        DeployEvent::SyncBackupFinished(path) => {
132            CliService::success(&format!("Backed up to {}", path.display()));
133        },
134        DeployEvent::SyncDiff(diff) => display_diff(diff),
135        DeployEvent::SyncAlreadyClean => CliService::success("All files are already in sync"),
136        DeployEvent::SyncCancelled => {
137            CliService::warning("Sync cancelled by user. Backup preserved.");
138        },
139        DeployEvent::SyncApplied { count } => {
140            CliService::success(&format!("Applied {} files from cloud", count));
141        },
142        DeployEvent::ArtifactsResolved {
143            tenant_name,
144            binary,
145            dockerfile,
146        } => {
147            CliService::key_value("Tenant", tenant_name);
148            CliService::key_value("Binary", &binary.display().to_string());
149            CliService::key_value("Dockerfile", &dockerfile.display().to_string());
150        },
151        DeployEvent::ImageResolved { image } => CliService::key_value("Image", image),
152        DeployEvent::BuildFinished => CliService::success("Docker image built"),
153        DeployEvent::PushSkipped => CliService::info("Push skipped (--skip-push)"),
154        DeployEvent::PushFinished => CliService::success("Image pushed"),
155        DeployEvent::SecretsPhaseStarted => CliService::section("Provisioning Secrets"),
156        DeployEvent::SecretsFileMissing => {
157            CliService::warning("No secrets.json found - skipping secrets sync");
158        },
159        DeployEvent::SecretsSynced { count } => {
160            CliService::success(&format!("Synced {} secrets", count));
161        },
162        DeployEvent::CredentialsSynced { count } => {
163            CliService::success(&format!("Synced {} cloud credentials", count));
164        },
165        DeployEvent::ProfilePathConfigured => CliService::success("Profile path configured"),
166        DeployEvent::Deployed { status, app_url } => {
167            CliService::success("Deployed!");
168            CliService::key_value("Status", status);
169            if let Some(url) = app_url {
170                CliService::key_value("URL", url);
171            }
172        },
173        _ => {},
174    }
175}
176
177fn display_destructive_warning() {
178    CliService::warning("DESTRUCTIVE OPERATION");
179    CliService::info("  Deploying replaces the running container.");
180    CliService::info("  Runtime files (uploads, AI-generated images) not in your local build");
181    CliService::info("  will be PERMANENTLY LOST unless synced first.");
182    CliService::info("");
183    CliService::info("  Database records are preserved.");
184    CliService::info("");
185}
186
187fn display_diff(diff: &SyncDiffResult) {
188    CliService::section("Cloud Sync Diff");
189
190    if diff.added > 0 {
191        CliService::info(&format!("  Added ({}):", diff.added));
192        for entry in &diff.entries {
193            if entry.status == FileDiffStatus::Added {
194                CliService::info(&format!("    + {}", entry.path));
195            }
196        }
197    }
198
199    if diff.modified > 0 {
200        CliService::info(&format!("  Modified ({}):", diff.modified));
201        for entry in &diff.entries {
202            if entry.status == FileDiffStatus::Modified {
203                CliService::info(&format!("    ~ {}", entry.path));
204            }
205        }
206    }
207
208    if diff.deleted > 0 {
209        CliService::info(&format!("  Deleted from cloud ({}):", diff.deleted));
210        for entry in &diff.entries {
211            if entry.status == FileDiffStatus::Deleted {
212                CliService::info(&format!("    - {}", entry.path));
213            }
214        }
215    }
216
217    if diff.unchanged > 0 {
218        CliService::info(&format!("  Unchanged ({} files identical)", diff.unchanged));
219    }
220}
221
222fn display_dry_run_result(result: &SyncOperationResult) {
223    CliService::section("Dry Run - Files to Sync");
224    match &result.details {
225        Some(details) => display_file_list(details),
226        None => CliService::info(&format!("Would sync {} items", result.items_skipped)),
227    }
228}
229
230fn display_file_list(details: &serde_json::Value) {
231    let Some(files) = details.get("files").and_then(|f| f.as_array()) else {
232        return;
233    };
234
235    CliService::info(&format!("Would sync {} files:", files.len()));
236
237    for file in files.iter().take(20) {
238        if let Some(path) = file.get("path").and_then(|p| p.as_str()) {
239            CliService::info(&format!("  - {}", path));
240        }
241    }
242
243    if files.len() > 20 {
244        CliService::info(&format!("  ... and {} more", files.len() - 20));
245    }
246}