1mod 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
29struct 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 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
95struct 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
129pub 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
194async 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
220const 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 let detected_tenant = detect_tenant_from_bundle(bundle_path);
273
274 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
292fn 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
313async fn get_existing_scopes(State(state): State<std::sync::Arc<UiState>>) -> Json<Value> {
319 let bundle_path = &state.bundle_path;
320
321 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 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 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 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 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; }
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 let locale = query.locale.as_deref().or(state.locale.as_deref());
425
426 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 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 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 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 let prefill = &state.prefill_answers;
515
516 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 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 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 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 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 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 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
735fn 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 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 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
837async 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
873fn 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 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}