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