Skip to main content

systemprompt_cli/commands/cloud/sync/
mod.rs

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