Skip to main content

systemprompt_cli/commands/admin/
bootstrap.rs

1//! `admin bootstrap` command: ensures the system-admin user row exists.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use 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}