Skip to main content

systemprompt_cli/commands/cloud/sync/
mod.rs

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