forge_core_server/routes/
config.rs

1use std::collections::HashMap;
2
3use axum::{
4    Json, Router,
5    body::Body,
6    extract::{Path, Query, State},
7    http,
8    response::{Json as ResponseJson, Response},
9    routing::{get, put},
10};
11use forge_core_deployment::{Deployment, DeploymentError};
12use forge_core_executors::{
13    executors::{BaseAgentCapability, BaseCodingAgent, StandardCodingAgentExecutor},
14    mcp_config::{McpConfig, read_agent_config, write_agent_config},
15    profile::{ExecutorConfigs, ExecutorProfileId},
16};
17use forge_core_services::services::config::{Config, ConfigError, SoundFile, save_config_to_file};
18use forge_core_utils::{assets::config_path, response::ApiResponse};
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21use tokio::fs;
22use ts_rs_forge::TS;
23
24use crate::{DeploymentImpl, error::ApiError};
25
26pub fn router() -> Router<DeploymentImpl> {
27    Router::new()
28        .route("/info", get(get_user_system_info))
29        .route("/config", put(update_config))
30        .route("/sounds/{sound}", get(get_sound))
31        .route("/mcp-config", get(get_mcp_servers).post(update_mcp_servers))
32        .route("/profiles", get(get_profiles).put(update_profiles))
33}
34
35#[derive(Debug, Serialize, Deserialize, TS)]
36pub struct Environment {
37    pub os_type: String,
38    pub os_version: String,
39    pub os_architecture: String,
40    pub bitness: String,
41}
42
43impl Default for Environment {
44    fn default() -> Self {
45        Self::new()
46    }
47}
48
49impl Environment {
50    pub fn new() -> Self {
51        let info = os_info::get();
52        Environment {
53            os_type: info.os_type().to_string(),
54            os_version: info.version().to_string(),
55            os_architecture: info.architecture().unwrap_or("unknown").to_string(),
56            bitness: info.bitness().to_string(),
57        }
58    }
59}
60
61#[derive(Debug, Serialize, Deserialize, TS)]
62pub struct UserSystemInfo {
63    pub config: Config,
64    pub analytics_user_id: String,
65    #[serde(flatten)]
66    pub profiles: ExecutorConfigs,
67    pub environment: Environment,
68    /// Capabilities supported per executor (e.g., { "CLAUDE_CODE": ["SESSION_FORK"] })
69    pub capabilities: HashMap<String, Vec<BaseAgentCapability>>,
70}
71
72// TODO: update frontend, BE schema has changed, this replaces GET /config and /config/constants
73#[axum::debug_handler]
74async fn get_user_system_info(
75    State(deployment): State<DeploymentImpl>,
76) -> ResponseJson<ApiResponse<UserSystemInfo>> {
77    let config = deployment.config().read().await;
78
79    let user_system_info = UserSystemInfo {
80        config: config.clone(),
81        analytics_user_id: deployment.user_id().to_string(),
82        profiles: ExecutorConfigs::get_cached(),
83        environment: Environment::new(),
84        capabilities: {
85            let mut caps: HashMap<String, Vec<BaseAgentCapability>> = HashMap::new();
86            let profs = ExecutorConfigs::get_cached();
87            for key in profs.executors.keys() {
88                if let Some(agent) = profs.get_coding_agent(&ExecutorProfileId::new(*key)) {
89                    caps.insert(key.to_string(), agent.capabilities());
90                }
91            }
92            caps
93        },
94    };
95
96    ResponseJson(ApiResponse::success(user_system_info))
97}
98
99async fn update_config(
100    State(deployment): State<DeploymentImpl>,
101    Json(new_config): Json<Config>,
102) -> ResponseJson<ApiResponse<Config>> {
103    let config_path = config_path();
104
105    // Validate git branch prefix
106    if !forge_core_utils::git::is_valid_branch_prefix(&new_config.git_branch_prefix) {
107        return ResponseJson(ApiResponse::error(
108            "Invalid git branch prefix. Must be a valid git branch name component without slashes.",
109        ));
110    }
111
112    // Get old config state before updating
113    let old_config = deployment.config().read().await.clone();
114
115    match save_config_to_file(&new_config, &config_path).await {
116        Ok(_) => {
117            let mut config = deployment.config().write().await;
118            *config = new_config.clone();
119            drop(config);
120
121            // Track config events when fields transition from false → true and run side effects
122            handle_config_events(&deployment, &old_config, &new_config).await;
123
124            ResponseJson(ApiResponse::success(new_config))
125        }
126        Err(e) => ResponseJson(ApiResponse::error(&format!("Failed to save config: {}", e))),
127    }
128}
129
130/// Track config events when fields transition from false → true
131async fn track_config_events(deployment: &DeploymentImpl, old: &Config, new: &Config) {
132    let events = [
133        (
134            !old.disclaimer_acknowledged && new.disclaimer_acknowledged,
135            "onboarding_disclaimer_accepted",
136            serde_json::json!({}),
137        ),
138        (
139            !old.onboarding_acknowledged && new.onboarding_acknowledged,
140            "onboarding_completed",
141            serde_json::json!({
142                "profile": new.executor_profile,
143                "editor": new.editor
144            }),
145        ),
146        (
147            !old.github_login_acknowledged && new.github_login_acknowledged,
148            "onboarding_github_login_completed",
149            serde_json::json!({
150                "username": new.github.username,
151                "email": new.github.primary_email,
152                "auth_method": if new.github.oauth_token.is_some() { "oauth" }
153                              else if new.github.pat.is_some() { "pat" }
154                              else { "none" },
155                "has_default_pr_base": new.github.default_pr_base.is_some(),
156                "skipped": new.github.username.is_none()
157            }),
158        ),
159        (
160            !old.telemetry_acknowledged && new.telemetry_acknowledged,
161            "onboarding_telemetry_choice",
162            serde_json::json!({}),
163        ),
164        (
165            !old.analytics_enabled.unwrap_or(false) && new.analytics_enabled.unwrap_or(false),
166            "analytics_session_start",
167            serde_json::json!({}),
168        ),
169    ];
170
171    for (should_track, event_name, properties) in events {
172        if should_track {
173            deployment
174                .track_if_analytics_allowed(event_name, properties)
175                .await;
176        }
177    }
178}
179
180async fn handle_config_events(deployment: &DeploymentImpl, old: &Config, new: &Config) {
181    track_config_events(deployment, old, new).await;
182
183    if !old.disclaimer_acknowledged && new.disclaimer_acknowledged {
184        // Spawn auto project setup as background task to avoid blocking config response
185        let deployment_clone = deployment.clone();
186        tokio::spawn(async move {
187            deployment_clone.trigger_auto_project_setup().await;
188        });
189    }
190}
191
192async fn get_sound(Path(sound): Path<SoundFile>) -> Result<Response, ApiError> {
193    let sound = sound.serve().await.map_err(DeploymentError::Other)?;
194    let response = Response::builder()
195        .status(http::StatusCode::OK)
196        .header(
197            http::header::CONTENT_TYPE,
198            http::HeaderValue::from_static("audio/wav"),
199        )
200        .body(Body::from(sound.data.into_owned()))
201        .unwrap();
202    Ok(response)
203}
204
205#[derive(TS, Debug, Deserialize)]
206pub struct McpServerQuery {
207    executor: BaseCodingAgent,
208}
209
210#[derive(TS, Debug, Serialize, Deserialize)]
211pub struct GetMcpServerResponse {
212    // servers: HashMap<String, Value>,
213    mcp_config: McpConfig,
214    config_path: String,
215}
216
217#[derive(TS, Debug, Serialize, Deserialize)]
218pub struct UpdateMcpServersBody {
219    servers: HashMap<String, Value>,
220}
221
222async fn get_mcp_servers(
223    State(_deployment): State<DeploymentImpl>,
224    Query(query): Query<McpServerQuery>,
225) -> Result<ResponseJson<ApiResponse<GetMcpServerResponse>>, ApiError> {
226    let coding_agent = ExecutorConfigs::get_cached()
227        .get_coding_agent(&ExecutorProfileId::new(query.executor))
228        .ok_or(ConfigError::ValidationError(
229            "Executor not found".to_string(),
230        ))?;
231
232    if !coding_agent.supports_mcp() {
233        return Ok(ResponseJson(ApiResponse::error(
234            "MCP not supported by this executor",
235        )));
236    }
237
238    // Resolve supplied config path or agent default
239    let config_path = match coding_agent.default_mcp_config_path() {
240        Some(path) => path,
241        None => {
242            return Ok(ResponseJson(ApiResponse::error(
243                "Could not determine config file path",
244            )));
245        }
246    };
247
248    let mut mcpc = coding_agent.get_mcp_config();
249    let raw_config = read_agent_config(&config_path, &mcpc).await?;
250    let servers = get_mcp_servers_from_config_path(&raw_config, &mcpc.servers_path);
251    mcpc.set_servers(servers);
252    Ok(ResponseJson(ApiResponse::success(GetMcpServerResponse {
253        mcp_config: mcpc,
254        config_path: config_path.to_string_lossy().to_string(),
255    })))
256}
257
258async fn update_mcp_servers(
259    State(_deployment): State<DeploymentImpl>,
260    Query(query): Query<McpServerQuery>,
261    Json(payload): Json<UpdateMcpServersBody>,
262) -> Result<ResponseJson<ApiResponse<String>>, ApiError> {
263    let profiles = ExecutorConfigs::get_cached();
264    let agent = profiles
265        .get_coding_agent(&ExecutorProfileId::new(query.executor))
266        .ok_or(ConfigError::ValidationError(
267            "Executor not found".to_string(),
268        ))?;
269
270    if !agent.supports_mcp() {
271        return Ok(ResponseJson(ApiResponse::error(
272            "This executor does not support MCP servers",
273        )));
274    }
275
276    // Resolve supplied config path or agent default
277    let config_path = match agent.default_mcp_config_path() {
278        Some(path) => path.to_path_buf(),
279        None => {
280            return Ok(ResponseJson(ApiResponse::error(
281                "Could not determine config file path",
282            )));
283        }
284    };
285
286    let mcpc = agent.get_mcp_config();
287    match update_mcp_servers_in_config(&config_path, &mcpc, payload.servers).await {
288        Ok(message) => Ok(ResponseJson(ApiResponse::success(message))),
289        Err(e) => Ok(ResponseJson(ApiResponse::error(&format!(
290            "Failed to update MCP servers: {}",
291            e
292        )))),
293    }
294}
295
296async fn update_mcp_servers_in_config(
297    config_path: &std::path::Path,
298    mcpc: &McpConfig,
299    new_servers: HashMap<String, Value>,
300) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
301    // Ensure parent directory exists
302    if let Some(parent) = config_path.parent() {
303        fs::create_dir_all(parent).await?;
304    }
305    // Read existing config (JSON or TOML depending on agent)
306    let mut config = read_agent_config(config_path, mcpc).await?;
307
308    // Get the current server count for comparison
309    let old_servers = get_mcp_servers_from_config_path(&config, &mcpc.servers_path).len();
310
311    // Set the MCP servers using the correct attribute path
312    set_mcp_servers_in_config_path(&mut config, &mcpc.servers_path, &new_servers)?;
313
314    // Write the updated config back to file (JSON or TOML depending on agent)
315    write_agent_config(config_path, mcpc, &config).await?;
316
317    let new_count = new_servers.len();
318    let message = match (old_servers, new_count) {
319        (0, 0) => "No MCP servers configured".to_string(),
320        (0, n) => format!("Added {} MCP server(s)", n),
321        (old, new) if old == new => format!("Updated MCP server configuration ({} server(s))", new),
322        (old, new) => format!(
323            "Updated MCP server configuration (was {}, now {})",
324            old, new
325        ),
326    };
327
328    Ok(message)
329}
330
331/// Helper function to get MCP servers from config using a path
332fn get_mcp_servers_from_config_path(raw_config: &Value, path: &[String]) -> HashMap<String, Value> {
333    let mut current = raw_config;
334    for part in path {
335        current = match current.get(part) {
336            Some(val) => val,
337            None => return HashMap::new(),
338        };
339    }
340    // Extract the servers object
341    match current.as_object() {
342        Some(servers) => servers
343            .iter()
344            .map(|(k, v)| (k.clone(), v.clone()))
345            .collect(),
346        None => HashMap::new(),
347    }
348}
349
350/// Helper function to set MCP servers in config using a path
351fn set_mcp_servers_in_config_path(
352    raw_config: &mut Value,
353    path: &[String],
354    servers: &HashMap<String, Value>,
355) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
356    // Ensure config is an object
357    if !raw_config.is_object() {
358        *raw_config = serde_json::json!({});
359    }
360
361    let mut current = raw_config;
362    // Navigate/create the nested structure (all parts except the last)
363    for part in &path[..path.len() - 1] {
364        if current.get(part).is_none() {
365            current
366                .as_object_mut()
367                .unwrap()
368                .insert(part.to_string(), serde_json::json!({}));
369        }
370        current = current.get_mut(part).unwrap();
371        if !current.is_object() {
372            *current = serde_json::json!({});
373        }
374    }
375
376    // Set the final attribute
377    let final_attr = path.last().unwrap();
378    current
379        .as_object_mut()
380        .unwrap()
381        .insert(final_attr.to_string(), serde_json::to_value(servers)?);
382
383    Ok(())
384}
385
386#[derive(Debug, Serialize, Deserialize)]
387pub struct ProfilesContent {
388    pub content: String,
389    pub path: String,
390}
391
392async fn get_profiles(
393    State(_deployment): State<DeploymentImpl>,
394) -> ResponseJson<ApiResponse<ProfilesContent>> {
395    let profiles_path = forge_core_utils::assets::profiles_path();
396
397    // Use cached data to ensure consistency with runtime and PUT updates
398    let profiles = ExecutorConfigs::get_cached();
399
400    let content = serde_json::to_string_pretty(&profiles).unwrap_or_else(|e| {
401        tracing::error!("Failed to serialize profiles to JSON: {}", e);
402        serde_json::to_string_pretty(&ExecutorConfigs::from_defaults())
403            .unwrap_or_else(|_| "{}".to_string())
404    });
405
406    ResponseJson(ApiResponse::success(ProfilesContent {
407        content,
408        path: profiles_path.display().to_string(),
409    }))
410}
411
412async fn update_profiles(
413    State(_deployment): State<DeploymentImpl>,
414    body: String,
415) -> ResponseJson<ApiResponse<String>> {
416    // Try to parse as ExecutorProfileConfigs format
417    match serde_json::from_str::<ExecutorConfigs>(&body) {
418        Ok(executor_profiles) => {
419            // Update in-memory cache only (no file persistence)
420            match executor_profiles.update_cache() {
421                Ok(_) => {
422                    tracing::info!("Executor profiles cache updated successfully");
423                    ResponseJson(ApiResponse::success(
424                        "Executor profiles updated successfully".to_string(),
425                    ))
426                }
427                Err(e) => {
428                    tracing::error!("Failed to save executor profiles: {}", e);
429                    ResponseJson(ApiResponse::error(&format!(
430                        "Failed to save executor profiles: {}",
431                        e
432                    )))
433                }
434            }
435        }
436        Err(e) => ResponseJson(ApiResponse::error(&format!(
437            "Invalid executor profiles format: {}",
438            e
439        ))),
440    }
441}