systemprompt_cli/commands/cloud/sync/
interactive.rs1use anyhow::{Context, Result, bail};
10use systemprompt_cloud::{ProfilePath, ProjectContext};
11use systemprompt_loader::ProfileLoader;
12use systemprompt_logging::CliService;
13use systemprompt_models::Profile;
14
15use crate::cli_settings::CliConfig;
16use crate::interactive::Prompter;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum SyncType {
20 Push,
21 Pull,
22}
23
24struct ProfileSelection {
25 pub name: String,
26 pub profile: Profile,
27}
28
29pub(super) async fn execute(prompter: &dyn Prompter, _config: &CliConfig) -> Result<()> {
30 CliService::section("Sync Menu");
31
32 let sync_type = select_sync_type(prompter)?;
33
34 let source = select_profile(prompter, "Select source profile")?;
35
36 execute_cloud_sync(sync_type, &source).await
37}
38
39pub fn select_sync_type(prompter: &dyn Prompter) -> Result<SyncType> {
40 let options = vec![
41 "Push to cloud (Local → Cloud)".to_owned(),
42 "Pull from cloud (Cloud → Local)".to_owned(),
43 ];
44
45 let selection = prompter.select("Select sync operation", &options)?;
46
47 match selection {
48 0 => Ok(SyncType::Push),
49 1 => Ok(SyncType::Pull),
50 _ => bail!("Invalid selection"),
51 }
52}
53
54fn select_profile(prompter: &dyn Prompter, prompt: &str) -> Result<ProfileSelection> {
55 let profiles = discover_profiles()?;
56
57 if profiles.is_empty() {
58 bail!(
59 "No profiles found.\nCreate a profile with: systemprompt cloud profile create <name>"
60 );
61 }
62
63 let options: Vec<String> = profiles
64 .iter()
65 .map(|p| {
66 let cloud_status = if p.profile.cloud.is_some() {
67 "cloud"
68 } else {
69 "local"
70 };
71 format!("{} ({})", p.name, cloud_status)
72 })
73 .collect();
74
75 let selection = prompter
76 .select(prompt, &options)
77 .context("Failed to select profile")?;
78
79 profiles
80 .into_iter()
81 .nth(selection)
82 .ok_or_else(|| anyhow::anyhow!("Invalid selection index"))
83}
84
85fn discover_profiles() -> Result<Vec<ProfileSelection>> {
86 let ctx = ProjectContext::discover();
87 let profiles_dir = ctx.profiles_dir();
88
89 if !profiles_dir.exists() {
90 return Ok(Vec::new());
91 }
92
93 let entries = std::fs::read_dir(&profiles_dir).with_context(|| {
94 format!(
95 "Failed to read profiles directory: {}",
96 profiles_dir.display()
97 )
98 })?;
99
100 let profiles: Vec<ProfileSelection> = entries
101 .filter_map(std::result::Result::ok)
102 .filter(|e| e.path().is_dir())
103 .filter_map(|entry| {
104 let profile_yaml = ProfilePath::Config.resolve(&entry.path());
105 if !profile_yaml.exists() {
106 return None;
107 }
108
109 let name = entry.file_name().to_string_lossy().to_string();
110 let profile = ProfileLoader::load_from_path(&profile_yaml).ok()?;
111
112 Some(ProfileSelection { name, profile })
113 })
114 .collect();
115
116 Ok(profiles)
117}
118
119async fn execute_cloud_sync(sync_type: SyncType, source: &ProfileSelection) -> Result<()> {
120 use systemprompt_cloud::{CloudPath, CredentialsBootstrap, TenantStore, get_cloud_paths};
121 use systemprompt_sync::{SyncConfig, SyncDirection, SyncOperationResult, SyncService};
122
123 let creds = CredentialsBootstrap::require()
124 .context("Cloud sync requires credentials. Run 'systemprompt cloud auth login'")?;
125
126 let cloud = source
127 .profile
128 .cloud
129 .as_ref()
130 .ok_or_else(|| anyhow::anyhow!("Profile has no cloud configuration"))?;
131
132 let tenant_id = cloud
133 .tenant_id
134 .as_ref()
135 .ok_or_else(|| anyhow::anyhow!("No tenant configured in profile"))?;
136
137 let cloud_paths = get_cloud_paths();
138 let tenants_path = cloud_paths.resolve(CloudPath::Tenants);
139 let store = TenantStore::load_from_path(&tenants_path).unwrap_or_else(|e| {
140 CliService::warning(&format!("Failed to load tenant store: {}", e));
141 TenantStore::default()
142 });
143 let tenant = store.find_tenant(tenant_id);
144
145 let hostname = tenant.and_then(|t| t.hostname.clone());
146
147 let direction = match sync_type {
148 SyncType::Push => SyncDirection::Push,
149 SyncType::Pull => SyncDirection::Pull,
150 };
151
152 let config = SyncConfig {
153 direction,
154 dry_run: false,
155 verbose: false,
156 tenant_id: tenant_id.clone(),
157 api_url: creds.api_url.clone(),
158 api_token: creds.api_token.as_str().to_owned(),
159 services_path: source.profile.paths.services.clone(),
160 hostname,
161 local_database_url: None,
162 };
163
164 let dir_label = match direction {
165 SyncDirection::Push => "Local → Cloud",
166 SyncDirection::Pull => "Cloud → Local",
167 };
168 CliService::key_value("Direction", dir_label);
169
170 let service = SyncService::new(config)?;
171 let mut results: Vec<SyncOperationResult> = Vec::new();
172
173 let spinner = CliService::spinner("Syncing files...");
174 let files_result = service.sync_files().await?;
175 spinner.finish_and_clear();
176 results.push(files_result);
177
178 for result in &results {
179 if result.success {
180 CliService::success(&format!(
181 "{} - Synced {} items",
182 result.operation, result.items_synced
183 ));
184 } else {
185 CliService::error(&format!(
186 "{} - Failed with {} errors",
187 result.operation,
188 result.errors.len()
189 ));
190 }
191 }
192
193 Ok(())
194}