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