1mod assets;
8
9use std::path::{Path, PathBuf};
10use std::sync::Mutex;
11
12use anyhow::{Context, Result, anyhow};
13use axum::extract::{Query, 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::setup_tunnel::{
28 SetupTunnel, inject_setup_public_base_url, should_start_setup_tunnel, start_setup_tunnel,
29};
30use crate::{SetupEngine, SetupMode, discovery, setup_to_formspec};
31
32use crate::qa::shared_questions::HIDDEN_FROM_PROMPTS;
33
34struct UiState {
37 bundle_path: PathBuf,
38 tenant: String,
39 team: Option<String>,
40 env: String,
41 #[allow(dead_code)]
42 advanced: bool,
43 locale: Option<String>,
44 prefill_answers: Option<JsonMap<String, Value>>,
46 output_target: Option<crate::cli_helpers::SetupOutputTarget>,
52 local_base_url: String,
53 setup_tunnel: Mutex<Option<SetupTunnel>>,
54 shutdown_tx: broadcast::Sender<()>,
55 #[allow(dead_code)]
56 result: Mutex<Option<ExecutionResult>>,
57}
58
59#[derive(Serialize)]
60#[allow(dead_code)]
61struct ProvidersResponse {
62 bundle_path: String,
63 providers: Vec<ProviderInfo>,
64 provider_forms: Vec<ProviderForm>,
65 shared_questions: Vec<QuestionInfo>,
66}
67
68#[derive(Serialize)]
69struct ProviderInfo {
70 provider_id: String,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 display_name: Option<String>,
73 domain: String,
74 question_count: usize,
75}
76
77#[derive(Serialize)]
78struct ProviderForm {
79 provider_id: String,
80 title: String,
81 questions: Vec<QuestionInfo>,
82}
83
84#[derive(Serialize, Clone)]
85struct QuestionInfo {
86 id: String,
87 title: String,
88 kind: String,
89 required: bool,
90 secret: bool,
91 default_value: Option<String>,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 saved_value: Option<String>,
94 #[serde(skip_serializing_if = "Option::is_none")]
100 saved_rows: Option<Vec<Value>>,
101 help: Option<String>,
102 choices: Option<Vec<String>>,
103 visible_if: Option<VisibleIfInfo>,
104 placeholder: Option<String>,
105 group: Option<String>,
106 docs_url: Option<String>,
107 #[serde(skip_serializing_if = "Option::is_none")]
111 list_columns: Option<Vec<ListColumnInfo>>,
112 #[serde(skip_serializing_if = "Option::is_none")]
114 min_rows: Option<usize>,
115 #[serde(skip_serializing_if = "Option::is_none")]
117 max_rows: Option<usize>,
118}
119
120#[derive(Serialize, Clone)]
123struct ListColumnInfo {
124 id: String,
125 title: String,
126 kind: String,
127 required: bool,
128 help: Option<String>,
129 placeholder: Option<String>,
130 choices: Option<Vec<String>>,
131 default_value: Option<String>,
132 #[serde(skip_serializing_if = "std::ops::Not::not")]
136 multilingual: bool,
137}
138
139#[derive(Serialize, Clone)]
140struct VisibleIfInfo {
141 field: String,
142 eq: Option<String>,
143}
144
145struct SetupQuestionExtras {
147 placeholder: Option<String>,
148 group: Option<String>,
149 docs_url: Option<String>,
150 column_multilingual: std::collections::HashMap<String, bool>,
154}
155
156#[derive(Deserialize)]
157struct ExecuteRequest {
158 answers: JsonMap<String, Value>,
159 #[serde(default)]
160 tenant: Option<String>,
161 #[serde(default)]
162 team: Option<String>,
163 #[serde(default)]
164 env: Option<String>,
165 #[serde(default)]
166 tunnel: Option<String>,
167}
168
169#[derive(Deserialize)]
170struct DraftSaveRequest {
171 answers: JsonMap<String, Value>,
172 tenant: String,
173 #[serde(default)]
174 team: Option<String>,
175 env: String,
176}
177
178#[derive(Serialize)]
179struct ScopeResponse {
180 tenant: String,
181 team: Option<String>,
182 env: String,
183 detected_tenant: Option<String>,
184 cloud_deploy: bool,
185}
186
187#[derive(Serialize, Clone)]
188struct ExecutionResult {
189 success: bool,
190 stdout: String,
191 stderr: String,
192 manual_steps: Vec<crate::webhook::ProviderInstruction>,
193 #[serde(default)]
194 pending_setup_actions: Vec<crate::setup_actions::SetupAction>,
195}
196
197#[allow(clippy::too_many_arguments)]
205pub async fn launch(
206 bundle_path: &Path,
207 tenant: &str,
208 team: Option<&str>,
209 env: &str,
210 advanced: bool,
211 locale: Option<&str>,
212 prefill_answers: Option<JsonMap<String, Value>>,
213 _scope_from_answers: bool,
214 output_target: Option<crate::cli_helpers::SetupOutputTarget>,
215) -> Result<()> {
216 let (shutdown_tx, _) = broadcast::channel::<()>(1);
217
218 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
219 let port = listener.local_addr()?.port();
220 let url = format!("http://127.0.0.1:{port}");
221
222 let state = std::sync::Arc::new(UiState {
223 bundle_path: bundle_path.to_path_buf(),
224 tenant: tenant.to_string(),
225 team: team.map(String::from),
226 env: env.to_string(),
227 advanced,
228 locale: locale.map(String::from),
229 prefill_answers,
230 output_target,
231 local_base_url: url.clone(),
232 setup_tunnel: Mutex::new(None),
233 shutdown_tx: shutdown_tx.clone(),
234 result: Mutex::new(None),
235 });
236
237 let router = build_router(state.clone());
238
239 eprintln!("Setup UI started at: {url}");
240 let _ = open::that(&url);
241
242 let mut shutdown_rx = shutdown_tx.subscribe();
243 axum::serve(listener, router)
244 .with_graceful_shutdown(async move {
245 let _ = shutdown_rx.recv().await;
246 })
247 .await?;
248
249 Ok(())
250}
251
252fn build_router(state: std::sync::Arc<UiState>) -> Router {
253 Router::new()
254 .route("/", get(serve_index))
255 .route("/app.js", get(serve_js))
256 .route("/style.css", get(serve_css))
257 .route("/api/locales", get(get_locales))
258 .route("/api/scope", get(get_scope))
259 .route("/api/existing-scopes", get(get_existing_scopes))
260 .route("/api/providers", get(get_providers))
261 .route("/api/draft", post(post_draft))
262 .route("/api/execute", post(post_execute))
263 .route("/api/oauth-device/start", post(post_oauth_device_start))
264 .route("/api/oauth-device/poll", post(post_oauth_device_poll))
265 .route("/api/export", post(post_export))
266 .route("/api/decrypt", post(post_decrypt))
267 .route("/oauth/callback/{provider}", get(get_oauth_callback))
268 .route("/api/shutdown", post(post_shutdown))
269 .with_state(state)
270}
271
272async fn serve_index() -> impl IntoResponse {
275 (
276 [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
277 assets::INDEX_HTML,
278 )
279}
280
281async fn serve_js() -> impl IntoResponse {
282 (
283 [(
284 header::CONTENT_TYPE,
285 "application/javascript; charset=utf-8",
286 )],
287 assets::APP_JS,
288 )
289}
290
291async fn serve_css() -> impl IntoResponse {
292 (
293 [(header::CONTENT_TYPE, "text/css; charset=utf-8")],
294 assets::STYLE_CSS,
295 )
296}
297
298const LOCALE_OPTIONS: &[(&str, &str)] = &[
302 ("en", "English"),
303 ("id", "Bahasa Indonesia"),
304 ("ja", "日本語"),
305 ("zh", "中文"),
306 ("ko", "한국어"),
307 ("es", "Español"),
308 ("fr", "Français"),
309 ("de", "Deutsch"),
310 ("pt", "Português"),
311 ("ru", "Русский"),
312 ("ar", "العربية"),
313 ("th", "ไทย"),
314 ("vi", "Tiếng Việt"),
315 ("tr", "Türkçe"),
316 ("it", "Italiano"),
317 ("nl", "Nederlands"),
318 ("pl", "Polski"),
319 ("sv", "Svenska"),
320 ("hi", "हिन्दी"),
321 ("ms", "Bahasa Melayu"),
322];
323
324async fn get_locales(State(state): State<std::sync::Arc<UiState>>) -> Json<Value> {
325 let current = state.locale.as_deref().unwrap_or("en");
326 let locales: Vec<Value> = LOCALE_OPTIONS
327 .iter()
328 .map(|(code, label)| {
329 serde_json::json!({
330 "code": code,
331 "label": label,
332 "selected": *code == current,
333 })
334 })
335 .collect();
336 Json(serde_json::json!({ "locales": locales, "current": current }))
337}
338
339#[derive(Deserialize)]
340struct ProviderQuery {
341 locale: Option<String>,
342}
343
344async fn get_scope(State(state): State<std::sync::Arc<UiState>>) -> Json<ScopeResponse> {
345 let bundle_path = &state.bundle_path;
346 let cli_tenant = &state.tenant;
347 let cli_env = &state.env;
348
349 let detected_tenant = detect_tenant_from_bundle(bundle_path);
351
352 let effective_tenant = cli_tenant.clone();
356
357 let cloud_deploy = prefill_has_cloud_deployment_targets(state.prefill_answers.as_ref());
358
359 Json(ScopeResponse {
360 tenant: effective_tenant,
361 team: state.team.clone(),
362 env: cli_env.clone(),
363 detected_tenant,
364 cloud_deploy,
365 })
366}
367
368fn prefill_has_cloud_deployment_targets(prefill: Option<&JsonMap<String, Value>>) -> bool {
369 prefill
370 .and_then(|answers| answers.get("platform_setup"))
371 .and_then(|value| value.as_object())
372 .and_then(|platform_setup| platform_setup.get("deployment_targets"))
373 .and_then(|value| value.as_array())
374 .map(|targets| {
375 targets.iter().any(|target| {
376 target
377 .get("target")
378 .and_then(Value::as_str)
379 .is_some_and(|target| matches!(target, "aws" | "gcp" | "azure"))
380 })
381 })
382 .unwrap_or(false)
383}
384
385fn detect_tenant_from_bundle(bundle_dir: &Path) -> Option<String> {
387 let tenants_dir = bundle_dir.join("tenants");
388 let entries: Vec<String> = std::fs::read_dir(&tenants_dir)
389 .ok()?
390 .filter_map(|e| e.ok())
391 .filter(|e| e.path().is_dir())
392 .filter_map(|e| e.file_name().into_string().ok())
393 .collect();
394
395 match entries.len() {
396 0 => None,
397 1 => Some(entries[0].clone()),
398 _ => entries
399 .iter()
400 .find(|t| t.as_str() != "demo")
401 .cloned()
402 .or_else(|| entries.first().cloned()),
403 }
404}
405
406async fn get_existing_scopes(State(state): State<std::sync::Arc<UiState>>) -> Json<Value> {
412 let bundle_path = &state.bundle_path;
413
414 let tenants = {
416 let mut t = Vec::new();
417 let tenants_dir = bundle_path.join("tenants");
418 if let Ok(entries) = std::fs::read_dir(&tenants_dir) {
419 for entry in entries.flatten() {
420 if entry.path().is_dir()
421 && let Some(name) = entry.file_name().to_str()
422 {
423 t.push(name.to_string());
424 }
425 }
426 }
427 if t.is_empty() {
428 t.push(state.tenant.clone());
429 }
430 t.sort();
431 if let Some(pos) = t.iter().position(|tenant| tenant == &state.tenant) {
432 let selected = t.remove(pos);
433 t.insert(0, selected);
434 }
435 t
436 };
437
438 let config_dir = bundle_path.join("state").join("config");
440 let mut provider_answers: JsonMap<String, Value> = JsonMap::new();
441 if let Ok(entries) = std::fs::read_dir(&config_dir) {
442 for entry in entries.flatten() {
443 if !entry.path().is_dir() {
444 continue;
445 }
446 let provider_id = entry.file_name().to_string_lossy().to_string();
447 let answers_file = entry.path().join("setup-answers.json");
448 if let Ok(content) = std::fs::read_to_string(&answers_file)
449 && let Ok(parsed) = serde_json::from_str::<Value>(&content)
450 {
451 provider_answers.insert(provider_id, parsed);
452 }
453 }
454 }
455
456 let discovered = discovery::discover(bundle_path).ok();
458 let provider_form_specs: Vec<wizard::ProviderFormSpec> = discovered
459 .iter()
460 .flat_map(|d| d.setup_targets())
461 .filter_map(|p| {
462 setup_to_formspec::pack_to_form_spec(&p.pack_path, &p.provider_id).map(|fs| {
463 wizard::ProviderFormSpec {
464 provider_id: p.provider_id.clone(),
465 form_spec: fs,
466 }
467 })
468 })
469 .collect();
470
471 let envs_to_probe = ["dev", "local"];
472 let mut scopes = Vec::new();
473
474 for tenant in &tenants {
475 for env in &envs_to_probe {
476 let saved =
477 load_saved_secrets(bundle_path, env, tenant, None, &provider_form_specs).await;
478
479 if saved.is_empty() {
480 continue;
481 }
482
483 let mut merged_answers = JsonMap::new();
485 for (pid, file_ans) in &provider_answers {
486 let mut cloned = file_ans.clone();
487 if let Some(map) = cloned.as_object_mut() {
497 let legacy_key = "nav_links_json";
498 let canonical_key = "nav_links";
499 if !map.contains_key(canonical_key)
500 && let Some(Value::String(raw)) = map.get(legacy_key)
501 && let Ok(parsed) = serde_json::from_str::<Value>(raw)
502 && parsed.is_array()
503 {
504 map.insert(canonical_key.to_string(), parsed);
505 }
506 map.remove(legacy_key);
507 }
508 merged_answers.insert(pid.clone(), cloned);
509 }
510 for (pid, secrets) in &saved {
512 let entry = merged_answers
513 .entry(pid.clone())
514 .or_insert_with(|| Value::Object(JsonMap::new()));
515 if let Some(obj) = entry.as_object_mut() {
516 for (k, v) in secrets {
517 obj.insert(k.clone(), Value::String(v.clone()));
518 }
519 }
520 }
521
522 scopes.push(serde_json::json!({
523 "tenant": tenant,
524 "env": env,
525 "team": null,
526 "answers": merged_answers,
527 "providers_done": saved.keys().collect::<Vec<_>>(),
528 }));
529 break; }
531 }
532
533 Json(serde_json::json!({ "scopes": scopes }))
534}
535
536async fn get_providers(
537 State(state): State<std::sync::Arc<UiState>>,
538 axum::extract::Query(query): axum::extract::Query<ProviderQuery>,
539) -> Json<Value> {
540 let bundle_path = &state.bundle_path;
541
542 let locale = query.locale.as_deref().or(state.locale.as_deref());
544
545 let i18n = CliI18n::from_request(locale)
547 .unwrap_or_else(|_| CliI18n::from_request(Some("en")).expect("en locale must exist"));
548 let ui_strings = i18n.keys_with_prefix("ui.");
549
550 let discovered = match discovery::discover(bundle_path) {
551 Ok(d) => d,
552 Err(e) => {
553 return Json(serde_json::json!({
554 "bundle_path": bundle_path.display().to_string(),
555 "providers": [],
556 "provider_forms": [],
557 "shared_questions": [],
558 "i18n": ui_strings,
559 "error": e.to_string(),
560 }));
561 }
562 };
563
564 let setup_targets = discovered.setup_targets();
565
566 let provider_form_specs: Vec<wizard::ProviderFormSpec> = setup_targets
567 .iter()
568 .filter_map(|provider| {
569 setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id).map(
570 |form_spec| wizard::ProviderFormSpec {
571 provider_id: provider.provider_id.clone(),
572 form_spec,
573 },
574 )
575 })
576 .collect();
577
578 let shared_question_specs = if provider_form_specs.len() > 1 {
580 wizard::collect_shared_questions(&provider_form_specs)
581 .shared_questions
582 .clone()
583 } else {
584 vec![]
585 };
586
587 let providers: Vec<ProviderInfo> = setup_targets
588 .iter()
589 .map(|p| {
590 let form = setup_to_formspec::pack_to_form_spec(&p.pack_path, &p.provider_id);
591 ProviderInfo {
592 provider_id: p.provider_id.clone(),
593 display_name: p.display_name.clone(),
594 domain: p.domain.clone(),
595 question_count: form.as_ref().map(|f| f.questions.len()).unwrap_or(0),
596 }
597 })
598 .collect();
599
600 let mut extras_by_provider: std::collections::HashMap<
602 String,
603 std::collections::HashMap<String, SetupQuestionExtras>,
604 > = std::collections::HashMap::new();
605 for provider in &setup_targets {
606 if let Ok(Some(spec)) = crate::setup_input::load_setup_spec(&provider.pack_path) {
607 let mut map = std::collections::HashMap::new();
608 for q in &spec.questions {
609 let mut column_multilingual = std::collections::HashMap::new();
610 for col in &q.columns {
611 if col.multilingual {
612 column_multilingual.insert(col.key.clone(), true);
613 }
614 }
615 map.insert(
616 q.name.clone(),
617 SetupQuestionExtras {
618 placeholder: q.placeholder.clone(),
619 group: q.group.clone(),
620 docs_url: q.docs_url.clone(),
621 column_multilingual,
622 },
623 );
624 }
625 extras_by_provider.insert(provider.provider_id.clone(), map);
626 }
627 }
628
629 let saved_secrets = load_saved_secrets(
631 bundle_path,
632 &state.env,
633 &state.tenant,
634 state.team.as_deref(),
635 &provider_form_specs,
636 )
637 .await;
638
639 let prefill = &state.prefill_answers;
641
642 let shared_questions: Vec<QuestionInfo> = shared_question_specs
646 .iter()
647 .filter(|q| !HIDDEN_FROM_PROMPTS.contains(&q.id.as_str()))
648 .map(|q| {
649 let mut info = form_question_to_info(q, Some(&i18n));
650 let mut found = false;
652 if let Some(answers) = prefill {
653 for pfs in &provider_form_specs {
654 if let Some(provider_answers) =
655 answers.get(&pfs.provider_id).and_then(|v| v.as_object())
656 && let Some(val) = provider_answers
657 .get(&q.id)
658 .and_then(value_as_nonempty_string)
659 {
660 info.saved_value = Some(val);
661 found = true;
662 break;
663 }
664 }
665 }
666 if !found {
668 for secrets in saved_secrets.values() {
669 if let Some(val) = secrets.get(&q.id) {
670 info.saved_value = Some(val.clone());
671 break;
672 }
673 }
674 }
675 info
676 })
677 .collect();
678
679 let provider_forms: Vec<ProviderForm> = provider_form_specs
680 .iter()
681 .map(|pfs| {
682 let extras = extras_by_provider.get(&pfs.provider_id);
683 let saved = saved_secrets.get(&pfs.provider_id);
684 let answers = prefill
685 .as_ref()
686 .and_then(|a| a.get(&pfs.provider_id))
687 .and_then(|v| v.as_object());
688 ProviderForm {
689 provider_id: pfs.provider_id.clone(),
690 title: pfs.form_spec.title.clone(),
691 questions: pfs
692 .form_spec
693 .questions
694 .iter()
695 .filter(|q| !HIDDEN_FROM_PROMPTS.contains(&q.id.as_str()))
696 .map(|q| {
697 let mut info = form_question_to_info(q, Some(&i18n));
698 if let Some(ext) = extras.and_then(|m| m.get(&q.id)) {
699 if info.placeholder.is_none() {
700 info.placeholder = ext.placeholder.clone();
701 }
702 info.group = ext.group.clone();
703 info.docs_url = ext.docs_url.clone();
704 if let Some(ref mut cols) = info.list_columns {
709 for col in cols.iter_mut() {
710 if ext
711 .column_multilingual
712 .get(&col.id)
713 .copied()
714 .unwrap_or(false)
715 {
716 col.multilingual = true;
717 }
718 }
719 }
720 }
721 if let Some(val) = answers
723 .and_then(|m| m.get(&q.id))
724 .and_then(value_as_nonempty_string)
725 {
726 info.saved_value = Some(val);
727 } else if let Some(val) = saved.and_then(|m| m.get(&q.id)) {
728 info.saved_value = Some(val.clone());
729 }
730 if matches!(q.kind, qa_spec::QuestionType::List) {
736 if let Some(arr) = answers
737 .and_then(|m| m.get(&q.id))
738 .and_then(Value::as_array)
739 .filter(|a| !a.is_empty())
740 {
741 info.saved_rows = Some(arr.clone());
742 eprintln!(
743 "[hydrate] {} {} → saved_rows from prefill: {} row(s)",
744 pfs.provider_id,
745 q.id,
746 arr.len()
747 );
748 } else if q.id == "nav_links"
749 && pfs.provider_id.contains("webchat-gui")
750 {
751 match crate::tenant_config::read_existing_nav_links(
752 &state.bundle_path,
753 &state.tenant,
754 ) {
755 Some(rows) => {
756 eprintln!(
757 "[hydrate] {} nav_links → saved_rows from tenant.json: {} row(s)",
758 pfs.provider_id,
759 rows.len()
760 );
761 info.saved_rows = Some(rows);
762 }
763 None => {
764 eprintln!(
765 "[hydrate] {} nav_links → tenant.json had no nav_links (bundle_path={}, tenant={})",
766 pfs.provider_id,
767 state.bundle_path.display(),
768 state.tenant
769 );
770 }
771 }
772 }
773 }
774 info
775 })
776 .collect(),
777 }
778 })
779 .collect();
780
781 Json(serde_json::json!({
782 "bundle_path": bundle_path.display().to_string(),
783 "providers": providers,
784 "provider_forms": provider_forms,
785 "shared_questions": shared_questions,
786 "i18n": ui_strings,
787 }))
788}
789
790async fn post_execute(
791 State(state): State<std::sync::Arc<UiState>>,
792 Json(req): Json<ExecuteRequest>,
793) -> Json<ExecutionResult> {
794 let bundle_path = state.bundle_path.clone();
795 let tenant = req.tenant.unwrap_or_else(|| state.tenant.clone());
797 let team = req.team.or_else(|| state.team.clone());
798 let env = req.env.unwrap_or_else(|| state.env.clone());
799 let mut answers = req.answers;
800 let tunnel_mode = req.tunnel.as_deref().unwrap_or("off").to_string();
801
802 if let Some(mode) = req.tunnel.as_deref() {
804 let tunnel = crate::platform_setup::TunnelAnswers {
805 mode: Some(mode.to_string()),
806 };
807 let _ = crate::platform_setup::persist_tunnel_artifact(&state.bundle_path, &tunnel);
808 }
809
810 let setup_public_base_url = if should_start_setup_tunnel(&tunnel_mode, &answers) {
811 match ensure_setup_tunnel(&state, &tunnel_mode).await {
812 Ok(url) => {
813 inject_setup_public_base_url(&mut answers, &url);
814 Some(url)
815 }
816 Err(err) => {
817 return Json(ExecutionResult {
818 success: false,
819 stdout: String::new(),
820 stderr: format!("Failed to start setup tunnel: {err}"),
821 manual_steps: vec![],
822 pending_setup_actions: vec![],
823 });
824 }
825 }
826 } else {
827 None
828 };
829
830 let bundle_path_for_repack = bundle_path.clone();
831 let mut result = tokio::task::spawn_blocking(move || {
832 execute_setup(&bundle_path, &tenant, team.as_deref(), &env, answers)
833 })
834 .await
835 .unwrap_or_else(|e| ExecutionResult {
836 success: false,
837 stdout: String::new(),
838 stderr: format!("Task panicked: {e}"),
839 manual_steps: vec![],
840 pending_setup_actions: vec![],
841 });
842 if let Some(public_base_url) = setup_public_base_url.as_deref()
843 && result.success
844 {
845 result.stdout = append_line(
846 &result.stdout,
847 &format!("Setup tunnel public_base_url: {public_base_url}"),
848 );
849 }
850
851 if result.success
857 && let Some(target) = state.output_target.clone()
858 {
859 let repack = tokio::task::spawn_blocking(move || -> Result<String, anyhow::Error> {
860 use crate::cli_helpers::{SetupOutputTarget, copy_dir_recursive};
861 use crate::gtbundle;
862 match target {
863 SetupOutputTarget::Archive(out) => {
864 gtbundle::create_gtbundle(&bundle_path_for_repack, &out).with_context(
865 || {
866 format!(
867 "failed to write configured .gtbundle archive to {}",
868 out.display()
869 )
870 },
871 )?;
872 Ok(format!("Configured bundle written to: {}", out.display()))
873 }
874 SetupOutputTarget::Directory(out) => {
875 if out.exists() {
876 if out.is_dir() {
877 std::fs::remove_dir_all(&out).with_context(|| {
878 format!(
879 "failed to replace existing bundle directory {}",
880 out.display()
881 )
882 })?;
883 } else {
884 std::fs::remove_file(&out).with_context(|| {
885 format!("failed to replace existing bundle file {}", out.display())
886 })?;
887 }
888 }
889 copy_dir_recursive(&bundle_path_for_repack, &out, false)
890 .context("failed to write configured local bundle directory")?;
891 Ok(format!("Configured bundle written to: {}", out.display()))
892 }
893 }
894 })
895 .await;
896 match repack {
897 Ok(Ok(msg)) => result.stdout.push_str(&format!("\n{msg}\n")),
898 Ok(Err(e)) => {
899 result.success = false;
900 result
901 .stderr
902 .push_str(&format!("\nWrite-back failed: {e:#}\n"));
903 }
904 Err(e) => {
905 result.success = false;
906 result
907 .stderr
908 .push_str(&format!("\nWrite-back panicked: {e}\n"));
909 }
910 }
911 }
912
913 *state.result.lock().unwrap() = Some(result.clone());
914 Json(result)
915}
916
917async fn post_draft(
918 State(state): State<std::sync::Arc<UiState>>,
919 Json(req): Json<DraftSaveRequest>,
920) -> Json<Value> {
921 match persist_ui_draft(
922 &state.bundle_path,
923 &req.tenant,
924 req.team.as_deref(),
925 &req.env,
926 &req.answers,
927 )
928 .await
929 {
930 Ok(persisted) => Json(serde_json::json!({
931 "ok": true,
932 "persisted": persisted,
933 })),
934 Err(err) => Json(serde_json::json!({
935 "ok": false,
936 "error": err.to_string(),
937 })),
938 }
939}
940
941#[derive(Deserialize)]
942struct ExportRequest {
943 scopes: Vec<ExportScope>,
944 #[serde(default)]
945 key: Option<String>,
946}
947
948#[derive(Deserialize)]
949struct ExportScope {
950 tenant: String,
951 #[serde(default)]
952 team: Option<String>,
953 env: String,
954 answers: JsonMap<String, Value>,
955}
956
957async fn post_export(
958 State(state): State<std::sync::Arc<UiState>>,
959 Json(req): Json<ExportRequest>,
960) -> Json<Value> {
961 let bundle_path = state.bundle_path.clone();
962
963 let discovered = discovery::discover(&bundle_path).ok();
965 let secret_fields: std::collections::HashSet<String> = discovered
966 .iter()
967 .flat_map(|d| d.setup_targets())
968 .filter_map(|p| setup_to_formspec::pack_to_form_spec(&p.pack_path, &p.provider_id))
969 .flat_map(|spec| spec.questions.into_iter())
970 .filter(|q| q.secret)
971 .map(|q| q.id)
972 .collect();
973
974 let mut scopes_json = Vec::new();
975 for scope in &req.scopes {
976 let mut setup_answers = JsonMap::new();
977 for (provider_id, provider_answers) in &scope.answers {
978 let mut encrypted_answers = JsonMap::new();
979 if let Some(obj) = provider_answers.as_object() {
980 for (field, value) in obj {
981 if secret_fields.contains(field) && req.key.is_some() {
982 let key = req.key.as_deref().unwrap();
983 match crate::answers_crypto::encrypt_value(value, key) {
984 Ok(enc) => {
985 encrypted_answers.insert(field.clone(), enc);
986 }
987 Err(_) => {
988 encrypted_answers.insert(field.clone(), value.clone());
989 }
990 }
991 } else {
992 encrypted_answers.insert(field.clone(), value.clone());
993 }
994 }
995 }
996 setup_answers.insert(provider_id.clone(), Value::Object(encrypted_answers));
997 }
998 scopes_json.push(serde_json::json!({
999 "tenant": scope.tenant,
1000 "team": scope.team,
1001 "env": scope.env,
1002 "setup_answers": setup_answers,
1003 }));
1004 }
1005
1006 let doc = if scopes_json.len() == 1 {
1009 let mut single = scopes_json.into_iter().next().unwrap();
1010 if let Some(obj) = single.as_object_mut() {
1011 obj.insert(
1012 "greentic_setup_version".to_string(),
1013 Value::String("1.0.0".to_string()),
1014 );
1015 obj.insert(
1016 "bundle_source".to_string(),
1017 Value::String(bundle_path.display().to_string()),
1018 );
1019 }
1020 single
1021 } else {
1022 serde_json::json!({
1023 "greentic_setup_version": "1.0.0",
1024 "bundle_source": bundle_path.display().to_string(),
1025 "scopes": scopes_json,
1026 })
1027 };
1028
1029 Json(doc)
1030}
1031
1032#[derive(Deserialize)]
1033struct DecryptRequest {
1034 doc: Value,
1035 key: String,
1036}
1037
1038async fn post_decrypt(Json(req): Json<DecryptRequest>) -> Json<Value> {
1039 match crate::answers_crypto::decrypt_tree(&req.doc, &req.key) {
1040 Ok(decrypted) => Json(serde_json::json!({ "ok": true, "doc": decrypted })),
1041 Err(e) => Json(serde_json::json!({ "ok": false, "error": e.to_string() })),
1042 }
1043}
1044
1045async fn get_oauth_callback(
1046 State(state): State<std::sync::Arc<UiState>>,
1047 Query(query): Query<std::collections::HashMap<String, String>>,
1048) -> impl IntoResponse {
1049 let code = query.get("code").cloned().unwrap_or_default();
1050 let oauth_state = query.get("state").cloned().unwrap_or_default();
1051 if code.is_empty() || oauth_state.is_empty() {
1052 return (
1053 axum::http::StatusCode::BAD_REQUEST,
1054 [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
1055 oauth_callback_page(
1056 false,
1057 "OAuth setup failed",
1058 "OAuth callback missing code or state.",
1059 ),
1060 );
1061 }
1062 match crate::oauth_callback::complete_oauth_callback(
1063 &state.bundle_path,
1064 &state.env,
1065 &crate::oauth_callback::OAuthCallbackInput {
1066 code,
1067 state: oauth_state,
1068 },
1069 "messaging.oauth.v1",
1070 )
1071 .await
1072 {
1073 Ok(report) => {
1074 let message = format!(
1075 "OAuth setup complete for {} ({}/{})",
1076 report.provider_id, report.tenant, report.team
1077 );
1078 (
1079 axum::http::StatusCode::OK,
1080 [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
1081 oauth_callback_page(
1082 true,
1083 "OAuth setup complete",
1084 &format!("{message}. You can close this tab and return to setup."),
1085 ),
1086 )
1087 }
1088 Err(err) => (
1089 axum::http::StatusCode::BAD_REQUEST,
1090 [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
1091 oauth_callback_page(false, "OAuth setup failed", &err.to_string()),
1092 ),
1093 }
1094}
1095
1096fn oauth_callback_page(success: bool, title: &str, message: &str) -> String {
1097 let status_class = if success { "success" } else { "error" };
1098 let close_script = if success {
1099 r#"<script>
1100setTimeout(function () {
1101 window.close();
1102}, 800);
1103</script>"#
1104 } else {
1105 ""
1106 };
1107 format!(
1108 r#"<!doctype html>
1109<html lang="en">
1110<head>
1111 <meta charset="utf-8">
1112 <meta name="viewport" content="width=device-width, initial-scale=1">
1113 <title>{title}</title>
1114 <style>
1115 body {{ margin: 0; min-height: 100vh; display: grid; place-items: center; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f6f8fb; color: #17202a; }}
1116 main {{ width: min(520px, calc(100vw - 32px)); padding: 28px; border: 1px solid #d7dee8; border-radius: 8px; background: #fff; box-shadow: 0 16px 40px rgba(15, 23, 42, .08); }}
1117 h1 {{ margin: 0 0 12px; font-size: 1.35rem; line-height: 1.25; }}
1118 p {{ margin: 0; line-height: 1.55; color: #465466; }}
1119 .success h1 {{ color: #087f5b; }}
1120 .error h1 {{ color: #b42318; }}
1121 </style>
1122</head>
1123<body>
1124 <main class="{status_class}">
1125 <h1>{title}</h1>
1126 <p>{message}</p>
1127 </main>
1128 {close_script}
1129</body>
1130</html>"#,
1131 title = html_escape(title),
1132 message = html_escape(message),
1133 status_class = status_class,
1134 close_script = close_script
1135 )
1136}
1137
1138fn html_escape(value: &str) -> String {
1139 value
1140 .replace('&', "&")
1141 .replace('<', "<")
1142 .replace('>', ">")
1143 .replace('"', """)
1144 .replace('\'', "'")
1145}
1146
1147async fn post_oauth_device_start(
1148 State(state): State<std::sync::Arc<UiState>>,
1149 Json(req): Json<crate::oauth_device::OAuthDeviceStartInput>,
1150) -> Json<Value> {
1151 match crate::oauth_device::start_oauth_device_code(
1152 &state.bundle_path,
1153 &req,
1154 crate::oauth_device::DEFAULT_EXTENSION_KEY,
1155 ) {
1156 Ok(report) => Json(serde_json::json!({ "ok": true, "report": report })),
1157 Err(err) => Json(serde_json::json!({ "ok": false, "error": err.to_string() })),
1158 }
1159}
1160
1161async fn post_oauth_device_poll(
1162 State(state): State<std::sync::Arc<UiState>>,
1163 Json(req): Json<crate::oauth_device::OAuthDevicePollInput>,
1164) -> Json<Value> {
1165 match crate::oauth_device::poll_oauth_device_code(
1166 &state.bundle_path,
1167 &state.env,
1168 &req,
1169 crate::oauth_device::DEFAULT_EXTENSION_KEY,
1170 )
1171 .await
1172 {
1173 Ok(report) => Json(serde_json::json!({ "ok": true, "report": report })),
1174 Err(err) => Json(serde_json::json!({ "ok": false, "error": err.to_string() })),
1175 }
1176}
1177
1178async fn post_shutdown(State(state): State<std::sync::Arc<UiState>>) {
1179 let _ = state.shutdown_tx.send(());
1180}
1181
1182fn append_line(existing: &str, line: &str) -> String {
1185 if existing.trim().is_empty() {
1186 line.to_string()
1187 } else {
1188 format!("{existing}\n{line}")
1189 }
1190}
1191
1192async fn ensure_setup_tunnel(state: &std::sync::Arc<UiState>, mode: &str) -> Result<String> {
1193 {
1194 let guard = state
1195 .setup_tunnel
1196 .lock()
1197 .map_err(|_| anyhow!("setup tunnel lock poisoned"))?;
1198 if let Some(tunnel) = guard.as_ref()
1199 && tunnel.mode == mode
1200 {
1201 return Ok(tunnel.public_base_url.clone());
1202 }
1203 }
1204
1205 let mode = mode.to_string();
1206 let local_base_url = state.local_base_url.clone();
1207 let tunnel = tokio::task::spawn_blocking(move || start_setup_tunnel(&mode, &local_base_url))
1208 .await
1209 .map_err(|err| anyhow!("setup tunnel task failed: {err}"))??;
1210 let public_base_url = tunnel.public_base_url.clone();
1211 let mut guard = state
1212 .setup_tunnel
1213 .lock()
1214 .map_err(|_| anyhow!("setup tunnel lock poisoned"))?;
1215 *guard = Some(tunnel);
1216 Ok(public_base_url)
1217}
1218
1219fn execute_setup(
1220 bundle_path: &Path,
1221 tenant: &str,
1222 team: Option<&str>,
1223 env: &str,
1224 answers: JsonMap<String, Value>,
1225) -> ExecutionResult {
1226 let config = SetupConfig {
1227 tenant: tenant.to_string(),
1228 team: team.map(String::from),
1229 env: env.to_string(),
1230 offline: false,
1231 verbose: true,
1232 };
1233
1234 let static_routes = match StaticRoutesPolicy::normalize(None, env) {
1235 Ok(sr) => sr,
1236 Err(e) => {
1237 return ExecutionResult {
1238 success: false,
1239 stdout: String::new(),
1240 stderr: format!("Failed to normalize static routes: {e}"),
1241 manual_steps: vec![],
1242 pending_setup_actions: vec![],
1243 };
1244 }
1245 };
1246
1247 let provider_configs: Vec<(String, serde_json::Value)> = answers
1249 .iter()
1250 .map(|(id, val)| (id.clone(), val.clone()))
1251 .collect();
1252 let team_str = team.unwrap_or("default");
1253 let manual_steps =
1254 crate::webhook::collect_post_setup_instructions(&provider_configs, tenant, team_str);
1255
1256 let request = SetupRequest {
1257 bundle: bundle_path.to_path_buf(),
1258 bundle_name: crate::bundle::read_bundle_name(bundle_path).ok().flatten(),
1259 tenants: vec![TenantSelection {
1260 tenant: tenant.to_string(),
1261 team: team.map(String::from),
1262 allow_paths: Vec::new(),
1263 }],
1264 static_routes,
1265 deployment_targets: Vec::new(),
1266 setup_answers: answers,
1267 ..Default::default()
1268 };
1269
1270 let engine = SetupEngine::new(config);
1271
1272 let plan = match engine.plan(SetupMode::Create, &request, false) {
1273 Ok(p) => p,
1274 Err(e) => {
1275 return ExecutionResult {
1276 success: false,
1277 stdout: String::new(),
1278 stderr: format!("Failed to build plan: {e}"),
1279 manual_steps: vec![],
1280 pending_setup_actions: vec![],
1281 };
1282 }
1283 };
1284
1285 let mut stdout = String::new();
1287 for step in &plan.steps {
1288 stdout.push_str(&format!(" {:?}: {}\n", step.kind, step.description));
1289 }
1290
1291 match engine.execute(&plan) {
1292 Ok(report) => {
1293 stdout.push_str(&format!(
1294 "\n{} provider(s) updated, {} pack(s) resolved.\n",
1295 report.provider_updates,
1296 report.resolved_packs.len()
1297 ));
1298 if !report.warnings.is_empty() {
1299 for w in &report.warnings {
1300 stdout.push_str(&format!(" warning: {w}\n"));
1301 }
1302 }
1303 ExecutionResult {
1304 success: true,
1305 stdout: format!(
1306 "Plan ({} steps):\n{stdout}Setup completed successfully.",
1307 plan.steps.len()
1308 ),
1309 stderr: String::new(),
1310 manual_steps,
1311 pending_setup_actions: report.pending_setup_actions,
1312 }
1313 }
1314 Err(e) => ExecutionResult {
1315 success: false,
1316 stdout,
1317 stderr: format!("Execution failed: {e}"),
1318 manual_steps: vec![],
1319 pending_setup_actions: vec![],
1320 },
1321 }
1322}
1323
1324async fn load_saved_secrets(
1328 bundle_path: &Path,
1329 env: &str,
1330 tenant: &str,
1331 team: Option<&str>,
1332 provider_form_specs: &[wizard::ProviderFormSpec],
1333) -> std::collections::HashMap<String, std::collections::HashMap<String, String>> {
1334 use greentic_secrets_lib::SecretsStore;
1335
1336 let store = match crate::secrets::open_dev_store(bundle_path) {
1337 Ok(s) => s,
1338 Err(_) => return std::collections::HashMap::new(),
1339 };
1340
1341 let mut result = std::collections::HashMap::new();
1342 for pfs in provider_form_specs {
1343 let mut values = std::collections::HashMap::new();
1344 for q in &pfs.form_spec.questions {
1345 let uri = crate::canonical_secret_uri(env, tenant, team, &pfs.provider_id, &q.id);
1346 if let Ok(bytes) = store.get(&uri).await
1347 && let Ok(text) = String::from_utf8(bytes)
1348 && !text.is_empty()
1349 {
1350 values.insert(q.id.clone(), text);
1351 }
1352 }
1353 if !values.is_empty() {
1354 result.insert(pfs.provider_id.clone(), values);
1355 }
1356 }
1357 result
1358}
1359
1360async fn persist_ui_draft(
1361 bundle_path: &Path,
1362 tenant: &str,
1363 team: Option<&str>,
1364 env: &str,
1365 answers: &JsonMap<String, Value>,
1366) -> Result<JsonMap<String, Value>> {
1367 let discovered = discovery::discover(bundle_path).ok();
1368 let mut persisted = JsonMap::new();
1369
1370 for (provider_id, provider_answers) in answers {
1371 let Some(config) = provider_answers.as_object() else {
1372 continue;
1373 };
1374 if config.is_empty() {
1375 continue;
1376 }
1377
1378 let pack_path = discovered.as_ref().and_then(|d| {
1379 d.find_setup_target(provider_id)
1380 .map(|provider| provider.pack_path.as_path())
1381 });
1382
1383 let keys = crate::qa::persist::persist_all_config_as_secrets(
1384 bundle_path,
1385 env,
1386 tenant,
1387 team,
1388 provider_id,
1389 provider_answers,
1390 pack_path,
1391 )
1392 .await?;
1393
1394 if !keys.is_empty() {
1395 persisted.insert(provider_id.clone(), serde_json::to_value(keys)?);
1396 }
1397 }
1398
1399 Ok(persisted)
1400}
1401
1402fn value_as_nonempty_string(v: &Value) -> Option<String> {
1404 match v {
1405 Value::String(s) if !s.is_empty() => Some(s.clone()),
1406 Value::Number(n) => Some(n.to_string()),
1407 Value::Bool(b) => Some(b.to_string()),
1408 _ => None,
1409 }
1410}
1411
1412fn form_question_to_info(q: &qa_spec::QuestionSpec, i18n: Option<&CliI18n>) -> QuestionInfo {
1413 let visible_if = q.visible_if.as_ref().and_then(|v| match v {
1414 qa_spec::Expr::Eq { left, right } => {
1415 let field = match left.as_ref() {
1416 qa_spec::Expr::Answer { path } => path.clone(),
1417 _ => return None,
1418 };
1419 let eq = match right.as_ref() {
1420 qa_spec::Expr::Literal { value } => {
1421 Some(value.as_str().unwrap_or("true").to_string())
1422 }
1423 _ => None,
1424 };
1425 Some(VisibleIfInfo { field, eq })
1426 }
1427 qa_spec::Expr::Answer { path } => Some(VisibleIfInfo {
1428 field: path.clone(),
1429 eq: None,
1430 }),
1431 _ => None,
1432 });
1433
1434 let title_key = format!("ui.q.{}", q.id);
1436 let help_key = format!("ui.q.{}.help", q.id);
1437
1438 let title = i18n
1439 .and_then(|i| {
1440 let t = i.t(&title_key);
1441 if t != title_key { Some(t) } else { None }
1442 })
1443 .unwrap_or_else(|| q.title.clone());
1444
1445 let help = i18n
1446 .and_then(|i| {
1447 let t = i.t(&help_key);
1448 if t != help_key { Some(t) } else { None }
1449 })
1450 .or_else(|| q.description.clone());
1451
1452 let (list_columns, min_rows, max_rows) = q
1453 .list
1454 .as_ref()
1455 .map(|list| {
1456 let cols: Vec<ListColumnInfo> = list
1457 .fields
1458 .iter()
1459 .map(|c| ListColumnInfo {
1460 id: c.id.clone(),
1461 title: c.title.clone(),
1462 kind: format!("{:?}", c.kind),
1463 required: c.required,
1464 help: c.description.clone(),
1465 placeholder: None,
1466 choices: c.choices.clone(),
1467 default_value: c.default_value.clone(),
1468 multilingual: false,
1473 })
1474 .collect();
1475 (Some(cols), list.min_items, list.max_items)
1476 })
1477 .unwrap_or((None, None, None));
1478
1479 QuestionInfo {
1480 id: q.id.clone(),
1481 title,
1482 kind: format!("{:?}", q.kind),
1483 required: q.required,
1484 secret: q.secret,
1485 default_value: q.default_value.clone(),
1486 saved_value: None,
1487 saved_rows: None,
1488 help,
1489 choices: q.choices.clone(),
1490 visible_if,
1491 placeholder: None,
1492 group: None,
1493 docs_url: None,
1494 list_columns,
1495 min_rows,
1496 max_rows,
1497 }
1498}
1499
1500#[cfg(test)]
1501mod tests {
1502 use super::{persist_ui_draft, prefill_has_cloud_deployment_targets};
1503 use crate::secrets::open_dev_store;
1504 use greentic_secrets_lib::SecretsStore;
1505 use serde_json::{Map as JsonMap, Value, json};
1506 use std::io::Write;
1507 use zip::write::SimpleFileOptions;
1508
1509 fn write_pack_with_secret_requirements(
1510 path: &std::path::Path,
1511 pack_id: &str,
1512 req_json: &str,
1513 ) -> anyhow::Result<()> {
1514 let file = std::fs::File::create(path)?;
1515 let mut zip = zip::ZipWriter::new(file);
1516 zip.start_file("manifest.json", SimpleFileOptions::default())?;
1517 zip.write_all(format!(r#"{{"pack_id":"{pack_id}"}}"#).as_bytes())?;
1518 zip.start_file(
1519 "assets/secret-requirements.json",
1520 SimpleFileOptions::default(),
1521 )?;
1522 zip.write_all(req_json.as_bytes())?;
1523 zip.finish()?;
1524 Ok(())
1525 }
1526
1527 #[test]
1528 fn oauth_callback_page_tells_user_to_close_success_tab() {
1529 let page = super::oauth_callback_page(
1530 true,
1531 "OAuth setup complete",
1532 "OAuth setup complete for messaging-slack. You can close this tab.",
1533 );
1534
1535 assert!(page.contains("window.close()"));
1536 assert!(page.contains("You can close this tab"));
1537 }
1538
1539 #[tokio::test]
1540 async fn persist_ui_draft_writes_provider_answers_to_dev_store() {
1541 let temp = tempfile::tempdir().expect("tempdir");
1542 let bundle_root = temp.path();
1543 std::fs::create_dir_all(bundle_root.join("packs")).expect("packs dir");
1544
1545 let pack_path = bundle_root.join("packs").join("weatherapi-pack.gtpack");
1546 write_pack_with_secret_requirements(
1547 &pack_path,
1548 "weatherapi-pack",
1549 r#"[{"key":"auth.param.get_weather.key"}]"#,
1550 )
1551 .expect("pack");
1552
1553 let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1554 "weatherapi-pack": {
1555 "auth_param_get_weather_key": "test-weather-key"
1556 }
1557 }))
1558 .expect("answers");
1559
1560 let persisted = persist_ui_draft(bundle_root, "dev-tenant", None, "dev", &answers)
1561 .await
1562 .expect("persist draft");
1563 assert_eq!(
1564 persisted.get("weatherapi-pack"),
1565 Some(&json!(["auth_param_get_weather_key"]))
1566 );
1567
1568 let store = open_dev_store(bundle_root).expect("open store");
1569 let base_uri = crate::canonical_secret_uri(
1570 "dev",
1571 "dev-tenant",
1572 None,
1573 "weatherapi-pack",
1574 "auth_param_get_weather_key",
1575 );
1576 let alias_uri = crate::canonical_secret_uri(
1577 "dev",
1578 "dev-tenant",
1579 None,
1580 "weatherapi-pack",
1581 "auth.param.get_weather.key",
1582 );
1583 let base_value =
1584 String::from_utf8(store.get(&base_uri).await.expect("base")).expect("base utf8");
1585 let alias_value =
1586 String::from_utf8(store.get(&alias_uri).await.expect("alias")).expect("alias utf8");
1587 assert_eq!(base_value, "test-weather-key");
1588 assert_eq!(alias_value, "test-weather-key");
1589 }
1590
1591 #[test]
1592 fn detects_cloud_deploy_targets_in_prefill_answers() {
1593 let cloud_prefill = serde_json::from_value::<JsonMap<String, Value>>(json!({
1594 "platform_setup": {
1595 "deployment_targets": [
1596 { "target": "runtime" },
1597 { "target": "aws" }
1598 ]
1599 }
1600 }))
1601 .expect("cloud prefill");
1602 assert!(prefill_has_cloud_deployment_targets(Some(&cloud_prefill)));
1603
1604 let local_prefill = serde_json::from_value::<JsonMap<String, Value>>(json!({
1605 "platform_setup": {
1606 "deployment_targets": [
1607 { "target": "runtime" },
1608 { "target": "single-vm" }
1609 ]
1610 }
1611 }))
1612 .expect("local prefill");
1613 assert!(!prefill_has_cloud_deployment_targets(Some(&local_prefill)));
1614 }
1615}