systemprompt_cli/commands/cloud/deploy/
progress.rs1use 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}