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