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