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_manifest::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_str(),
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::connect(
115 &Config::get()?.database_url,
116 Config::get()?.database_write_url.as_deref(),
117 &systemprompt_database::PoolConfig::default(),
118 )
119 .await
120 .context("Failed to connect to database")?,
121 );
122 Ok(UserService::new(Arc::new(UserRepository::new(&database))))
123}
124
125async fn ensure_admin_role(user_service: &UserService, user: User) -> Result<User> {
126 let admin_role = UserRole::Admin.as_str().to_owned();
127
128 let user = if user.roles.contains(&admin_role) {
129 user
130 } else {
131 let mut next_roles = user.roles.clone();
132 next_roles.push(admin_role.clone());
133 user_service.assign_roles(&user.id, &next_roles).await?
134 };
135
136 if !user.roles.contains(&admin_role) {
137 return Err(anyhow!(
138 "Failed to assign 'admin' role to bootstrap user '{}'",
139 user.name
140 ));
141 }
142
143 Ok(user)
144}
145
146fn build_output(user: User, created: bool) -> CommandOutput {
147 let message = if created {
148 format!(
149 "Bootstrap user '{}' created and granted admin role",
150 user.name
151 )
152 } else {
153 format!(
154 "Bootstrap user '{}' already exists; admin role verified",
155 user.name
156 )
157 };
158
159 let output = BootstrapOutput {
160 id: user.id,
161 name: user.name,
162 email: user.email,
163 created,
164 roles: user.roles,
165 message,
166 };
167
168 let title = if created {
169 "Admin Bootstrapped"
170 } else {
171 "Admin Verified"
172 };
173 CommandOutput::card_value(title, &output)
174}