systemprompt_cli/commands/cloud/sync/
mod.rs1pub 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}