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, 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 = args
46        .email
47        .clone()
48        .filter(|e| !e.trim().is_empty())
49        .unwrap_or_else(|| format!("{name}@localhost"));
50
51    let user_service = connect_user_service().await?;
52
53    let (user, created) = if let Some(existing) = user_service.find_by_name(&name).await? {
54        (existing, false)
55    } else {
56        let created = user_service
57            .create(&name, &email, Some(&args.full_name), None)
58            .await?;
59        (created, true)
60    };
61
62    if !user.is_active() {
63        return Err(anyhow!(
64            "Bootstrap user '{}' exists but has status '{}'; expected '{}'. Re-activate it before \
65             running the platform.",
66            user.name,
67            user.status.as_deref().unwrap_or("(none)"),
68            UserStatus::Active.as_str(),
69        ));
70    }
71
72    let user = ensure_admin_role(&user_service, user).await?;
73
74    Ok(build_output(user, created))
75}
76
77fn resolve_admin_name(requested: Option<&str>) -> Result<String> {
78    let configured = Config::get()?.system_admin_username.clone();
79    if configured.trim().is_empty() {
80        return Err(anyhow!(
81            "Profile is missing `system_admin.username`; cannot run bootstrap"
82        ));
83    }
84
85    match requested {
86        Some(n) if !n.trim().is_empty() => {
87            if n != configured {
88                return Err(anyhow!(
89                    "--name '{}' does not match profile system_admin.username '{}'; refusing to \
90                     bootstrap the wrong user",
91                    n,
92                    configured,
93                ));
94            }
95            Ok(n.to_owned())
96        },
97        _ => Ok(configured),
98    }
99}
100
101// Why: bootstrap must run before AppContext::build, because AppContext
102// resolution requires the admin row to already exist. Open a database
103// pool directly so SystemAdmin does not need to be installed yet.
104async fn connect_user_service() -> Result<UserService> {
105    let database: DbPool = Arc::new(
106        Database::from_config_with_write(
107            &Config::get()?.database_type,
108            &Config::get()?.database_url,
109            Config::get()?.database_write_url.as_deref(),
110            &systemprompt_database::PoolConfig::default(),
111        )
112        .await
113        .context("Failed to connect to database")?,
114    );
115    Ok(UserService::new(&database)?)
116}
117
118async fn ensure_admin_role(user_service: &UserService, user: User) -> Result<User> {
119    let admin_role = UserRole::Admin.as_str().to_owned();
120
121    let user = if user.roles.contains(&admin_role) {
122        user
123    } else {
124        let mut next_roles = user.roles.clone();
125        next_roles.push(admin_role.clone());
126        user_service.assign_roles(&user.id, &next_roles).await?
127    };
128
129    if !user.roles.contains(&admin_role) {
130        return Err(anyhow!(
131            "Failed to assign 'admin' role to bootstrap user '{}'",
132            user.name
133        ));
134    }
135
136    Ok(user)
137}
138
139fn build_output(user: User, created: bool) -> CommandOutput {
140    let message = if created {
141        format!(
142            "Bootstrap user '{}' created and granted admin role",
143            user.name
144        )
145    } else {
146        format!(
147            "Bootstrap user '{}' already exists; admin role verified",
148            user.name
149        )
150    };
151
152    let output = BootstrapOutput {
153        id: user.id,
154        name: user.name,
155        email: user.email,
156        created,
157        roles: user.roles,
158        message,
159    };
160
161    let title = if created {
162        "Admin Bootstrapped"
163    } else {
164        "Admin Verified"
165    };
166    CommandOutput::card_value(title, &output)
167}