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