Skip to main content

systemprompt_cli/commands/cloud/sync/
mod.rs

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