Skip to main content

systemprompt_cli/commands/admin/setup/
wizard.rs

1//! Top-level orchestration of the setup wizard.
2//!
3//! `execute` runs the end-to-end flow: detect the project root, resolve the
4//! environment, provision `PostgreSQL`, collect secrets, write the profile, and
5//! optionally run migrations, returning a [`SetupOutput`]. The dry-run and
6//! cancellation paths short-circuit without touching the filesystem.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use std::path::{Path, PathBuf};
12
13use crate::interactive::Prompter;
14use crate::shared::CommandOutput;
15use anyhow::Result;
16use systemprompt_logging::CliService;
17
18use super::common::PostgresConfig;
19use super::types::{DatabaseSetupInfo, SecretsConfiguredInfo, SetupOutput};
20use super::wizard_dry_run::execute_dry_run;
21use super::wizard_prompts::{
22    collect_secrets, detect_project_root, get_environment_name, print_summary, resolve_admin_email,
23    setup_postgres, should_run_migrations,
24};
25use super::{SetupArgs, ai_config, common, profile, secrets, services_files};
26use crate::CliConfig;
27
28pub fn should_write(path: &Path, force: bool, config: &CliConfig) -> bool {
29    if force || !path.exists() {
30        return true;
31    }
32    if !config.is_json_output() {
33        CliService::info(&format!(
34            "Preserving existing {} (pass --force to overwrite)",
35            path.display()
36        ));
37    }
38    false
39}
40
41pub(super) async fn execute(
42    args: SetupArgs,
43    prompter: &dyn Prompter,
44    config: &CliConfig,
45) -> Result<CommandOutput> {
46    if !config.is_json_output() {
47        CliService::section("systemprompt.io Setup Wizard");
48    }
49
50    let project_root = detect_project_root()?;
51    announce_project(&project_root, config);
52
53    let env_name = get_environment_name(&args, prompter, config)?;
54
55    if !config.is_json_output() {
56        CliService::info(&format!("Configuring environment: {}", env_name));
57    }
58
59    if !confirm_setup(&args, prompter, &env_name, config)? {
60        return Ok(build_cancelled(&args, &env_name, config));
61    }
62
63    if args.dry_run {
64        let systemprompt_dir = project_root.join(".systemprompt");
65        return Ok(execute_dry_run(&args, &env_name, &systemprompt_dir, config));
66    }
67
68    if !config.is_json_output() {
69        CliService::section(&format!("Setting up '{}' environment", env_name));
70    }
71
72    let pg_config = setup_postgres(&args, prompter, config, &env_name).await?;
73
74    let connection_status = if common::test_connection(&pg_config).await {
75        "connected"
76    } else {
77        "unreachable"
78    };
79
80    let (secrets_data, profile_path) = write_configuration(
81        &args,
82        prompter,
83        config,
84        &SetupTarget {
85            env_name: &env_name,
86            project_root: &project_root,
87            pg_config: &pg_config,
88        },
89    )?;
90
91    let run_migrations = should_run_migrations(&args, prompter, config)?;
92    if run_migrations {
93        profile::run_migrations(&profile_path)?;
94    }
95
96    let output = SetupOutput {
97        environment: env_name.clone(),
98        profile_path: profile_path.to_string_lossy().to_string(),
99        database: database_info(&pg_config, connection_status, args.docker),
100        secrets_configured: secrets_info(&secrets_data),
101        migrations_run: run_migrations,
102        message: format!("Environment '{}' setup completed successfully", env_name),
103    };
104
105    if !config.is_json_output() {
106        print_summary(&env_name, &profile_path);
107    }
108
109    let result = CommandOutput::card_value("Setup Complete", &output);
110    if config.is_json_output() {
111        Ok(result)
112    } else {
113        Ok(result.with_skip_render())
114    }
115}
116
117fn announce_project(project_root: &Path, config: &CliConfig) {
118    if config.is_json_output() {
119        return;
120    }
121    let project_name = project_root
122        .file_name()
123        .and_then(|n| n.to_str())
124        .unwrap_or("systemprompt");
125    CliService::success(&format!(
126        "Project: {} ({})",
127        project_name,
128        project_root.display()
129    ));
130}
131
132fn confirm_setup(
133    args: &SetupArgs,
134    prompter: &dyn Prompter,
135    env_name: &str,
136    config: &CliConfig,
137) -> Result<bool> {
138    if args.dry_run || args.yes || !config.is_interactive() {
139        return Ok(true);
140    }
141    prompter.confirm(
142        &format!(
143            "This will create/update configuration for '{}' environment. Continue?",
144            env_name
145        ),
146        true,
147    )
148}
149
150#[derive(Clone, Copy)]
151struct SetupTarget<'a> {
152    env_name: &'a str,
153    project_root: &'a Path,
154    pg_config: &'a PostgresConfig,
155}
156
157fn write_configuration(
158    args: &SetupArgs,
159    prompter: &dyn Prompter,
160    config: &CliConfig,
161    target: &SetupTarget<'_>,
162) -> Result<(secrets::SecretsData, PathBuf)> {
163    let SetupTarget {
164        env_name,
165        project_root,
166        pg_config,
167    } = *target;
168    let systemprompt_dir = project_root.join(".systemprompt");
169
170    let admin_email = resolve_admin_email(args, prompter, config)?;
171
172    let (mut secrets_data, primary_provider) = collect_secrets(args, prompter, config, env_name)?;
173    secrets_data.database_url = Some(pg_config.database_url());
174
175    let secrets_path = profile::profile_dir(&systemprompt_dir, env_name).join("secrets.json");
176    if should_write(&secrets_path, args.force, config) {
177        secrets::save(&secrets_data, &secrets_path)?;
178    }
179
180    let profile_data = profile::build(&profile::ProfileBuildParams {
181        env_name,
182        secrets_path: "secrets.json",
183        project_root,
184        bin_path: None,
185        port_offset: args.port_offset,
186        admin_email: &admin_email,
187    })?;
188    let profile_path = profile::default_path(&systemprompt_dir, env_name);
189    if should_write(&profile_path, args.force, config) {
190        profile::save(&profile_data, &profile_path)?;
191    }
192
193    services_files::seed(
194        &project_root.join("services"),
195        &secrets_data,
196        primary_provider.as_ref(),
197        args.force,
198    )?;
199
200    if let Some(primary) = primary_provider.as_ref() {
201        ai_config::reconcile(project_root, primary, &secrets_data, config)?;
202    }
203
204    Ok((secrets_data, profile_path))
205}
206
207pub fn database_info(
208    pg_config: &PostgresConfig,
209    connection_status: &str,
210    docker: bool,
211) -> DatabaseSetupInfo {
212    DatabaseSetupInfo {
213        host: pg_config.host.clone(),
214        port: pg_config.port,
215        name: pg_config.database.clone(),
216        user: pg_config.user.clone(),
217        connection_status: connection_status.to_owned(),
218        docker,
219    }
220}
221
222const fn secrets_info(secrets_data: &secrets::SecretsData) -> SecretsConfiguredInfo {
223    SecretsConfiguredInfo {
224        anthropic: secrets_data.anthropic.is_some(),
225        openai: secrets_data.openai.is_some(),
226        gemini: secrets_data.gemini.is_some(),
227        github: secrets_data.github.is_some(),
228    }
229}
230
231pub fn build_cancelled(args: &SetupArgs, env_name: &str, config: &CliConfig) -> CommandOutput {
232    let output = SetupOutput {
233        environment: env_name.to_owned(),
234        profile_path: String::new(),
235        database: DatabaseSetupInfo {
236            host: args.db_host.clone(),
237            port: args.db_port,
238            name: args.effective_db_name(env_name),
239            user: args.effective_db_user(env_name),
240            connection_status: "cancelled".to_owned(),
241            docker: args.docker,
242        },
243        secrets_configured: SecretsConfiguredInfo {
244            anthropic: args.anthropic_key.is_some(),
245            openai: args.openai_key.is_some(),
246            gemini: args.gemini_key.is_some(),
247            github: args.github_token.is_some(),
248        },
249        migrations_run: false,
250        message: "Setup cancelled by user".to_owned(),
251    };
252
253    if !config.is_json_output() {
254        CliService::info("Setup cancelled");
255    }
256
257    let result = CommandOutput::card_value("Setup Cancelled", &output);
258    if config.is_json_output() {
259        result
260    } else {
261        result.with_skip_render()
262    }
263}