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