use crate::orchestrate::{cards2pack, deployer};
use crate::ui::state::{AppState, LogKind, PackLogLine, WizardJob, WizardJobStatus, WizardMode};
use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::{StatusCode, header};
use axum::response::IntoResponse;
use serde::Deserialize;
use serde_json::{Value, json};
use std::path::PathBuf;
use std::sync::Arc;
const WEBCHAT_PROVIDER: &str =
"oci://ghcr.io/greenticai/packs/messaging/messaging-webchat-gui:latest";
const STATE_MEMORY_PROVIDER: &str = "oci://ghcr.io/greenticai/packs/state/state-memory:latest";
#[derive(Deserialize)]
pub struct CardEntry {
pub id: String,
#[serde(default)]
pub card: Option<Value>,
#[serde(default, rename = "type")]
pub entry_type: Option<String>,
#[serde(default)]
pub config: Option<Value>,
}
#[derive(Deserialize)]
pub struct WizardBuildBody {
pub mode: String,
pub name: String,
pub cards: Vec<CardEntry>,
#[serde(default)]
pub channels: Vec<String>,
#[serde(default)]
pub cloud: Option<String>,
#[serde(default)]
pub translate: bool,
#[serde(default)]
pub languages: Vec<String>,
}
pub async fn get_credentials(Path(cloud): Path<String>) -> impl IntoResponse {
match cloud.as_str() {
"aws" | "gcp" | "azure" => {}
_ => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "unsupported cloud; use aws, gcp, or azure"})),
)
.into_response();
}
}
let reqs = match deployer::target_requirements(&cloud) {
Ok(v) => v,
Err(e) => {
return (
StatusCode::OK,
Json(json!({
"ready": false,
"provider": cloud,
"label": cloud_label(&cloud),
"requirements": [],
"error": format!("greentic-deployer unavailable: {e}"),
})),
)
.into_response();
}
};
let cred_reqs = reqs
.get("credential_requirements")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let any_satisfied = cred_reqs.iter().any(|cr| {
cr.get("satisfaction_env_groups")
.and_then(|g| g.as_array())
.is_some_and(|groups| {
groups.iter().any(|group| {
group.as_array().is_some_and(|vars| {
vars.iter()
.all(|v| v.as_str().is_some_and(|k| std::env::var(k).is_ok()))
})
})
})
});
let requirements: Vec<Value> = cred_reqs
.first()
.and_then(|cr| cr.get("prompt_fields"))
.and_then(|pf| pf.as_array())
.cloned()
.unwrap_or_default()
.iter()
.filter(|f| {
let kind = f.get("kind").and_then(|v| v.as_str()).unwrap_or("");
kind == "required" || kind == "secret"
})
.map(|f| {
let key = f.get("env_name").and_then(|v| v.as_str()).unwrap_or("");
let label = f.get("prompt").and_then(|v| v.as_str()).unwrap_or(key);
let found = !key.is_empty() && std::env::var(key).is_ok();
json!({"key": key, "label": label, "found": found})
})
.collect();
let ready = any_satisfied;
let provider = reqs
.get("target")
.and_then(|v| v.as_str())
.unwrap_or(&cloud);
let label = reqs
.get("target_label")
.and_then(|v| v.as_str())
.unwrap_or(cloud_label(&cloud));
(
StatusCode::OK,
Json(json!({
"ready": ready,
"provider": provider,
"label": label,
"requirements": requirements,
})),
)
.into_response()
}
pub async fn post_build(
State(state): State<Arc<AppState>>,
Json(body): Json<WizardBuildBody>,
) -> impl IntoResponse {
if body.cards.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "no cards provided"})),
)
.into_response();
}
let mode = match body.mode.as_str() {
"deploy" => WizardMode::Deploy,
"develop" => WizardMode::Develop,
_ => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "mode must be 'deploy' or 'develop'"})),
)
.into_response();
}
};
let job_id = format!("wizard-{}", uuid_short());
let mut providers = vec![
WEBCHAT_PROVIDER.to_string(),
STATE_MEMORY_PROVIDER.to_string(),
];
for ch in &body.channels {
if !providers.contains(ch) {
providers.push(ch.clone());
}
}
let card_pairs: Vec<(String, Value)> = body
.cards
.into_iter()
.map(|ce| {
let value = if let Some(card) = ce.card {
card
} else if matches!(ce.entry_type.as_deref(), Some("http")) {
json!({"type": "http", "config": ce.config.unwrap_or_else(|| json!({}))})
} else {
json!({})
};
(ce.id, value)
})
.collect();
let prep = match cards2pack::prepare_cards(&body.name, &card_pairs) {
Ok(p) => p,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)
.into_response();
}
};
{
let mut jobs = state.wizard_jobs.lock().await;
jobs.insert(
job_id.clone(),
WizardJob {
mode: mode.clone(),
lines: vec![PackLogLine {
text: format!("Starting wizard build: {}", body.name),
kind: LogKind::Info,
}],
progress: 5,
step: "Preparing workspace...".to_string(),
status: WizardJobStatus::Building,
download_url: None,
filename: None,
deploy_url: None,
error: None,
},
);
}
let mut args = vec![
"generate".to_string(),
"--cards".to_string(),
prep.cards_dir.display().to_string(),
"--out".to_string(),
prep.out_dir.display().to_string(),
"--name".to_string(),
body.name.clone(),
"--default-flow".to_string(),
prep.flow_name.clone(),
];
if body.translate && !body.languages.is_empty() {
args.extend([
"--auto-translate".to_string(),
"--langs".to_string(),
body.languages.join(","),
]);
} else {
args.push("--no-auto-i18n".to_string());
}
let state_clone = state.clone();
let jid = job_id.clone();
let pack_name = body.name.clone();
let out_dir = prep.out_dir.clone();
let cloud = body.cloud.clone();
let http_entries = prep.http_entries.clone();
tokio::spawn(async move {
crate::ui::routes::wizard_pipeline::run_wizard_pipeline(
&state_clone,
&jid,
&args,
&out_dir,
&pack_name,
&providers,
&mode,
cloud.as_deref(),
&http_entries,
)
.await;
prep.persist();
});
(StatusCode::OK, Json(json!({"job_id": job_id}))).into_response()
}
pub async fn get_build_status(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> impl IntoResponse {
let jobs = state.wizard_jobs.lock().await;
match jobs.get(&id) {
Some(job) => {
let lines: Vec<Value> = job
.lines
.iter()
.map(|l| json!({"text": l.text, "kind": l.kind.as_str()}))
.collect();
(
StatusCode::OK,
Json(json!({
"status": job.status.as_str(),
"progress": job.progress,
"step": job.step,
"lines": lines,
"download_url": job.download_url,
"deploy_url": job.deploy_url,
"filename": job.filename,
"error": job.error,
})),
)
.into_response()
}
None => (
StatusCode::NOT_FOUND,
Json(json!({"error": "wizard job not found"})),
)
.into_response(),
}
}
#[derive(Deserialize)]
pub struct DownloadQuery {
pub path: String,
}
pub async fn get_download(Query(q): Query<DownloadQuery>) -> impl IntoResponse {
let path = std::path::Path::new(&q.path);
let valid_ext = path
.extension()
.is_some_and(|e| e == "gtpack" || e == "gtbundle" || e == "zip");
if !q.path.starts_with("/tmp/") || !valid_ext {
return (StatusCode::FORBIDDEN, "Access denied").into_response();
}
match tokio::fs::read(path).await {
Ok(bytes) => {
let filename = path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_else(|| "bundle.zip".to_string());
(
StatusCode::OK,
[
(header::CONTENT_TYPE, "application/octet-stream".to_string()),
(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{filename}\""),
),
],
bytes,
)
.into_response()
}
Err(_) => (StatusCode::NOT_FOUND, "File not found").into_response(),
}
}
fn cloud_label(cloud: &str) -> &str {
match cloud {
"aws" => "Amazon Web Services",
"gcp" => "Google Cloud",
"azure" => "Microsoft Azure",
_ => cloud,
}
}
pub fn uuid_short() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let t = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
format!("{t:x}")
}
pub fn download_url(path: &std::path::Path) -> String {
format!(
"/api/wizard/download?path={}",
urlencoding::encode(&path.display().to_string())
)
}
pub fn find_file_in_dir(dir: &std::path::Path, ext: &str) -> Option<PathBuf> {
std::fs::read_dir(dir).ok()?.find_map(|e| {
let p = e.ok()?.path();
if p.extension().is_some_and(|x| x == ext) {
Some(p)
} else {
None
}
})
}
pub fn zip_directory(src_dir: &std::path::Path, dest_path: &std::path::Path) -> anyhow::Result<()> {
use std::fs::File;
use std::io::Write;
use zip::ZipWriter;
use zip::write::SimpleFileOptions;
let file = File::create(dest_path)?;
let mut zip = ZipWriter::new(file);
let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
for entry in walkdir::WalkDir::new(src_dir)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
let relative = path.strip_prefix(src_dir)?;
let name = relative.to_string_lossy();
if path.is_file() {
zip.start_file(name, options)?;
let data = std::fs::read(path)?;
zip.write_all(&data)?;
} else if path.is_dir() && !name.is_empty() {
zip.add_directory(name, options)?;
}
}
zip.finish()?;
Ok(())
}