systemprompt_cli/commands/admin/
bootstrap.rs1use std::sync::Arc;
7
8use anyhow::{Context, Result, anyhow};
9use clap::Args;
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use systemprompt_database::{Database, DbPool};
13use systemprompt_identifiers::UserId;
14use systemprompt_models::Config;
15use systemprompt_users::{User, UserRepository, UserRole, UserService, UserStatus};
16
17use crate::CliConfig;
18use crate::shared::CommandOutput;
19
20#[derive(Debug, Args)]
21pub struct BootstrapArgs {
22 #[arg(long)]
23 pub name: Option<String>,
24
25 #[arg(long)]
26 pub email: Option<String>,
27
28 #[arg(long, default_value = "Platform Admin")]
29 pub full_name: String,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
33pub struct BootstrapOutput {
34 pub id: UserId,
35 pub name: String,
36 pub email: String,
37 pub created: bool,
38 pub roles: Vec<String>,
39 pub message: String,
40}
41
42pub async fn execute(args: BootstrapArgs, _config: &CliConfig) -> Result<CommandOutput> {
43 let name = resolve_admin_name(args.name.as_deref())?;
44
45 let email = resolve_admin_email(args.email.as_deref())?;
46
47 let user_service = connect_user_service().await?;
48
49 let (user, created) = if let Some(existing) = user_service.find_by_name(&name).await? {
50 (existing, false)
51 } else {
52 let Some(email) = email else {
53 return Err(anyhow!(
54 "No admin email configured for '{name}'. Set `system_admin.email` in the profile \
55 or pass --email."
56 ));
57 };
58 let created = user_service
59 .create(&name, email.as_str(), Some(&args.full_name), None)
60 .await?;
61 (created, true)
62 };
63
64 if !user.is_active() {
65 return Err(anyhow!(
66 "Bootstrap user '{}' exists but has status '{}'; expected '{}'. Re-activate it before \
67 running the platform.",
68 user.name,
69 user.status.as_deref().unwrap_or("(none)"),
70 UserStatus::Active.as_str(),
71 ));
72 }
73
74 let user = ensure_admin_role(&user_service, user).await?;
75
76 Ok(build_output(user, created))
77}
78
79fn resolve_admin_email(requested: Option<&str>) -> Result<Option<systemprompt_identifiers::Email>> {
80 match requested.map(str::trim).filter(|e| !e.is_empty()) {
81 Some(e) => systemprompt_identifiers::Email::try_new(e)
82 .map(Some)
83 .map_err(|err| anyhow!("invalid --email '{e}': {err}")),
84 None => Ok(Config::get()?.system_admin_email.clone()),
85 }
86}
87
88fn resolve_admin_name(requested: Option<&str>) -> Result<String> {
89 let configured = Config::get()?.system_admin_username.clone();
90 if configured.trim().is_empty() {
91 return Err(anyhow!(
92 "Profile is missing `system_admin.username`; cannot run bootstrap"
93 ));
94 }
95
96 match requested {
97 Some(n) if !n.trim().is_empty() => {
98 if n != configured {
99 return Err(anyhow!(
100 "--name '{}' does not match profile system_admin.username '{}'; refusing to \
101 bootstrap the wrong user",
102 n,
103 configured,
104 ));
105 }
106 Ok(n.to_owned())
107 },
108 _ => Ok(configured),
109 }
110}
111
112async fn connect_user_service() -> Result<UserService> {
113 let database: DbPool = Arc::new(
114 Database::from_config_with_write(
115 &Config::get()?.database_type,
116 &Config::get()?.database_url,
117 Config::get()?.database_write_url.as_deref(),
118 &systemprompt_database::PoolConfig::default(),
119 )
120 .await
121 .context("Failed to connect to database")?,
122 );
123 Ok(UserService::new(Arc::new(UserRepository::new(&database)?)))
124}
125
126async fn ensure_admin_role(user_service: &UserService, user: User) -> Result<User> {
127 let admin_role = UserRole::Admin.as_str().to_owned();
128
129 let user = if user.roles.contains(&admin_role) {
130 user
131 } else {
132 let mut next_roles = user.roles.clone();
133 next_roles.push(admin_role.clone());
134 user_service.assign_roles(&user.id, &next_roles).await?
135 };
136
137 if !user.roles.contains(&admin_role) {
138 return Err(anyhow!(
139 "Failed to assign 'admin' role to bootstrap user '{}'",
140 user.name
141 ));
142 }
143
144 Ok(user)
145}
146
147fn build_output(user: User, created: bool) -> CommandOutput {
148 let message = if created {
149 format!(
150 "Bootstrap user '{}' created and granted admin role",
151 user.name
152 )
153 } else {
154 format!(
155 "Bootstrap user '{}' already exists; admin role verified",
156 user.name
157 )
158 };
159
160 let output = BootstrapOutput {
161 id: user.id,
162 name: user.name,
163 email: user.email,
164 created,
165 roles: user.roles,
166 message,
167 };
168
169 let title = if created {
170 "Admin Bootstrapped"
171 } else {
172 "Admin Verified"
173 };
174 CommandOutput::card_value(title, &output)
175}