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