Skip to main content

systemprompt_cli/commands/cloud/sync/
mod.rs

1pub mod admin_user;
2pub mod content;
3mod interactive;
4mod prompt;
5pub mod skills;
6
7use anyhow::{anyhow, Result};
8use clap::{Args, Subcommand, ValueEnum};
9use systemprompt_cloud::{get_cloud_paths, CloudPath, TenantStore};
10use systemprompt_logging::CliService;
11use systemprompt_models::profile_bootstrap::ProfileBootstrap;
12use systemprompt_sync::{SyncConfig, SyncDirection, SyncOperationResult, SyncService};
13
14use crate::cli_settings::CliConfig;
15use crate::cloud::tenant::get_credentials;
16
17#[derive(Debug, Clone, Copy, ValueEnum)]
18pub enum CliLocalSyncDirection {
19    ToDb,
20    ToDisk,
21}
22
23#[derive(Debug, Subcommand)]
24pub enum SyncCommands {
25    Push(SyncArgs),
26
27    Pull(SyncArgs),
28
29    #[command(subcommand)]
30    Local(LocalSyncCommands),
31}
32
33#[derive(Debug, Subcommand)]
34pub enum LocalSyncCommands {
35    Content(ContentSyncArgs),
36
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 ContentSyncArgs {
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 source: 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 SkillsSyncArgs {
75    #[arg(long, value_enum)]
76    pub direction: Option<CliLocalSyncDirection>,
77
78    #[arg(long)]
79    pub database_url: Option<String>,
80
81    #[arg(long)]
82    pub skill: Option<String>,
83
84    #[arg(long)]
85    pub dry_run: bool,
86
87    #[arg(long)]
88    pub delete_orphans: bool,
89
90    #[arg(short = 'y', long, help = "Skip confirmation prompts")]
91    pub yes: bool,
92}
93
94pub async fn execute(cmd: Option<SyncCommands>, config: &CliConfig) -> Result<()> {
95    match cmd {
96        Some(SyncCommands::Push(args)) => execute_cloud_sync(SyncDirection::Push, args).await,
97        Some(SyncCommands::Pull(args)) => execute_cloud_sync(SyncDirection::Pull, args).await,
98        Some(SyncCommands::Local(cmd)) => execute_local_sync(cmd, config).await,
99        None => {
100            if !config.is_interactive() {
101                return Err(anyhow!(
102                    "Sync subcommand required in non-interactive mode. Use push, pull, or local."
103                ));
104            }
105            interactive::execute(config).await
106        },
107    }
108}
109
110async fn execute_local_sync(cmd: LocalSyncCommands, config: &CliConfig) -> Result<()> {
111    match cmd {
112        LocalSyncCommands::Content(args) => content::execute(args, config).await,
113        LocalSyncCommands::Skills(args) => skills::execute(args, config).await,
114    }
115}
116
117async fn execute_cloud_sync(direction: SyncDirection, args: SyncArgs) -> Result<()> {
118    let creds = get_credentials()?;
119
120    let profile = ProfileBootstrap::get()
121        .map_err(|_| anyhow!("Profile required for sync. Set SYSTEMPROMPT_PROFILE"))?;
122
123    let tenant_id = profile
124        .cloud
125        .as_ref()
126        .and_then(|c| c.tenant_id.as_ref())
127        .ok_or_else(|| anyhow!("No tenant configured. Run 'systemprompt cloud profile create'"))?;
128
129    let cloud_paths = get_cloud_paths()?;
130    let tenants_path = cloud_paths.resolve(CloudPath::Tenants);
131    let store = TenantStore::load_from_path(&tenants_path).unwrap_or_else(|e| {
132        CliService::warning(&format!("Failed to load tenant store: {}", e));
133        TenantStore::default()
134    });
135    let tenant = store.find_tenant(tenant_id);
136
137    if let Some(t) = tenant {
138        if t.is_local() {
139            return Err(anyhow!(
140                "Cannot sync local tenant '{}' to cloud. Local tenants are for development \
141                 only.\nCreate a cloud tenant with 'systemprompt cloud tenant create' or select \
142                 an existing cloud tenant with 'systemprompt cloud profile create'.",
143                tenant_id
144            ));
145        }
146    }
147
148    let (hostname, sync_token) =
149        tenant.map_or((None, None), |t| (t.hostname.clone(), t.sync_token.clone()));
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: tenant_id.clone(),
158        api_url: creds.api_url.clone(),
159        api_token: creds.api_token.clone(),
160        services_path,
161        hostname,
162        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        if result.success {
196            CliService::success(&format!(
197                "{} - Synced {} items",
198                result.operation, result.items_synced
199            ));
200        } else {
201            CliService::error(&format!(
202                "{} - Failed with {} errors",
203                result.operation,
204                result.errors.len()
205            ));
206            for err in &result.errors {
207                CliService::error(&format!("  - {}", err));
208            }
209        }
210    }
211}