Skip to main content

systemprompt_cli/commands/cloud/sync/
mod.rs

1pub mod admin_user;
2mod interactive;
3
4use anyhow::{Result, anyhow};
5use clap::{Args, Subcommand};
6use systemprompt_cloud::{CloudPath, TenantStore, get_cloud_paths};
7use systemprompt_config::ProfileBootstrap;
8use systemprompt_logging::CliService;
9use systemprompt_sync::{SyncConfig, SyncDirection, SyncOpState, SyncOperationResult, SyncService};
10
11use crate::cli_settings::CliConfig;
12use crate::cloud::tenant::get_credentials;
13
14#[derive(Debug, Subcommand)]
15pub enum SyncCommands {
16    #[command(about = "Push local state to cloud")]
17    Push(SyncArgs),
18
19    #[command(about = "Pull cloud state to local")]
20    Pull(SyncArgs),
21
22    #[command(about = "Sync cloud user as admin to all local profiles")]
23    AdminUser(AdminUserSyncArgs),
24}
25
26#[derive(Debug, Clone, Copy, Args)]
27pub struct SyncArgs {
28    #[arg(long)]
29    pub dry_run: bool,
30
31    #[arg(long)]
32    pub force: bool,
33
34    #[arg(short, long)]
35    pub verbose: bool,
36}
37
38#[derive(Debug, Args)]
39pub struct AdminUserSyncArgs {
40    #[arg(short, long, help = "Show detailed discovery information")]
41    pub verbose: bool,
42
43    #[arg(long, help = "Specific profile to sync (default: all profiles)")]
44    pub profile: Option<String>,
45
46    #[arg(long, help = "Override database URL (requires --profile)")]
47    pub database_url: Option<String>,
48}
49
50pub async fn execute(cmd: Option<SyncCommands>, config: &CliConfig) -> Result<()> {
51    match cmd {
52        Some(SyncCommands::Push(args)) => execute_cloud_sync(SyncDirection::Push, args).await,
53        Some(SyncCommands::Pull(args)) => execute_cloud_sync(SyncDirection::Pull, args).await,
54        Some(SyncCommands::AdminUser(args)) => execute_admin_user_sync(args).await,
55        None => {
56            if !config.is_interactive() {
57                return Err(anyhow!(
58                    "Sync subcommand required in non-interactive mode. Use push, pull, or \
59                     admin-user."
60                ));
61            }
62            interactive::execute(config).await
63        },
64    }
65}
66
67async fn execute_admin_user_sync(args: AdminUserSyncArgs) -> Result<()> {
68    CliService::section("Admin User Sync");
69
70    let cloud_user = admin_user::CloudUser::from_credentials()?
71        .ok_or_else(|| anyhow!("Not logged in. Run 'systemprompt cloud auth login' first."))?;
72
73    CliService::key_value("Cloud User", &cloud_user.email);
74
75    if let Some(profile_name) = &args.profile {
76        let database_url = if let Some(url) = &args.database_url {
77            url.clone()
78        } else {
79            let discovery = admin_user::discover_profiles()?;
80            discovery
81                .profiles
82                .into_iter()
83                .find(|p| &p.name == profile_name)
84                .and_then(|p| p.database_url)
85                .ok_or_else(|| {
86                    anyhow!(
87                        "Profile '{}' not found or has no database_url",
88                        profile_name
89                    )
90                })?
91        };
92
93        let result =
94            admin_user::sync_admin_to_database(&cloud_user, &database_url, profile_name).await;
95        admin_user::print_sync_results(&[result]);
96    } else {
97        if args.database_url.is_some() {
98            return Err(anyhow!("--database-url requires --profile"));
99        }
100
101        let results = admin_user::sync_admin_to_all_profiles(&cloud_user, args.verbose).await;
102        admin_user::print_sync_results(&results);
103    }
104
105    Ok(())
106}
107
108async fn execute_cloud_sync(direction: SyncDirection, args: SyncArgs) -> Result<()> {
109    let creds = get_credentials()?;
110
111    let profile = ProfileBootstrap::get()
112        .map_err(|_e| anyhow!("Profile required for sync. Set SYSTEMPROMPT_PROFILE"))?;
113
114    let tenant_id = profile
115        .cloud
116        .as_ref()
117        .and_then(|c| c.tenant_id.as_ref())
118        .ok_or_else(|| anyhow!("No tenant configured. Run 'systemprompt cloud profile create'"))?;
119
120    let cloud_paths = get_cloud_paths();
121    let tenants_path = cloud_paths.resolve(CloudPath::Tenants);
122    let store = TenantStore::load_from_path(&tenants_path).unwrap_or_else(|e| {
123        CliService::warning(&format!("Failed to load tenant store: {}", e));
124        TenantStore::default()
125    });
126    let tenant = store.find_tenant(tenant_id.as_str());
127
128    if let Some(t) = tenant {
129        if t.is_local() {
130            return Err(anyhow!(
131                "Cannot sync local tenant '{}' to cloud. Local tenants are for development \
132                 only.\nCreate a cloud tenant with 'systemprompt cloud tenant create' or select \
133                 an existing cloud tenant with 'systemprompt cloud profile create'.",
134                tenant_id
135            ));
136        }
137    }
138
139    let hostname = tenant.and_then(|t| t.hostname.clone()).ok_or_else(|| {
140        anyhow!("Hostname not configured for tenant. Run: systemprompt cloud login")
141    })?;
142
143    let services_path = profile.paths.services.clone();
144
145    let config = SyncConfig {
146        direction,
147        dry_run: args.dry_run,
148        verbose: args.verbose,
149        tenant_id: tenant_id.clone(),
150        api_url: creds.api_url.clone(),
151        api_token: creds.api_token.clone(),
152        services_path,
153        hostname: Some(hostname),
154        local_database_url: None,
155    };
156
157    print_header(&direction, args.dry_run);
158
159    let service = SyncService::new(config)?;
160    let mut results = Vec::new();
161
162    let spinner = CliService::spinner("Syncing files...");
163    let files_result = service.sync_files().await?;
164    spinner.finish_and_clear();
165    results.push(files_result);
166
167    print_results(&results);
168
169    Ok(())
170}
171
172fn print_header(direction: &SyncDirection, dry_run: bool) {
173    CliService::section("Cloud Sync");
174    let dir = match direction {
175        SyncDirection::Push => "Local -> Cloud",
176        SyncDirection::Pull => "Cloud -> Local",
177    };
178    CliService::key_value("Direction", dir);
179    if dry_run {
180        CliService::warning("DRY RUN - no changes will be made");
181    }
182}
183
184fn print_results(results: &[SyncOperationResult]) {
185    for result in results {
186        match &result.state {
187            SyncOpState::Completed => {
188                CliService::success(&format!(
189                    "{} - Synced {} items",
190                    result.operation, result.items_synced
191                ));
192            },
193            SyncOpState::NotStarted => {
194                CliService::error(&format!("{} - did not run", result.operation));
195                for err in &result.errors {
196                    CliService::error(&format!("  - {}", err));
197                }
198            },
199            SyncOpState::Partial { completed, total } => {
200                CliService::warning(&format!(
201                    "{} - partial: {} of {} items completed",
202                    result.operation, completed, total
203                ));
204                for err in &result.errors {
205                    CliService::error(&format!("  - {}", err));
206                }
207            },
208            SyncOpState::Failed => {
209                CliService::error(&format!(
210                    "{} - failed with {} errors",
211                    result.operation,
212                    result.errors.len()
213                ));
214                for err in &result.errors {
215                    CliService::error(&format!("  - {}", err));
216                }
217            },
218        }
219    }
220}