1mod args;
8mod bootstrap;
9mod db_url;
10mod routing;
11
12use anyhow::{Context, Result, bail};
13use clap::Parser;
14use systemprompt_config::{ProfileBootstrap, SecretsBootstrap};
15use systemprompt_logging::set_startup_mode;
16use systemprompt_runtime::DatabaseContext;
17
18use crate::cli_settings::{self, CliConfig};
19use crate::commands::{admin, analytics, cloud, core, infrastructure, plugins, web};
20use crate::descriptor::{CommandDescriptor, DescribeCommand};
21
22enum RoutingAction {
23 ContinueLocal,
24 ExternalDbUrl(String),
25}
26
27fn has_local_export_flag(command: Option<&args::Commands>) -> bool {
28 let is_analytics = matches!(command, Some(args::Commands::Analytics(_)));
29 if !is_analytics {
30 return false;
31 }
32 std::env::args().any(|arg| arg == "--export" || arg.starts_with("--export="))
33}
34
35pub async fn run() -> Result<()> {
36 let cli = args::Cli::parse();
37
38 set_startup_mode(cli.command.is_none());
39
40 let cli_config = args::build_cli_config(&cli);
41 cli_settings::set_global_config(cli_config.clone());
42
43 if cli.display.no_color || !cli_config.should_use_color() {
44 console::set_colors_enabled(false);
45 }
46
47 if let Some(database_url) = cli.database.database_url.clone() {
48 match cli.command.as_ref().map(args::Commands::db_url_routing) {
49 Some(db_url::DbUrlRouting::Direct) => {
50 return run_with_database_url(cli.command, &cli_config, &database_url).await;
51 },
52 Some(db_url::DbUrlRouting::Unsupported) => bail!(
53 "This command cannot run with --database-url; it requires full profile \
54 initialization. Remove --database-url."
55 ),
56 Some(db_url::DbUrlRouting::ProfileDriven) | None => {},
57 }
58 }
59
60 let desc = cli
61 .command
62 .as_ref()
63 .map_or(CommandDescriptor::FULL, DescribeCommand::descriptor);
64
65 if !desc.database() {
66 let effective_level = resolve_log_level(&cli_config);
67 systemprompt_logging::init_console_logging_with_level(effective_level.as_deref());
68 }
69
70 if desc.profile() {
71 if let Some(external_db_url) = bootstrap_profile(&cli, &desc, &cli_config).await? {
72 return run_with_database_url(cli.command, &cli_config, &external_db_url).await;
73 }
74 }
75
76 dispatch_command(cli.command, &cli_config).await
77}
78
79async fn bootstrap_profile(
80 cli: &args::Cli,
81 desc: &CommandDescriptor,
82 cli_config: &CliConfig,
83) -> Result<Option<String>> {
84 let has_export = has_local_export_flag(cli.command.as_ref());
85 let ctx = bootstrap::resolve_and_display_profile(cli_config, has_export)?;
86
87 enforce_routing_policy(&ctx, cli, desc).await?;
88
89 let needs_cloud = is_cloud_bypass_command(cli.command.as_ref());
90 match initialize_post_routing(&ctx, desc, needs_cloud).await? {
91 RoutingAction::ExternalDbUrl(url) => Ok(Some(url)),
92 RoutingAction::ContinueLocal => Ok(None),
93 }
94}
95
96async fn enforce_routing_policy(
97 ctx: &bootstrap::ProfileContext,
98 cli: &args::Cli,
99 desc: &CommandDescriptor,
100) -> Result<()> {
101 if !ctx.env.is_fly && desc.remote_eligible() && !ctx.has_export {
102 let profile = ProfileBootstrap::get()?;
103 try_remote_routing(cli, profile).await?;
104 return Ok(());
105 }
106
107 if ctx.has_export && ctx.is_cloud && !ctx.external_db_access {
108 bail!(
109 "Export with cloud profile '{}' requires external database access.\nEnable \
110 external_db_access in the profile or use a local profile.",
111 ctx.profile_name
112 );
113 }
114
115 if ctx.is_cloud
116 && !ctx.env.is_fly
117 && !ctx.external_db_access
118 && !is_cloud_bypass_command(cli.command.as_ref())
119 {
120 bail!(
121 "Cloud profile '{}' selected but this command doesn't support remote execution.\nUse \
122 a local profile with --profile <name> or enable external database access.",
123 ctx.profile_name
124 );
125 }
126
127 Ok(())
128}
129
130const fn is_cloud_bypass_command(command: Option<&args::Commands>) -> bool {
131 matches!(
132 command,
133 Some(args::Commands::Cloud(_) | args::Commands::Admin(admin::AdminCommands::Session(_)))
134 )
135}
136
137async fn initialize_post_routing(
138 ctx: &bootstrap::ProfileContext,
139 desc: &CommandDescriptor,
140 needs_cloud: bool,
141) -> Result<RoutingAction> {
142 if needs_cloud || (ctx.is_cloud && ctx.external_db_access) {
147 bootstrap::init_credentials_gracefully(needs_cloud).await?;
148 }
149
150 if desc.secrets() {
151 bootstrap::init_secrets()?;
152 }
153
154 if ctx.is_cloud && ctx.external_db_access && desc.paths() && !ctx.env.is_fly {
155 let secrets = SecretsBootstrap::get().context("Secrets required for external DB access")?;
156 let db_url = secrets.effective_database_url(true).to_owned();
157 return Ok(RoutingAction::ExternalDbUrl(db_url));
158 }
159
160 if desc.paths() {
161 bootstrap::init_paths()?;
162 if !desc.skip_validation() {
163 bootstrap::run_validation()?;
164 }
165 }
166
167 if !ctx.is_cloud {
168 bootstrap::validate_cloud_credentials(&ctx.env);
169 }
170
171 Ok(RoutingAction::ContinueLocal)
172}
173
174async fn try_remote_routing(cli: &args::Cli, profile: &systemprompt_models::Profile) -> Result<()> {
175 let is_cloud = profile.target.is_cloud();
176
177 match routing::determine_execution_target() {
178 Ok(routing::ExecutionTarget::Remote {
179 hostname,
180 token,
181 context,
182 }) => {
183 let args = args::reconstruct_args(cli);
184 let exit_code = routing::remote::execute_remote(
185 &hostname,
186 token.as_str(),
187 context.as_str(),
188 &args,
189 300,
190 )
191 .await?;
192 if exit_code != 0 {
193 bail!("Remote command exited with code {}", exit_code);
194 }
195 return Ok(());
196 },
197 Ok(routing::ExecutionTarget::Local) if is_cloud => {
198 require_external_db_access(profile, "no tenant is configured")?;
199 },
200 Err(e) if is_cloud => {
201 require_external_db_access(profile, &format!("routing failed: {}", e))?;
202 },
203 _ => {},
204 }
205
206 Ok(())
207}
208
209fn require_external_db_access(profile: &systemprompt_models::Profile, reason: &str) -> Result<()> {
210 if profile.database.external_db_access {
211 tracing::debug!(
212 profile_name = %profile.name,
213 reason = reason,
214 "Cloud profile allowing local execution via external_db_access"
215 );
216 Ok(())
217 } else {
218 bail!(
219 "Cloud profile '{}' requires remote execution but {}.\nRun 'systemprompt admin \
220 session login' to authenticate.",
221 profile.name,
222 reason
223 )
224 }
225}
226
227fn resolve_log_level(cli_config: &CliConfig) -> Option<String> {
228 if std::env::var("RUST_LOG").is_ok() {
229 return None;
230 }
231
232 if let Some(level) = cli_config.verbosity.as_tracing_filter() {
233 return Some(level.to_owned());
234 }
235
236 if let Ok(profile_path) = bootstrap::resolve_profile(cli_config.profile_override.as_deref()) {
237 if let Some(log_level) = bootstrap::try_load_log_level(&profile_path) {
238 return Some(log_level.as_tracing_filter().to_owned());
239 }
240 }
241
242 Some("warn".to_owned())
243}
244
245async fn dispatch_command(command: Option<args::Commands>, config: &CliConfig) -> Result<()> {
246 match command {
247 Some(args::Commands::Core(cmd)) => core::execute(cmd, config).await?,
248 Some(args::Commands::Infra(cmd)) => infrastructure::execute(cmd, config).await?,
249 Some(args::Commands::Admin(cmd)) => admin::execute(cmd, config).await?,
250 Some(args::Commands::Cloud(cmd)) => cloud::execute(cmd, config).await?,
251 Some(args::Commands::Analytics(cmd)) => analytics::execute(cmd, config).await?,
252 Some(args::Commands::Web(cmd)) => web::execute(cmd)?,
253 Some(args::Commands::Plugins(cmd)) => plugins::execute(cmd, config).await?,
254 Some(args::Commands::Build(cmd)) => {
255 crate::commands::build::execute(cmd, config)?;
256 },
257 None => {
258 args::Cli::parse_from(["systemprompt", "--help"]);
259 },
260 }
261
262 Ok(())
263}
264
265async fn run_with_database_url(
266 command: Option<args::Commands>,
267 config: &CliConfig,
268 database_url: &str,
269) -> Result<()> {
270 let db_ctx = DatabaseContext::from_url(database_url)
271 .await
272 .context("Failed to connect to database")?;
273
274 systemprompt_logging::init_logging(db_ctx.db_pool_arc());
275
276 match command {
277 Some(args::Commands::Core(cmd)) => core::execute_with_db(cmd, &db_ctx, config).await,
278 Some(args::Commands::Infra(cmd)) => {
279 infrastructure::execute_with_db(cmd, &db_ctx, config).await
280 },
281 Some(args::Commands::Admin(cmd)) => admin::execute_with_db(cmd, &db_ctx, config).await,
282 Some(args::Commands::Analytics(cmd)) => {
283 analytics::execute_with_db(cmd, &db_ctx, config).await
284 },
285 Some(args::Commands::Cloud(cloud::CloudCommands::Db(cmd))) => {
286 cloud::db::execute_with_database_url(cmd, database_url, config).await
287 },
288 Some(_) => {
289 bail!("This command requires full profile initialization. Remove --database-url flag.")
290 },
291 None => bail!("No subcommand provided. Use --help to see available commands."),
292 }
293}