use crate::server::app::AppState;
use crate::server::body::read_json_object;
use crate::server::errors::{error, mutation_message};
use crate::server::sse;
use axum::body::Bytes;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::post;
use axum::{Json, Router};
use nomoreide_core::config::{DatabaseDef, GitRepoDef};
use nomoreide_core::repo_onboard::{
clone_repository, default_repos_dir, is_inside_repos_dir, propose_databases, propose_services,
run_install, scan_repo,
};
use nomoreide_core::service_definition::service_definition;
use serde_json::{json, Map, Value};
pub(crate) fn routes() -> Router<AppState> {
Router::new()
.route("/api/onboard/scan", post(scan))
.route("/api/onboard/install/stream", post(install_stream))
.route("/api/onboard/register", post(register))
}
async fn install_stream(State(state): State<AppState>, body: Bytes) -> Response {
let _ = &state;
let payload = read_json_object(&body);
let clone_path = payload
.get("clonePath")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let command = payload
.get("command")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default()
.to_string();
if clone_path.is_empty() || !is_inside_repos_dir(&clone_path, &default_repos_dir()) {
return error(
StatusCode::BAD_REQUEST,
"clonePath must be an onboarded repo",
);
}
if command.is_empty() {
return error(StatusCode::BAD_REQUEST, "command is required");
}
sse::driven(sse::RETRY_AND_PING, move |sink| async move {
let (tx, mut rx) = tokio::sync::mpsc::channel(64);
let install = tokio::spawn(async move { run_install(&clone_path, &command, tx).await });
while let Some(line) = rx.recv().await {
if !sink.send(sse::named("output", line)).await {
break;
}
}
let exit_code = install.await.ok().flatten();
sink.send(sse::named("done", json!({ "exitCode": exit_code })))
.await;
})
}
async fn scan(State(state): State<AppState>, body: Bytes) -> Response {
let _ = &state;
let payload = read_json_object(&body);
let url = payload
.get("url")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default()
.to_string();
if url.is_empty() {
return error(StatusCode::BAD_REQUEST, "url is required");
}
let cloned = match clone_repository(&url, Some(&default_repos_dir()), None).await {
Ok(cloned) => cloned,
Err(reason) => return error(StatusCode::UNPROCESSABLE_ENTITY, &reason.to_string()),
};
let profile = match scan_repo(&cloned.clone_path).await {
Ok(profile) => profile,
Err(reason) => return error(StatusCode::UNPROCESSABLE_ENTITY, &reason.to_string()),
};
let proposals = propose_services(&profile);
let databases = propose_databases(&profile);
Json(json!({
"ok": true,
"profile": profile,
"proposals": proposals,
"databases": databases,
}))
.into_response()
}
async fn register(State(state): State<AppState>, body: Bytes) -> Response {
let payload = read_json_object(&body);
let (Some(_), Some(cwd)) = (
payload.get("name").and_then(Value::as_str),
payload.get("cwd").and_then(Value::as_str),
) else {
return error(
StatusCode::BAD_REQUEST,
"name and an onboarded cwd are required",
);
};
if !is_inside_repos_dir(cwd, &default_repos_dir()) {
return error(
StatusCode::BAD_REQUEST,
"name and an onboarded cwd are required",
);
}
let arguments = match proposal_arguments(&payload) {
Ok(arguments) => arguments,
Err(reason) => return error(StatusCode::UNPROCESSABLE_ENTITY, &reason),
};
let definition = match service_definition(&arguments) {
Ok(definition) => definition,
Err(report) => return error(StatusCode::UNPROCESSABLE_ENTITY, &report),
};
let name = definition.name.clone();
let config = match state.config_store.register_service(definition).await {
Ok(config) => config,
Err(reason) => return error(StatusCode::UNPROCESSABLE_ENTITY, &reason.to_string()),
};
let _ = state
.config_store
.register_git_repository(GitRepoDef {
name: name.clone(),
path: cwd.to_string(),
active_worktree_path: None,
github_credential: None,
provider_projects: None,
legacy_vercel_project_id: None,
})
.await;
if let Some(database) = parse_database_input(payload.get("database")) {
let _ = state.config_store.register_database(database).await;
}
let started = if payload.get("start") == Some(&Value::Bool(true)) {
match state.runtime.start_service(&name).await {
Ok(status) => match serde_json::to_value(status) {
Ok(value) => value,
Err(_) => Value::Null,
},
Err(failure) => {
return error(StatusCode::UNPROCESSABLE_ENTITY, &mutation_message(failure))
}
}
} else {
Value::Null
};
let view = serde_json::to_value(config.public_view()).unwrap_or_else(|_| json!({}));
Json(json!({ "ok": true, "config": view, "started": started })).into_response()
}
fn proposal_arguments(payload: &Value) -> Result<Map<String, Value>, String> {
let mut arguments = Map::new();
let text = |key: &str| payload.get(key).and_then(Value::as_str);
arguments.insert("name".to_string(), json!(text("name").unwrap_or_default()));
arguments.insert("cwd".to_string(), json!(text("cwd").unwrap_or_default()));
if let Some(port) = payload.get("port").filter(|value| value.is_number()) {
arguments.insert("port".to_string(), port.clone());
}
if let Some(description) = text("description")
.map(str::trim)
.filter(|value| !value.is_empty())
{
arguments.insert("description".to_string(), json!(description));
}
if text("kind") == Some("docker-compose") {
arguments.insert("kind".to_string(), json!("docker-compose"));
let Some(service) = text("composeService")
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Err("composeService is required for a docker-compose service.".to_string());
};
arguments.insert("composeService".to_string(), json!(service));
if let Some(file) = text("composeFile")
.map(str::trim)
.filter(|value| !value.is_empty())
{
arguments.insert("composeFile".to_string(), json!(file));
}
return Ok(arguments);
}
let Some(command) = text("command")
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Err("command is required for a local service.".to_string());
};
arguments.insert("command".to_string(), json!(command));
if let Some(env) = payload.get("env").filter(|value| value.is_object()) {
arguments.insert("env".to_string(), env.clone());
}
Ok(arguments)
}
fn parse_database_input(value: Option<&Value>) -> Option<DatabaseDef> {
let value = value?;
if !value.is_object() {
return None;
}
let text = |key: &str| value.get(key).and_then(Value::as_str);
let name = text("name")?;
let url = text("url")?;
let engine = text("engine")?;
if name.trim().is_empty() || url.trim().is_empty() {
return None;
}
if !matches!(engine, "postgres" | "mysql" | "sqlite") {
return None;
}
Some(DatabaseDef {
name: name.trim().to_string(),
engine: engine.to_string(),
url: url.trim().to_string(),
write_unlocked: None,
project_path: None,
})
}