Skip to main content

systemprompt_cli/commands/admin/
bootstrap.rs

1use std::sync::Arc;
2
3use anyhow::{Context, Result, anyhow};
4use clap::Args;
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use systemprompt_database::{Database, DbPool};
8use systemprompt_identifiers::UserId;
9use systemprompt_models::Config;
10use systemprompt_users::{UserRole, UserService, UserStatus};
11
12use crate::CliConfig;
13use crate::shared::CommandOutput;
14
15#[derive(Debug, Args)]
16pub struct BootstrapArgs {
17    #[arg(long)]
18    pub name: Option<String>,
19
20    #[arg(long)]
21    pub email: Option<String>,
22
23    #[arg(long, default_value = "Platform Admin")]
24    pub full_name: String,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
28pub struct BootstrapOutput {
29    pub id: UserId,
30    pub name: String,
31    pub email: String,
32    pub created: bool,
33    pub roles: Vec<String>,
34    pub message: String,
35}
36
37pub async fn execute(args: BootstrapArgs, _config: &CliConfig) -> Result<CommandOutput> {
38    let configured = Config::get()?.system_admin_username.clone();
39    if configured.trim().is_empty() {
40        return Err(anyhow!(
41            "Profile is missing `system_admin.username`; cannot run bootstrap"
42        ));
43    }
44
45    let name = match args.name.as_deref() {
46        Some(n) if !n.trim().is_empty() => {
47            if n != configured {
48                return Err(anyhow!(
49                    "--name '{}' does not match profile system_admin.username '{}'; refusing to \
50                     bootstrap the wrong user",
51                    n,
52                    configured,
53                ));
54            }
55            n.to_owned()
56        },
57        _ => configured.clone(),
58    };
59
60    let email = args
61        .email
62        .clone()
63        .filter(|e| !e.trim().is_empty())
64        .unwrap_or_else(|| format!("{name}@localhost"));
65
66    // Why: bootstrap must run before AppContext::build, because AppContext
67    // resolution requires the admin row to already exist. Open a database
68    // pool directly so SystemAdmin does not need to be installed yet.
69    let database: DbPool = Arc::new(
70        Database::from_config_with_write(
71            &Config::get()?.database_type,
72            &Config::get()?.database_url,
73            Config::get()?.database_write_url.as_deref(),
74        )
75        .await
76        .context("Failed to connect to database")?,
77    );
78    let user_service = UserService::new(&database)?;
79
80    let admin_role = UserRole::Admin.as_str().to_owned();
81
82    let (user, created) = if let Some(existing) = user_service.find_by_name(&name).await? {
83        (existing, false)
84    } else {
85        let created = user_service
86            .create(&name, &email, Some(&args.full_name), None)
87            .await?;
88        (created, true)
89    };
90
91    if !user.is_active() {
92        return Err(anyhow!(
93            "Bootstrap user '{}' exists but has status '{}'; expected '{}'. Re-activate it before \
94             running the platform.",
95            user.name,
96            user.status.as_deref().unwrap_or("(none)"),
97            UserStatus::Active.as_str(),
98        ));
99    }
100
101    let user = if user.roles.contains(&admin_role) {
102        user
103    } else {
104        let mut next_roles = user.roles.clone();
105        next_roles.push(admin_role.clone());
106        user_service.assign_roles(&user.id, &next_roles).await?
107    };
108
109    if !user.roles.contains(&admin_role) {
110        return Err(anyhow!(
111            "Failed to assign 'admin' role to bootstrap user '{}'",
112            user.name
113        ));
114    }
115
116    let message = if created {
117        format!(
118            "Bootstrap user '{}' created and granted admin role",
119            user.name
120        )
121    } else {
122        format!(
123            "Bootstrap user '{}' already exists; admin role verified",
124            user.name
125        )
126    };
127
128    let output = BootstrapOutput {
129        id: user.id,
130        name: user.name,
131        email: user.email,
132        created,
133        roles: user.roles,
134        message,
135    };
136
137    let title = if created {
138        "Admin Bootstrapped"
139    } else {
140        "Admin Verified"
141    };
142    Ok(CommandOutput::card_value(title, &output))
143}