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