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