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