Skip to main content

systemprompt_cli/commands/cloud/sync/
interactive.rs

1//! Interactive menu for `cloud sync` when no subcommand is given.
2//!
3//! Prompts for push/pull direction and a source profile, then drives the
4//! `SyncService` to synchronise files for the profile's configured tenant.
5
6use 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}