Skip to main content

systemprompt_cli/commands/admin/config/
server.rs

1//! `admin config server` command: show and edit profile server settings.
2//!
3//! [`ServerCommands`] reports and updates host, port, URLs, and HTTPS settings,
4//! and manages the CORS allowed-origins list, persisting changes to the active
5//! profile.
6
7use anyhow::{Context, Result, bail};
8use clap::{Args, Subcommand};
9use std::fs;
10use systemprompt_config::ProfileBootstrap;
11use systemprompt_logging::CliService;
12use systemprompt_models::Profile;
13
14use super::types::{CorsListOutput, CorsModifyOutput, ServerConfigOutput, ServerSetOutput};
15use crate::CliConfig;
16use crate::cli_settings::OutputFormat;
17use crate::shared::{CommandResult, render_result};
18
19#[derive(Debug, Subcommand)]
20pub enum ServerCommands {
21    #[command(about = "Show server configuration")]
22    Show,
23
24    #[command(about = "Set server configuration value")]
25    Set(SetArgs),
26
27    #[command(subcommand, about = "Manage CORS allowed origins")]
28    Cors(CorsCommands),
29}
30
31#[derive(Debug, Clone, Args)]
32pub struct SetArgs {
33    #[arg(long, help = "Server host address")]
34    pub host: Option<String>,
35
36    #[arg(long, help = "Server port")]
37    pub port: Option<u16>,
38
39    #[arg(long, help = "Enable/disable HTTPS")]
40    pub use_https: Option<bool>,
41
42    #[arg(long, help = "API server URL")]
43    pub api_server_url: Option<String>,
44
45    #[arg(long, help = "API internal URL")]
46    pub api_internal_url: Option<String>,
47
48    #[arg(long, help = "API external URL")]
49    pub api_external_url: Option<String>,
50}
51
52#[derive(Debug, Subcommand)]
53pub enum CorsCommands {
54    #[command(about = "List CORS allowed origins")]
55    List,
56
57    #[command(about = "Add a CORS origin")]
58    Add(CorsAddArgs),
59
60    #[command(about = "Remove a CORS origin")]
61    Remove(CorsRemoveArgs),
62}
63
64#[derive(Debug, Clone, Args)]
65pub struct CorsAddArgs {
66    #[arg(help = "Origin URL to add (e.g., https://example.com)")]
67    pub origin: String,
68}
69
70#[derive(Debug, Clone, Args)]
71pub struct CorsRemoveArgs {
72    #[arg(help = "Origin URL to remove")]
73    pub origin: String,
74}
75
76pub fn execute(command: &ServerCommands, config: &CliConfig) -> Result<()> {
77    match command {
78        ServerCommands::Show => execute_show(config),
79        ServerCommands::Set(args) => execute_set(args, config),
80        ServerCommands::Cors(cmd) => execute_cors(cmd, config),
81    }
82}
83
84pub(super) fn execute_show(_config: &CliConfig) -> Result<()> {
85    let profile = ProfileBootstrap::get()?;
86
87    let output = ServerConfigOutput {
88        host: profile.server.host.clone(),
89        port: profile.server.port,
90        api_server_url: profile.server.api_server_url.clone(),
91        api_internal_url: profile.server.api_internal_url.clone(),
92        api_external_url: profile.server.api_external_url.clone(),
93        use_https: profile.server.use_https,
94        cors_allowed_origins: profile.server.cors_allowed_origins.clone(),
95    };
96
97    render_result(&CommandResult::card(output).with_title("Server Configuration"));
98
99    Ok(())
100}
101
102pub(super) fn execute_set(args: &SetArgs, config: &CliConfig) -> Result<()> {
103    if args.host.is_none()
104        && args.port.is_none()
105        && args.use_https.is_none()
106        && args.api_server_url.is_none()
107        && args.api_internal_url.is_none()
108        && args.api_external_url.is_none()
109    {
110        bail!(
111            "Must specify at least one option: --host, --port, --use-https, --api-server-url, \
112             --api-internal-url, --api-external-url"
113        );
114    }
115
116    let profile_path = ProfileBootstrap::get_path()?;
117    let mut profile = load_profile(profile_path)?;
118
119    let mut changes: Vec<ServerSetOutput> = Vec::new();
120
121    if let Some(ref host) = args.host {
122        let old = profile.server.host.clone();
123        profile.server.host.clone_from(host);
124        changes.push(ServerSetOutput {
125            field: "host".to_owned(),
126            old_value: old,
127            new_value: host.clone(),
128            message: format!("Updated host to {}", host),
129        });
130    }
131
132    if let Some(port) = args.port {
133        let old = profile.server.port;
134        profile.server.port = port;
135        changes.push(ServerSetOutput {
136            field: "port".to_owned(),
137            old_value: old.to_string(),
138            new_value: port.to_string(),
139            message: format!("Updated port to {}", port),
140        });
141    }
142
143    if let Some(use_https) = args.use_https {
144        let old = profile.server.use_https;
145        profile.server.use_https = use_https;
146        changes.push(ServerSetOutput {
147            field: "use_https".to_owned(),
148            old_value: old.to_string(),
149            new_value: use_https.to_string(),
150            message: format!("Updated use_https to {}", use_https),
151        });
152    }
153
154    if let Some(ref url) = args.api_server_url {
155        let old = profile.server.api_server_url.clone();
156        profile.server.api_server_url.clone_from(url);
157        changes.push(ServerSetOutput {
158            field: "api_server_url".to_owned(),
159            old_value: old,
160            new_value: url.clone(),
161            message: format!("Updated api_server_url to {}", url),
162        });
163    }
164
165    if let Some(ref url) = args.api_internal_url {
166        let old = profile.server.api_internal_url.clone();
167        profile.server.api_internal_url.clone_from(url);
168        changes.push(ServerSetOutput {
169            field: "api_internal_url".to_owned(),
170            old_value: old,
171            new_value: url.clone(),
172            message: format!("Updated api_internal_url to {}", url),
173        });
174    }
175
176    if let Some(ref url) = args.api_external_url {
177        let old = profile.server.api_external_url.clone();
178        profile.server.api_external_url.clone_from(url);
179        changes.push(ServerSetOutput {
180            field: "api_external_url".to_owned(),
181            old_value: old,
182            new_value: url.clone(),
183            message: format!("Updated api_external_url to {}", url),
184        });
185    }
186
187    save_profile(&profile, profile_path)?;
188
189    for change in &changes {
190        render_result(&CommandResult::text(change.clone()).with_title("Server Updated"));
191    }
192
193    if config.output_format() == OutputFormat::Table {
194        CliService::warning("Restart services for changes to take effect");
195    }
196
197    Ok(())
198}
199
200fn execute_cors(command: &CorsCommands, config: &CliConfig) -> Result<()> {
201    match command {
202        CorsCommands::List => execute_cors_list(),
203        CorsCommands::Add(args) => execute_cors_add(args, config),
204        CorsCommands::Remove(args) => execute_cors_remove(args, config),
205    }
206}
207
208fn execute_cors_list() -> Result<()> {
209    let profile = ProfileBootstrap::get()?;
210
211    let output = CorsListOutput {
212        origins: profile.server.cors_allowed_origins.clone(),
213        count: profile.server.cors_allowed_origins.len(),
214    };
215
216    render_result(&CommandResult::list(output).with_title("CORS Allowed Origins"));
217
218    Ok(())
219}
220
221fn execute_cors_add(args: &CorsAddArgs, config: &CliConfig) -> Result<()> {
222    let profile_path = ProfileBootstrap::get_path()?;
223    let mut profile = load_profile(profile_path)?;
224
225    if profile.server.cors_allowed_origins.contains(&args.origin) {
226        let output = CorsModifyOutput {
227            action: "skipped".to_owned(),
228            origin: args.origin.clone(),
229            message: format!("Origin {} already exists", args.origin),
230        };
231        render_result(&CommandResult::text(output).with_title("CORS Origin"));
232        return Ok(());
233    }
234
235    profile
236        .server
237        .cors_allowed_origins
238        .push(args.origin.clone());
239    save_profile(&profile, profile_path)?;
240
241    let output = CorsModifyOutput {
242        action: "added".to_owned(),
243        origin: args.origin.clone(),
244        message: format!("Added CORS origin: {}", args.origin),
245    };
246    render_result(&CommandResult::text(output).with_title("CORS Origin Added"));
247
248    if config.output_format() == OutputFormat::Table {
249        CliService::warning("Restart services for changes to take effect");
250    }
251
252    Ok(())
253}
254
255fn execute_cors_remove(args: &CorsRemoveArgs, config: &CliConfig) -> Result<()> {
256    let profile_path = ProfileBootstrap::get_path()?;
257    let mut profile = load_profile(profile_path)?;
258
259    let original_len = profile.server.cors_allowed_origins.len();
260    profile
261        .server
262        .cors_allowed_origins
263        .retain(|o| o != &args.origin);
264
265    if profile.server.cors_allowed_origins.len() == original_len {
266        let output = CorsModifyOutput {
267            action: "skipped".to_owned(),
268            origin: args.origin.clone(),
269            message: format!("Origin {} not found", args.origin),
270        };
271        render_result(&CommandResult::text(output).with_title("CORS Origin"));
272        return Ok(());
273    }
274
275    save_profile(&profile, profile_path)?;
276
277    let output = CorsModifyOutput {
278        action: "removed".to_owned(),
279        origin: args.origin.clone(),
280        message: format!("Removed CORS origin: {}", args.origin),
281    };
282    render_result(&CommandResult::text(output).with_title("CORS Origin Removed"));
283
284    if config.output_format() == OutputFormat::Table {
285        CliService::warning("Restart services for changes to take effect");
286    }
287
288    Ok(())
289}
290
291fn load_profile(path: &str) -> Result<Profile> {
292    let content =
293        fs::read_to_string(path).with_context(|| format!("Failed to read profile: {}", path))?;
294    let profile: Profile = serde_yaml::from_str(&content)
295        .with_context(|| format!("Failed to parse profile: {}", path))?;
296    Ok(profile)
297}
298
299pub(super) fn save_profile(profile: &Profile, path: &str) -> Result<()> {
300    let content = serde_yaml::to_string(profile).context("Failed to serialize profile")?;
301    fs::write(path, content).with_context(|| format!("Failed to write profile: {}", path))?;
302    Ok(())
303}