Skip to main content

systemprompt_cli/commands/cloud/sync/
admin_user.rs

1use 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}