1mod 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
31struct 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 prefill_answers: Option<JsonMap<String, Value>>,
43 scope_from_answers: bool,
46 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 #[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 #[serde(skip_serializing_if = "Option::is_none")]
109 list_columns: Option<Vec<ListColumnInfo>>,
110 #[serde(skip_serializing_if = "Option::is_none")]
112 min_rows: Option<usize>,
113 #[serde(skip_serializing_if = "Option::is_none")]
115 max_rows: Option<usize>,
116}
117
118#[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 #[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
143struct SetupQuestionExtras {
145 placeholder: Option<String>,
146 group: Option<String>,
147 docs_url: Option<String>,
148 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#[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
263async 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
289const 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 let detected_tenant = detect_tenant_from_bundle(bundle_path);
342
343 let effective_tenant = if state.scope_from_answers {
346 cli_tenant.clone()
347 } else if cli_tenant == "demo" {
348 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
366fn 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
387async fn get_existing_scopes(State(state): State<std::sync::Arc<UiState>>) -> Json<Value> {
393 let bundle_path = &state.bundle_path;
394
395 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 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 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 let mut merged_answers = JsonMap::new();
462 for (pid, file_ans) in &provider_answers {
463 let mut cloned = file_ans.clone();
464 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 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; }
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 let locale = query.locale.as_deref().or(state.locale.as_deref());
521
522 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 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 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 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 let prefill = &state.prefill_answers;
618
619 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 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 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 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 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 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 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 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 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 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 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
996fn 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 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 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
1098async 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
1176fn 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 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: 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}