systemprompt_cli/commands/admin/setup/
wizard.rs1use 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};
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 &env_name,
85 &project_root,
86 &pg_config,
87 )?;
88
89 let run_migrations = should_run_migrations(&args, prompter, config)?;
90 if run_migrations {
91 profile::run_migrations(&profile_path)?;
92 }
93
94 let output = SetupOutput {
95 environment: env_name.clone(),
96 profile_path: profile_path.to_string_lossy().to_string(),
97 database: database_info(&pg_config, connection_status, args.docker),
98 secrets_configured: secrets_info(&secrets_data),
99 migrations_run: run_migrations,
100 message: format!("Environment '{}' setup completed successfully", env_name),
101 };
102
103 if !config.is_json_output() {
104 print_summary(&env_name, &profile_path);
105 }
106
107 let result = CommandOutput::card_value("Setup Complete", &output);
108 if config.is_json_output() {
109 Ok(result)
110 } else {
111 Ok(result.with_skip_render())
112 }
113}
114
115fn announce_project(project_root: &Path, config: &CliConfig) {
116 if config.is_json_output() {
117 return;
118 }
119 let project_name = project_root
120 .file_name()
121 .and_then(|n| n.to_str())
122 .unwrap_or("systemprompt");
123 CliService::success(&format!(
124 "Project: {} ({})",
125 project_name,
126 project_root.display()
127 ));
128}
129
130fn confirm_setup(
131 args: &SetupArgs,
132 prompter: &dyn Prompter,
133 env_name: &str,
134 config: &CliConfig,
135) -> Result<bool> {
136 if args.dry_run || args.yes || !config.is_interactive() {
137 return Ok(true);
138 }
139 prompter.confirm(
140 &format!(
141 "This will create/update configuration for '{}' environment. Continue?",
142 env_name
143 ),
144 true,
145 )
146}
147
148#[expect(
149 clippy::too_many_arguments,
150 reason = "wizard step threads discrete, already-validated setup inputs"
151)]
152fn write_configuration(
153 args: &SetupArgs,
154 prompter: &dyn Prompter,
155 config: &CliConfig,
156 env_name: &str,
157 project_root: &Path,
158 pg_config: &PostgresConfig,
159) -> Result<(secrets::SecretsData, PathBuf)> {
160 let systemprompt_dir = project_root.join(".systemprompt");
161
162 let admin_email = resolve_admin_email(args, prompter, config)?;
163
164 let (mut secrets_data, primary_provider) = collect_secrets(args, prompter, config, env_name)?;
165 secrets_data.database_url = Some(pg_config.database_url());
166
167 let secrets_path = profile::profile_dir(&systemprompt_dir, env_name).join("secrets.json");
168 if should_write(&secrets_path, args.force, config) {
169 secrets::save(&secrets_data, &secrets_path)?;
170 }
171
172 let profile_data = profile::build(&profile::ProfileBuildParams {
173 env_name,
174 secrets_path: "secrets.json",
175 project_root,
176 bin_path: None,
177 secrets: &secrets_data,
178 default_provider: primary_provider.as_ref(),
179 port_offset: args.port_offset,
180 admin_email: &admin_email,
181 })?;
182 let profile_path = profile::default_path(&systemprompt_dir, env_name);
183 if should_write(&profile_path, args.force, config) {
184 profile::save(&profile_data, &profile_path)?;
185 }
186
187 if let Some(primary) = primary_provider.as_ref() {
188 ai_config::reconcile(project_root, primary, &secrets_data, config)?;
189 }
190
191 Ok((secrets_data, profile_path))
192}
193
194pub fn database_info(
195 pg_config: &PostgresConfig,
196 connection_status: &str,
197 docker: bool,
198) -> DatabaseSetupInfo {
199 DatabaseSetupInfo {
200 host: pg_config.host.clone(),
201 port: pg_config.port,
202 name: pg_config.database.clone(),
203 user: pg_config.user.clone(),
204 connection_status: connection_status.to_owned(),
205 docker,
206 }
207}
208
209const fn secrets_info(secrets_data: &secrets::SecretsData) -> SecretsConfiguredInfo {
210 SecretsConfiguredInfo {
211 anthropic: secrets_data.anthropic.is_some(),
212 openai: secrets_data.openai.is_some(),
213 gemini: secrets_data.gemini.is_some(),
214 github: secrets_data.github.is_some(),
215 }
216}
217
218pub fn build_cancelled(args: &SetupArgs, env_name: &str, config: &CliConfig) -> CommandOutput {
219 let output = SetupOutput {
220 environment: env_name.to_owned(),
221 profile_path: String::new(),
222 database: DatabaseSetupInfo {
223 host: args.db_host.clone(),
224 port: args.db_port,
225 name: args.effective_db_name(env_name),
226 user: args.effective_db_user(env_name),
227 connection_status: "cancelled".to_owned(),
228 docker: args.docker,
229 },
230 secrets_configured: SecretsConfiguredInfo {
231 anthropic: args.anthropic_key.is_some(),
232 openai: args.openai_key.is_some(),
233 gemini: args.gemini_key.is_some(),
234 github: args.github_token.is_some(),
235 },
236 migrations_run: false,
237 message: "Setup cancelled by user".to_owned(),
238 };
239
240 if !config.is_json_output() {
241 CliService::info("Setup cancelled");
242 }
243
244 let result = CommandOutput::card_value("Setup Cancelled", &output);
245 if config.is_json_output() {
246 result
247 } else {
248 result.with_skip_render()
249 }
250}