Skip to main content

greentic_setup/ui/
mod.rs

1//! Web-based setup UI server.
2//!
3//! Launches an Axum HTTP server on a random port, opens the browser, and serves
4//! a single-page app that drives the setup wizard through the same FormSpec
5//! infrastructure as the terminal wizard.
6
7mod assets;
8
9use std::path::{Path, PathBuf};
10use std::sync::Mutex;
11
12use anyhow::Result;
13use axum::extract::State;
14use axum::http::header;
15use axum::response::IntoResponse;
16use axum::routing::{get, post};
17use axum::{Json, Router};
18use serde::{Deserialize, Serialize};
19use serde_json::{Map as JsonMap, Value};
20use tokio::sync::broadcast;
21
22use crate::cli_i18n::CliI18n;
23use crate::engine::{SetupConfig, SetupRequest};
24use crate::plan::TenantSelection;
25use crate::platform_setup::StaticRoutesPolicy;
26use crate::qa::wizard;
27use crate::{SetupEngine, SetupMode, discovery, setup_to_formspec};
28
29// ── Types ──
30
31struct UiState {
32    bundle_path: PathBuf,
33    tenant: String,
34    team: Option<String>,
35    env: String,
36    #[allow(dead_code)]
37    advanced: bool,
38    locale: Option<String>,
39    /// Pre-loaded answers from `--answers` file, keyed by provider_id.
40    prefill_answers: Option<JsonMap<String, Value>>,
41    shutdown_tx: broadcast::Sender<()>,
42    #[allow(dead_code)]
43    result: Mutex<Option<ExecutionResult>>,
44}
45
46#[derive(Serialize)]
47#[allow(dead_code)]
48struct ProvidersResponse {
49    bundle_path: String,
50    providers: Vec<ProviderInfo>,
51    provider_forms: Vec<ProviderForm>,
52    shared_questions: Vec<QuestionInfo>,
53}
54
55#[derive(Serialize)]
56struct ProviderInfo {
57    provider_id: String,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    display_name: Option<String>,
60    domain: String,
61    question_count: usize,
62}
63
64#[derive(Serialize)]
65struct ProviderForm {
66    provider_id: String,
67    title: String,
68    questions: Vec<QuestionInfo>,
69}
70
71#[derive(Serialize, Clone)]
72struct QuestionInfo {
73    id: String,
74    title: String,
75    kind: String,
76    required: bool,
77    secret: bool,
78    default_value: Option<String>,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    saved_value: Option<String>,
81    help: Option<String>,
82    choices: Option<Vec<String>>,
83    visible_if: Option<VisibleIfInfo>,
84    placeholder: Option<String>,
85    group: Option<String>,
86    docs_url: Option<String>,
87}
88
89#[derive(Serialize, Clone)]
90struct VisibleIfInfo {
91    field: String,
92    eq: Option<String>,
93}
94
95/// Extra fields from setup.yaml not in FormSpec.
96struct SetupQuestionExtras {
97    placeholder: Option<String>,
98    group: Option<String>,
99    docs_url: Option<String>,
100}
101
102#[derive(Deserialize)]
103struct ExecuteRequest {
104    answers: JsonMap<String, Value>,
105    #[serde(default)]
106    tenant: Option<String>,
107    #[serde(default)]
108    team: Option<String>,
109    #[serde(default)]
110    env: Option<String>,
111}
112
113#[derive(Serialize)]
114struct ScopeResponse {
115    tenant: String,
116    team: Option<String>,
117    env: String,
118    detected_tenant: Option<String>,
119}
120
121#[derive(Serialize, Clone)]
122struct ExecutionResult {
123    success: bool,
124    stdout: String,
125    stderr: String,
126    manual_steps: Vec<crate::webhook::ProviderInstruction>,
127}
128
129// ── Public API ──
130
131/// Launch the setup UI server and open in browser.
132///
133/// When `prefill_answers` is provided (from `--answers` file), the values are
134/// injected into the UI as pre-filled form values so the user can review and
135/// edit before executing.
136pub async fn launch(
137    bundle_path: &Path,
138    tenant: &str,
139    team: Option<&str>,
140    env: &str,
141    advanced: bool,
142    locale: Option<&str>,
143    prefill_answers: Option<JsonMap<String, Value>>,
144) -> Result<()> {
145    let (shutdown_tx, _) = broadcast::channel::<()>(1);
146
147    let state = std::sync::Arc::new(UiState {
148        bundle_path: bundle_path.to_path_buf(),
149        tenant: tenant.to_string(),
150        team: team.map(String::from),
151        env: env.to_string(),
152        advanced,
153        locale: locale.map(String::from),
154        prefill_answers,
155        shutdown_tx: shutdown_tx.clone(),
156        result: Mutex::new(None),
157    });
158
159    let router = build_router(state.clone());
160
161    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
162    let port = listener.local_addr()?.port();
163    let url = format!("http://127.0.0.1:{port}");
164
165    eprintln!("Setup UI started at: {url}");
166    let _ = open::that(&url);
167
168    let mut shutdown_rx = shutdown_tx.subscribe();
169    axum::serve(listener, router)
170        .with_graceful_shutdown(async move {
171            let _ = shutdown_rx.recv().await;
172        })
173        .await?;
174
175    Ok(())
176}
177
178fn build_router(state: std::sync::Arc<UiState>) -> Router {
179    Router::new()
180        .route("/", get(serve_index))
181        .route("/app.js", get(serve_js))
182        .route("/style.css", get(serve_css))
183        .route("/api/locales", get(get_locales))
184        .route("/api/scope", get(get_scope))
185        .route("/api/existing-scopes", get(get_existing_scopes))
186        .route("/api/providers", get(get_providers))
187        .route("/api/execute", post(post_execute))
188        .route("/api/export", post(post_export))
189        .route("/api/decrypt", post(post_decrypt))
190        .route("/api/shutdown", post(post_shutdown))
191        .with_state(state)
192}
193
194// ── Static assets ──
195
196async fn serve_index() -> impl IntoResponse {
197    (
198        [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
199        assets::INDEX_HTML,
200    )
201}
202
203async fn serve_js() -> impl IntoResponse {
204    (
205        [(
206            header::CONTENT_TYPE,
207            "application/javascript; charset=utf-8",
208        )],
209        assets::APP_JS,
210    )
211}
212
213async fn serve_css() -> impl IntoResponse {
214    (
215        [(header::CONTENT_TYPE, "text/css; charset=utf-8")],
216        assets::STYLE_CSS,
217    )
218}
219
220// ── API handlers ──
221
222/// Well-known locales with display labels.
223const LOCALE_OPTIONS: &[(&str, &str)] = &[
224    ("en", "English"),
225    ("id", "Bahasa Indonesia"),
226    ("ja", "日本語"),
227    ("zh", "中文"),
228    ("ko", "한국어"),
229    ("es", "Español"),
230    ("fr", "Français"),
231    ("de", "Deutsch"),
232    ("pt", "Português"),
233    ("ru", "Русский"),
234    ("ar", "العربية"),
235    ("th", "ไทย"),
236    ("vi", "Tiếng Việt"),
237    ("tr", "Türkçe"),
238    ("it", "Italiano"),
239    ("nl", "Nederlands"),
240    ("pl", "Polski"),
241    ("sv", "Svenska"),
242    ("hi", "हिन्दी"),
243    ("ms", "Bahasa Melayu"),
244];
245
246async fn get_locales(State(state): State<std::sync::Arc<UiState>>) -> Json<Value> {
247    let current = state.locale.as_deref().unwrap_or("en");
248    let locales: Vec<Value> = LOCALE_OPTIONS
249        .iter()
250        .map(|(code, label)| {
251            serde_json::json!({
252                "code": code,
253                "label": label,
254                "selected": *code == current,
255            })
256        })
257        .collect();
258    Json(serde_json::json!({ "locales": locales, "current": current }))
259}
260
261#[derive(Deserialize)]
262struct ProviderQuery {
263    locale: Option<String>,
264}
265
266async fn get_scope(State(state): State<std::sync::Arc<UiState>>) -> Json<ScopeResponse> {
267    let bundle_path = &state.bundle_path;
268    let cli_tenant = &state.tenant;
269    let cli_env = &state.env;
270
271    // Detect tenant from the bundle's tenants/ directory, same as --answers mode
272    let detected_tenant = detect_tenant_from_bundle(bundle_path);
273
274    // Apply same resolution logic as resolve_setup_scope_with_bundle:
275    // if CLI tenant is the default "demo" and we detect a tenant from the bundle, use it.
276    let effective_tenant = if cli_tenant == "demo" {
277        detected_tenant
278            .clone()
279            .unwrap_or_else(|| cli_tenant.clone())
280    } else {
281        cli_tenant.clone()
282    };
283
284    Json(ScopeResponse {
285        tenant: effective_tenant,
286        team: state.team.clone(),
287        env: cli_env.clone(),
288        detected_tenant,
289    })
290}
291
292/// Detect tenant from the bundle's `tenants/` directory.
293fn detect_tenant_from_bundle(bundle_dir: &Path) -> Option<String> {
294    let tenants_dir = bundle_dir.join("tenants");
295    let entries: Vec<String> = std::fs::read_dir(&tenants_dir)
296        .ok()?
297        .filter_map(|e| e.ok())
298        .filter(|e| e.path().is_dir())
299        .filter_map(|e| e.file_name().into_string().ok())
300        .collect();
301
302    match entries.len() {
303        0 => None,
304        1 => Some(entries[0].clone()),
305        _ => entries
306            .iter()
307            .find(|t| t.as_str() != "demo")
308            .cloned()
309            .or_else(|| entries.first().cloned()),
310    }
311}
312
313/// Scan the bundle for previously configured scopes.
314///
315/// Reads `state/config/*/setup-answers.json` for provider answers and
316/// probes the dev secrets store with detected tenants to reconstruct
317/// existing scope configurations.
318async fn get_existing_scopes(State(state): State<std::sync::Arc<UiState>>) -> Json<Value> {
319    let bundle_path = &state.bundle_path;
320
321    // 1. Detect tenants from tenants/ directory
322    let tenants = {
323        let mut t = Vec::new();
324        let tenants_dir = bundle_path.join("tenants");
325        if let Ok(entries) = std::fs::read_dir(&tenants_dir) {
326            for entry in entries.flatten() {
327                if entry.path().is_dir()
328                    && let Some(name) = entry.file_name().to_str()
329                {
330                    t.push(name.to_string());
331                }
332            }
333        }
334        if t.is_empty() {
335            t.push(state.tenant.clone());
336        }
337        t.sort();
338        t
339    };
340
341    // 2. Read provider answers from state/config/*/setup-answers.json
342    let config_dir = bundle_path.join("state").join("config");
343    let mut provider_answers: JsonMap<String, Value> = JsonMap::new();
344    if let Ok(entries) = std::fs::read_dir(&config_dir) {
345        for entry in entries.flatten() {
346            if !entry.path().is_dir() {
347                continue;
348            }
349            let provider_id = entry.file_name().to_string_lossy().to_string();
350            let answers_file = entry.path().join("setup-answers.json");
351            if let Ok(content) = std::fs::read_to_string(&answers_file)
352                && let Ok(parsed) = serde_json::from_str::<Value>(&content)
353            {
354                provider_answers.insert(provider_id, parsed);
355            }
356        }
357    }
358
359    // 3. For each tenant, probe secrets store to see if secrets exist
360    let discovered = discovery::discover(bundle_path).ok();
361    let provider_form_specs: Vec<wizard::ProviderFormSpec> = discovered
362        .iter()
363        .flat_map(|d| d.providers.iter())
364        .filter_map(|p| {
365            setup_to_formspec::pack_to_form_spec(&p.pack_path, &p.provider_id).map(|fs| {
366                wizard::ProviderFormSpec {
367                    provider_id: p.provider_id.clone(),
368                    form_spec: fs,
369                }
370            })
371        })
372        .collect();
373
374    let envs_to_probe = ["dev", "local"];
375    let mut scopes = Vec::new();
376
377    for tenant in &tenants {
378        for env in &envs_to_probe {
379            let saved =
380                load_saved_secrets(bundle_path, env, tenant, None, &provider_form_specs).await;
381
382            if saved.is_empty() {
383                continue;
384            }
385
386            // Merge saved secrets with file-based answers
387            let mut merged_answers = JsonMap::new();
388            for (pid, file_ans) in &provider_answers {
389                merged_answers.insert(pid.clone(), file_ans.clone());
390            }
391            // Overlay saved secrets into answers
392            for (pid, secrets) in &saved {
393                let entry = merged_answers
394                    .entry(pid.clone())
395                    .or_insert_with(|| Value::Object(JsonMap::new()));
396                if let Some(obj) = entry.as_object_mut() {
397                    for (k, v) in secrets {
398                        obj.insert(k.clone(), Value::String(v.clone()));
399                    }
400                }
401            }
402
403            scopes.push(serde_json::json!({
404                "tenant": tenant,
405                "env": env,
406                "team": null,
407                "answers": merged_answers,
408                "providers_done": saved.keys().collect::<Vec<_>>(),
409            }));
410            break; // found secrets for this tenant, skip other envs
411        }
412    }
413
414    Json(serde_json::json!({ "scopes": scopes }))
415}
416
417async fn get_providers(
418    State(state): State<std::sync::Arc<UiState>>,
419    axum::extract::Query(query): axum::extract::Query<ProviderQuery>,
420) -> Json<Value> {
421    let bundle_path = &state.bundle_path;
422
423    // Use query locale override, fall back to CLI locale
424    let locale = query.locale.as_deref().or(state.locale.as_deref());
425
426    // Load i18n strings for the UI
427    let i18n = CliI18n::from_request(locale)
428        .unwrap_or_else(|_| CliI18n::from_request(Some("en")).expect("en locale must exist"));
429    let ui_strings = i18n.keys_with_prefix("ui.");
430
431    let discovered = match discovery::discover(bundle_path) {
432        Ok(d) => d,
433        Err(e) => {
434            return Json(serde_json::json!({
435                "bundle_path": bundle_path.display().to_string(),
436                "providers": [],
437                "provider_forms": [],
438                "shared_questions": [],
439                "i18n": ui_strings,
440                "error": e.to_string(),
441            }));
442        }
443    };
444
445    let provider_form_specs: Vec<wizard::ProviderFormSpec> = discovered
446        .providers
447        .iter()
448        .filter_map(|provider| {
449            setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id).map(
450                |form_spec| wizard::ProviderFormSpec {
451                    provider_id: provider.provider_id.clone(),
452                    form_spec,
453                },
454            )
455        })
456        .collect();
457
458    // Detect shared questions (saved values injected after secrets are loaded below)
459    let shared_question_specs = if provider_form_specs.len() > 1 {
460        wizard::collect_shared_questions(&provider_form_specs)
461            .shared_questions
462            .clone()
463    } else {
464        vec![]
465    };
466
467    let providers: Vec<ProviderInfo> = discovered
468        .providers
469        .iter()
470        .map(|p| {
471            let form = setup_to_formspec::pack_to_form_spec(&p.pack_path, &p.provider_id);
472            ProviderInfo {
473                provider_id: p.provider_id.clone(),
474                display_name: p.display_name.clone(),
475                domain: p.domain.clone(),
476                question_count: form.as_ref().map(|f| f.questions.len()).unwrap_or(0),
477            }
478        })
479        .collect();
480
481    // Build lookup maps for extra fields (placeholder, group, docs_url) from setup.yaml
482    let mut extras_by_provider: std::collections::HashMap<
483        String,
484        std::collections::HashMap<String, SetupQuestionExtras>,
485    > = std::collections::HashMap::new();
486    for provider in &discovered.providers {
487        if let Ok(Some(spec)) = crate::setup_input::load_setup_spec(&provider.pack_path) {
488            let mut map = std::collections::HashMap::new();
489            for q in &spec.questions {
490                map.insert(
491                    q.name.clone(),
492                    SetupQuestionExtras {
493                        placeholder: q.placeholder.clone(),
494                        group: q.group.clone(),
495                        docs_url: q.docs_url.clone(),
496                    },
497                );
498            }
499            extras_by_provider.insert(provider.provider_id.clone(), map);
500        }
501    }
502
503    // Load saved secrets from dev store for auto-fill
504    let saved_secrets = load_saved_secrets(
505        bundle_path,
506        &state.env,
507        &state.tenant,
508        state.team.as_deref(),
509        &provider_form_specs,
510    )
511    .await;
512
513    // Build per-provider prefill map from --answers file (overrides saved secrets)
514    let prefill = &state.prefill_answers;
515
516    // Inject saved values into shared questions (pick from first provider that has the value)
517    // Answers from --answers file take priority over saved secrets.
518    let shared_questions: Vec<QuestionInfo> = shared_question_specs
519        .iter()
520        .map(|q| {
521            let mut info = form_question_to_info(q, Some(&i18n));
522            // First try --answers prefill (check all providers for the shared question)
523            let mut found = false;
524            if let Some(answers) = prefill {
525                for pfs in &provider_form_specs {
526                    if let Some(provider_answers) =
527                        answers.get(&pfs.provider_id).and_then(|v| v.as_object())
528                        && let Some(val) = provider_answers
529                            .get(&q.id)
530                            .and_then(value_as_nonempty_string)
531                    {
532                        info.saved_value = Some(val);
533                        found = true;
534                        break;
535                    }
536                }
537            }
538            // Fall back to saved secrets
539            if !found {
540                for secrets in saved_secrets.values() {
541                    if let Some(val) = secrets.get(&q.id) {
542                        info.saved_value = Some(val.clone());
543                        break;
544                    }
545                }
546            }
547            info
548        })
549        .collect();
550
551    let provider_forms: Vec<ProviderForm> = provider_form_specs
552        .iter()
553        .map(|pfs| {
554            let extras = extras_by_provider.get(&pfs.provider_id);
555            let saved = saved_secrets.get(&pfs.provider_id);
556            let answers = prefill
557                .as_ref()
558                .and_then(|a| a.get(&pfs.provider_id))
559                .and_then(|v| v.as_object());
560            ProviderForm {
561                provider_id: pfs.provider_id.clone(),
562                title: pfs.form_spec.title.clone(),
563                questions: pfs
564                    .form_spec
565                    .questions
566                    .iter()
567                    .map(|q| {
568                        let mut info = form_question_to_info(q, Some(&i18n));
569                        if let Some(ext) = extras.and_then(|m| m.get(&q.id)) {
570                            if info.placeholder.is_none() {
571                                info.placeholder = ext.placeholder.clone();
572                            }
573                            info.group = ext.group.clone();
574                            info.docs_url = ext.docs_url.clone();
575                        }
576                        // --answers prefill takes priority over saved secrets
577                        if let Some(val) = answers
578                            .and_then(|m| m.get(&q.id))
579                            .and_then(value_as_nonempty_string)
580                        {
581                            info.saved_value = Some(val);
582                        } else if let Some(val) = saved.and_then(|m| m.get(&q.id)) {
583                            info.saved_value = Some(val.clone());
584                        }
585                        info
586                    })
587                    .collect(),
588            }
589        })
590        .collect();
591
592    Json(serde_json::json!({
593        "bundle_path": bundle_path.display().to_string(),
594        "providers": providers,
595        "provider_forms": provider_forms,
596        "shared_questions": shared_questions,
597        "i18n": ui_strings,
598    }))
599}
600
601async fn post_execute(
602    State(state): State<std::sync::Arc<UiState>>,
603    Json(req): Json<ExecuteRequest>,
604) -> Json<ExecutionResult> {
605    let bundle_path = state.bundle_path.clone();
606    // Use scope from UI request if provided, otherwise fall back to CLI defaults
607    let tenant = req.tenant.unwrap_or_else(|| state.tenant.clone());
608    let team = req.team.or_else(|| state.team.clone());
609    let env = req.env.unwrap_or_else(|| state.env.clone());
610    let answers = req.answers;
611
612    let result = tokio::task::spawn_blocking(move || {
613        execute_setup(&bundle_path, &tenant, team.as_deref(), &env, answers)
614    })
615    .await
616    .unwrap_or_else(|e| ExecutionResult {
617        success: false,
618        stdout: String::new(),
619        stderr: format!("Task panicked: {e}"),
620        manual_steps: vec![],
621    });
622
623    *state.result.lock().unwrap() = Some(result.clone());
624    Json(result)
625}
626
627#[derive(Deserialize)]
628struct ExportRequest {
629    scopes: Vec<ExportScope>,
630    #[serde(default)]
631    key: Option<String>,
632}
633
634#[derive(Deserialize)]
635struct ExportScope {
636    tenant: String,
637    #[serde(default)]
638    team: Option<String>,
639    env: String,
640    answers: JsonMap<String, Value>,
641}
642
643async fn post_export(
644    State(state): State<std::sync::Arc<UiState>>,
645    Json(req): Json<ExportRequest>,
646) -> Json<Value> {
647    let bundle_path = state.bundle_path.clone();
648
649    // Discover packs to identify secret fields for encryption
650    let discovered = discovery::discover(&bundle_path).ok();
651    let secret_fields: std::collections::HashSet<String> = discovered
652        .iter()
653        .flat_map(|d| d.providers.iter())
654        .filter_map(|p| setup_to_formspec::pack_to_form_spec(&p.pack_path, &p.provider_id))
655        .flat_map(|spec| spec.questions.into_iter())
656        .filter(|q| q.secret)
657        .map(|q| q.id)
658        .collect();
659
660    let mut scopes_json = Vec::new();
661    for scope in &req.scopes {
662        let mut setup_answers = JsonMap::new();
663        for (provider_id, provider_answers) in &scope.answers {
664            let mut encrypted_answers = JsonMap::new();
665            if let Some(obj) = provider_answers.as_object() {
666                for (field, value) in obj {
667                    if secret_fields.contains(field) && req.key.is_some() {
668                        let key = req.key.as_deref().unwrap();
669                        match crate::answers_crypto::encrypt_value(value, key) {
670                            Ok(enc) => {
671                                encrypted_answers.insert(field.clone(), enc);
672                            }
673                            Err(_) => {
674                                encrypted_answers.insert(field.clone(), value.clone());
675                            }
676                        }
677                    } else {
678                        encrypted_answers.insert(field.clone(), value.clone());
679                    }
680                }
681            }
682            setup_answers.insert(provider_id.clone(), Value::Object(encrypted_answers));
683        }
684        scopes_json.push(serde_json::json!({
685            "tenant": scope.tenant,
686            "team": scope.team,
687            "env": scope.env,
688            "setup_answers": setup_answers,
689        }));
690    }
691
692    // Single scope → flat format (compatible with --answers)
693    // Multiple scopes → array format
694    let doc = if scopes_json.len() == 1 {
695        let mut single = scopes_json.into_iter().next().unwrap();
696        if let Some(obj) = single.as_object_mut() {
697            obj.insert(
698                "greentic_setup_version".to_string(),
699                Value::String("1.0.0".to_string()),
700            );
701            obj.insert(
702                "bundle_source".to_string(),
703                Value::String(bundle_path.display().to_string()),
704            );
705        }
706        single
707    } else {
708        serde_json::json!({
709            "greentic_setup_version": "1.0.0",
710            "bundle_source": bundle_path.display().to_string(),
711            "scopes": scopes_json,
712        })
713    };
714
715    Json(doc)
716}
717
718#[derive(Deserialize)]
719struct DecryptRequest {
720    doc: Value,
721    key: String,
722}
723
724async fn post_decrypt(Json(req): Json<DecryptRequest>) -> Json<Value> {
725    match crate::answers_crypto::decrypt_tree(&req.doc, &req.key) {
726        Ok(decrypted) => Json(serde_json::json!({ "ok": true, "doc": decrypted })),
727        Err(e) => Json(serde_json::json!({ "ok": false, "error": e.to_string() })),
728    }
729}
730
731async fn post_shutdown(State(state): State<std::sync::Arc<UiState>>) {
732    let _ = state.shutdown_tx.send(());
733}
734
735// ── Execution ──
736
737fn execute_setup(
738    bundle_path: &Path,
739    tenant: &str,
740    team: Option<&str>,
741    env: &str,
742    answers: JsonMap<String, Value>,
743) -> ExecutionResult {
744    let config = SetupConfig {
745        tenant: tenant.to_string(),
746        team: team.map(String::from),
747        env: env.to_string(),
748        offline: false,
749        verbose: true,
750    };
751
752    let static_routes = match StaticRoutesPolicy::normalize(None, env) {
753        Ok(sr) => sr,
754        Err(e) => {
755            return ExecutionResult {
756                success: false,
757                stdout: String::new(),
758                stderr: format!("Failed to normalize static routes: {e}"),
759                manual_steps: vec![],
760            };
761        }
762    };
763
764    // Collect manual steps before moving answers into request
765    let provider_configs: Vec<(String, serde_json::Value)> = answers
766        .iter()
767        .map(|(id, val)| (id.clone(), val.clone()))
768        .collect();
769    let team_str = team.unwrap_or("default");
770    let manual_steps =
771        crate::webhook::collect_post_setup_instructions(&provider_configs, tenant, team_str);
772
773    let request = SetupRequest {
774        bundle: bundle_path.to_path_buf(),
775        tenants: vec![TenantSelection {
776            tenant: tenant.to_string(),
777            team: team.map(String::from),
778            allow_paths: Vec::new(),
779        }],
780        static_routes,
781        deployment_targets: Vec::new(),
782        setup_answers: answers,
783        ..Default::default()
784    };
785
786    let engine = SetupEngine::new(config);
787
788    let plan = match engine.plan(SetupMode::Create, &request, false) {
789        Ok(p) => p,
790        Err(e) => {
791            return ExecutionResult {
792                success: false,
793                stdout: String::new(),
794                stderr: format!("Failed to build plan: {e}"),
795                manual_steps: vec![],
796            };
797        }
798    };
799
800    // Capture plan summary
801    let mut stdout = String::new();
802    for step in &plan.steps {
803        stdout.push_str(&format!("  {:?}: {}\n", step.kind, step.description));
804    }
805
806    match engine.execute(&plan) {
807        Ok(report) => {
808            stdout.push_str(&format!(
809                "\n{} provider(s) updated, {} pack(s) resolved.\n",
810                report.provider_updates,
811                report.resolved_packs.len()
812            ));
813            if !report.warnings.is_empty() {
814                for w in &report.warnings {
815                    stdout.push_str(&format!("  warning: {w}\n"));
816                }
817            }
818            ExecutionResult {
819                success: true,
820                stdout: format!(
821                    "Plan ({} steps):\n{stdout}Setup completed successfully.",
822                    plan.steps.len()
823                ),
824                stderr: String::new(),
825                manual_steps,
826            }
827        }
828        Err(e) => ExecutionResult {
829            success: false,
830            stdout,
831            stderr: format!("Execution failed: {e}"),
832            manual_steps: vec![],
833        },
834    }
835}
836
837// ── Helpers ──
838
839/// Load previously saved secret values from the dev store for all providers.
840async fn load_saved_secrets(
841    bundle_path: &Path,
842    env: &str,
843    tenant: &str,
844    team: Option<&str>,
845    provider_form_specs: &[wizard::ProviderFormSpec],
846) -> std::collections::HashMap<String, std::collections::HashMap<String, String>> {
847    use greentic_secrets_lib::SecretsStore;
848
849    let store = match crate::secrets::open_dev_store(bundle_path) {
850        Ok(s) => s,
851        Err(_) => return std::collections::HashMap::new(),
852    };
853
854    let mut result = std::collections::HashMap::new();
855    for pfs in provider_form_specs {
856        let mut values = std::collections::HashMap::new();
857        for q in &pfs.form_spec.questions {
858            let uri = crate::canonical_secret_uri(env, tenant, team, &pfs.provider_id, &q.id);
859            if let Ok(bytes) = store.get(&uri).await
860                && let Ok(text) = String::from_utf8(bytes)
861                && !text.is_empty()
862            {
863                values.insert(q.id.clone(), text);
864            }
865        }
866        if !values.is_empty() {
867            result.insert(pfs.provider_id.clone(), values);
868        }
869    }
870    result
871}
872
873/// Extract a non-empty string from a JSON value (handles String, Number, Bool).
874fn value_as_nonempty_string(v: &Value) -> Option<String> {
875    match v {
876        Value::String(s) if !s.is_empty() => Some(s.clone()),
877        Value::Number(n) => Some(n.to_string()),
878        Value::Bool(b) => Some(b.to_string()),
879        _ => None,
880    }
881}
882
883fn form_question_to_info(q: &qa_spec::QuestionSpec, i18n: Option<&CliI18n>) -> QuestionInfo {
884    let visible_if = q.visible_if.as_ref().and_then(|v| match v {
885        qa_spec::Expr::Eq { left, right } => {
886            let field = match left.as_ref() {
887                qa_spec::Expr::Answer { path } => path.clone(),
888                _ => return None,
889            };
890            let eq = match right.as_ref() {
891                qa_spec::Expr::Literal { value } => {
892                    Some(value.as_str().unwrap_or("true").to_string())
893                }
894                _ => None,
895            };
896            Some(VisibleIfInfo { field, eq })
897        }
898        qa_spec::Expr::Answer { path } => Some(VisibleIfInfo {
899            field: path.clone(),
900            eq: None,
901        }),
902        _ => None,
903    });
904
905    // Resolve title and help from i18n if available
906    let title_key = format!("ui.q.{}", q.id);
907    let help_key = format!("ui.q.{}.help", q.id);
908
909    let title = i18n
910        .and_then(|i| {
911            let t = i.t(&title_key);
912            if t != title_key { Some(t) } else { None }
913        })
914        .unwrap_or_else(|| q.title.clone());
915
916    let help = i18n
917        .and_then(|i| {
918            let t = i.t(&help_key);
919            if t != help_key { Some(t) } else { None }
920        })
921        .or_else(|| q.description.clone());
922
923    QuestionInfo {
924        id: q.id.clone(),
925        title,
926        kind: format!("{:?}", q.kind),
927        required: q.required,
928        secret: q.secret,
929        default_value: q.default_value.clone(),
930        saved_value: None,
931        help,
932        choices: q.choices.clone(),
933        visible_if,
934        placeholder: None,
935        group: None,
936        docs_url: None,
937    }
938}