systemprompt_cli/commands/cloud/sync/
admin_user.rs1use anyhow::Result;
2use std::path::PathBuf;
3use std::sync::Arc;
4use systemprompt_cloud::{
5 get_cloud_paths, CloudCredentials, CloudPath, ProfilePath, ProjectContext,
6};
7use systemprompt_database::Database;
8use systemprompt_logging::CliService;
9use systemprompt_users::{PromoteResult, UserAdminService, UserService};
10
11#[derive(Debug, Clone)]
12pub struct CloudUser {
13 pub email: String,
14 pub name: Option<String>,
15}
16
17#[derive(Debug)]
18pub enum SyncResult {
19 Created { email: String, profile: String },
20 Promoted { email: String, profile: String },
21 AlreadyAdmin { email: String, profile: String },
22 ConnectionFailed { profile: String, error: String },
23 Failed { profile: String, error: String },
24}
25
26impl CloudUser {
27 pub fn from_credentials() -> Result<Option<Self>> {
28 let cloud_paths = get_cloud_paths()?;
29 let creds_path = cloud_paths.resolve(CloudPath::Credentials);
30
31 if !creds_path.exists() {
32 return Ok(None);
33 }
34
35 let creds = CloudCredentials::load_from_path(&creds_path)?;
36
37 Ok(creds.user_email.map(|email| Self { email, name: None }))
38 }
39
40 pub fn username(&self) -> String {
41 self.email
42 .split('@')
43 .next()
44 .unwrap_or(&self.email)
45 .to_string()
46 }
47}
48
49#[derive(Debug)]
50pub struct ProfileInfo {
51 pub name: String,
52 pub database_url: String,
53 #[allow(dead_code)]
54 pub path: PathBuf,
55}
56
57pub fn discover_profiles() -> Result<Vec<ProfileInfo>> {
58 let ctx = ProjectContext::discover();
59 let profiles_dir = ctx.profiles_dir();
60
61 if !profiles_dir.exists() {
62 return Ok(Vec::new());
63 }
64
65 let mut profiles = Vec::new();
66
67 for entry in std::fs::read_dir(&profiles_dir)? {
68 let entry = entry?;
69 let path = entry.path();
70
71 if path.is_dir() {
72 let name = match path.file_name().and_then(|n| n.to_str()) {
73 Some(n) => n.to_string(),
74 None => continue,
75 };
76
77 let profile_yaml = ctx.profile_path(&name, ProfilePath::Config);
78 let secrets_json = ctx.profile_path(&name, ProfilePath::Secrets);
79
80 if profile_yaml.exists() && secrets_json.exists() {
81 if let Ok(content) = std::fs::read_to_string(&secrets_json) {
82 if let Ok(secrets) = serde_json::from_str::<serde_json::Value>(&content) {
83 if let Some(db_url) = secrets.get("database_url").and_then(|v| v.as_str()) {
84 profiles.push(ProfileInfo {
85 name,
86 database_url: db_url.to_string(),
87 path: path.clone(),
88 });
89 }
90 }
91 }
92 }
93 }
94 }
95
96 Ok(profiles)
97}
98
99pub async fn sync_admin_to_database(
100 user: &CloudUser,
101 database_url: &str,
102 profile_name: &str,
103) -> SyncResult {
104 let db = match tokio::time::timeout(
105 std::time::Duration::from_secs(5),
106 Database::new_postgres(database_url),
107 )
108 .await
109 {
110 Ok(Ok(db)) => Arc::new(db),
111 Ok(Err(e)) => {
112 return SyncResult::ConnectionFailed {
113 profile: profile_name.to_string(),
114 error: e.to_string(),
115 };
116 },
117 Err(e) => {
118 tracing::debug!(profile = %profile_name, error = %e, "Database connection timed out");
119 return SyncResult::ConnectionFailed {
120 profile: profile_name.to_string(),
121 error: "Connection timed out".to_string(),
122 };
123 },
124 };
125
126 let user_service = match UserService::new(&db) {
127 Ok(s) => s,
128 Err(e) => {
129 return SyncResult::Failed {
130 profile: profile_name.to_string(),
131 error: format!("Failed to create user service: {}", e),
132 };
133 },
134 };
135
136 let admin_service = UserAdminService::new(user_service.clone());
137
138 match user_service.find_by_email(&user.email).await {
139 Ok(Some(_existing)) => match admin_service.promote_to_admin(&user.email).await {
140 Ok(PromoteResult::Promoted(_, _)) => SyncResult::Promoted {
141 email: user.email.clone(),
142 profile: profile_name.to_string(),
143 },
144 Ok(PromoteResult::AlreadyAdmin(_)) => SyncResult::AlreadyAdmin {
145 email: user.email.clone(),
146 profile: profile_name.to_string(),
147 },
148 Ok(PromoteResult::UserNotFound) => SyncResult::Failed {
149 profile: profile_name.to_string(),
150 error: "User not found after existence check".to_string(),
151 },
152 Err(e) => SyncResult::Failed {
153 profile: profile_name.to_string(),
154 error: format!("Promotion failed: {}", e),
155 },
156 },
157 Ok(None) => {
158 let username = user.username();
159 let display_name = user.name.as_deref();
160
161 match user_service
162 .create(&username, &user.email, display_name, display_name)
163 .await
164 {
165 Ok(_new_user) => match admin_service.promote_to_admin(&user.email).await {
166 Ok(_) => SyncResult::Created {
167 email: user.email.clone(),
168 profile: profile_name.to_string(),
169 },
170 Err(e) => SyncResult::Failed {
171 profile: profile_name.to_string(),
172 error: format!("Created user but promotion failed: {}", e),
173 },
174 },
175 Err(e) => SyncResult::Failed {
176 profile: profile_name.to_string(),
177 error: format!("User creation failed: {}", e),
178 },
179 }
180 },
181 Err(e) => SyncResult::Failed {
182 profile: profile_name.to_string(),
183 error: format!("Failed to check existing user: {}", e),
184 },
185 }
186}
187
188pub async fn sync_admin_to_all_profiles(user: &CloudUser) -> Vec<SyncResult> {
189 let profiles = match discover_profiles() {
190 Ok(p) => p,
191 Err(e) => {
192 CliService::warning(&format!("Failed to discover profiles: {}", e));
193 return Vec::new();
194 },
195 };
196
197 if profiles.is_empty() {
198 CliService::info("No profiles found to sync admin user.");
199 return Vec::new();
200 }
201
202 let mut results = Vec::new();
203
204 for profile in profiles {
205 let result = sync_admin_to_database(user, &profile.database_url, &profile.name).await;
206 results.push(result);
207 }
208
209 results
210}
211
212pub fn print_sync_results(results: &[SyncResult]) {
213 for result in results {
214 match result {
215 SyncResult::Created { email, profile } => {
216 CliService::success(&format!(
217 "Created admin user '{}' in profile '{}'",
218 email, profile
219 ));
220 },
221 SyncResult::Promoted { email, profile } => {
222 CliService::success(&format!(
223 "Promoted existing user '{}' to admin in profile '{}'",
224 email, profile
225 ));
226 },
227 SyncResult::AlreadyAdmin { email, profile } => {
228 CliService::info(&format!(
229 "User '{}' is already admin in profile '{}'",
230 email, profile
231 ));
232 },
233 SyncResult::ConnectionFailed { profile, error } => {
234 CliService::warning(&format!(
235 "Could not connect to profile '{}': {}",
236 profile, error
237 ));
238 },
239 SyncResult::Failed { profile, error } => {
240 CliService::warning(&format!(
241 "Failed to sync admin to profile '{}': {}",
242 profile, error
243 ));
244 },
245 }
246 }
247}