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