1mod bundle;
4mod env_vars;
5mod prompts;
6
7use std::path::Path;
8
9use anyhow::Result;
10use qa_spec::{VisibilityMode, resolve_visibility};
11use serde_json::Value;
12
13use crate::deployment_targets::DeploymentTargetRecord;
14use crate::discovery;
15use crate::engine::LoadedAnswers;
16use crate::platform_setup::{
17 PlatformSetupAnswers, StaticRoutesPolicy, TunnelAnswers, load_effective_static_routes_defaults,
18 prompt_static_routes_policy, prompt_static_routes_policy_with_answers,
19};
20use crate::qa::wizard;
21use crate::setup_to_formspec;
22use crate::setup_tunnel::{SetupTunnel, inject_setup_public_base_url, should_start_setup_tunnel};
23
24pub use bundle::{
26 SetupOutputTarget, copy_dir_recursive, detect_domain_from_filename, resolve_bundle_dir,
27 resolve_bundle_source, resolve_pack_source, setup_output_target,
28};
29pub use env_vars::{
30 EnvVarPlaceholder, apply_resolved_env_vars, collect_env_var_placeholders,
31 confirm_env_var_placeholders,
32};
33pub use prompts::{SetupParams, prompt_setup_params};
34
35pub fn maybe_start_cli_setup_tunnel(
43 bundle_root: &Path,
44 loaded: &mut LoadedAnswers,
45 local_base_url: &str,
46) -> Result<Option<SetupTunnel>> {
47 let mode = loaded
48 .platform_setup
49 .tunnel
50 .as_ref()
51 .and_then(|tunnel| tunnel.mode.as_deref())
52 .unwrap_or("off")
53 .to_string();
54 if !should_start_setup_tunnel(&mode, &loaded.setup_answers) {
55 return Ok(None);
56 }
57
58 let tunnel = crate::setup_tunnel::start_setup_tunnel(&mode, local_base_url)?;
59 inject_setup_public_base_url(&mut loaded.setup_answers, &tunnel.public_base_url);
60 persist_tunnel_handoff(bundle_root, &tunnel);
61 Ok(Some(tunnel))
62}
63
64fn persist_tunnel_handoff(bundle_root: &Path, tunnel: &SetupTunnel) {
68 let Some(local_port) = crate::shared_tunnel::local_port_from_base_url(&tunnel.local_base_url)
69 else {
70 return;
71 };
72 let handoff = crate::platform_setup::TunnelHandoff {
73 service: tunnel.mode.clone(),
74 local_port,
75 public_base_url: tunnel.public_base_url.clone(),
76 };
77 if let Err(err) = crate::platform_setup::persist_tunnel_handoff_artifact(bundle_root, &handoff)
78 {
79 tracing::warn!("failed to persist setup tunnel handoff: {err:#}");
80 }
81}
82
83pub fn resolve_setup_scope(
90 tenant: String,
91 team: Option<String>,
92 env: String,
93 loaded: &LoadedAnswers,
94) -> (String, Option<String>, String) {
95 let tenant = if tenant == "demo" {
96 loaded.tenant.clone().unwrap_or(tenant)
97 } else {
98 tenant
99 };
100 let team = if team.is_none() {
101 loaded.team.clone()
102 } else {
103 team
104 };
105 let env = if env == crate::LEGACY_ENV_ID || env == crate::DEFAULT_ENV_ID {
109 loaded.env.clone().unwrap_or(env)
110 } else {
111 env
112 };
113 (tenant, team, env)
114}
115
116pub fn resolve_setup_scope_with_bundle(
119 tenant: String,
120 team: Option<String>,
121 env: String,
122 loaded: &LoadedAnswers,
123 bundle_dir: &std::path::Path,
124) -> (String, Option<String>, String) {
125 let (mut tenant, team, env) = resolve_setup_scope(tenant, team, env, loaded);
126
127 if tenant == "demo"
130 && loaded.tenant.is_none()
131 && let Some(detected) = detect_tenant_from_bundle(bundle_dir)
132 {
133 tenant = detected;
134 }
135
136 (tenant, team, env)
137}
138
139fn detect_tenant_from_bundle(bundle_dir: &std::path::Path) -> Option<String> {
143 let tenants_dir = bundle_dir.join("tenants");
144 let entries: Vec<String> = std::fs::read_dir(&tenants_dir)
145 .ok()?
146 .filter_map(|e| e.ok())
147 .filter(|e| e.path().is_dir())
148 .filter_map(|e| e.file_name().into_string().ok())
149 .collect();
150
151 match entries.len() {
152 0 => None,
153 1 => Some(entries[0].clone()),
154 _ => {
155 entries
157 .iter()
158 .find(|t| t.as_str() != "demo")
159 .cloned()
160 .or_else(|| entries.first().cloned())
161 }
162 }
163}
164
165fn has_cloud_deployment_target(targets: &[DeploymentTargetRecord]) -> bool {
166 targets
167 .iter()
168 .any(|record| matches!(record.target.as_str(), "aws" | "gcp" | "azure"))
169}
170
171fn default_no_tunnel_answers() -> TunnelAnswers {
172 TunnelAnswers {
173 mode: Some("off".to_string()),
174 }
175}
176
177pub fn run_interactive_wizard(
179 bundle_path: &Path,
180 tenant: &str,
181 team: Option<&str>,
182 env: &str,
183 advanced: bool,
184) -> Result<LoadedAnswers> {
185 use serde_json::Value;
186
187 let mut all_answers = serde_json::Map::new();
188 let existing_static_routes = load_effective_static_routes_defaults(bundle_path, tenant, team)?;
189 let static_routes = prompt_static_routes_policy(env, existing_static_routes.as_ref())?;
190 let deployer_candidates =
191 crate::deployment_targets::discover_deployer_pack_candidates(bundle_path)?;
192 let deployment_targets =
193 crate::deployment_targets::reconcile_deployment_targets(bundle_path, &[])?;
194
195 let tunnel = if has_cloud_deployment_target(&deployment_targets) {
197 Some(default_no_tunnel_answers())
198 } else if deployer_candidates.is_empty() {
199 Some(crate::platform_setup::prompt_tunnel_mode(None)?)
200 } else {
201 None
202 };
203
204 let discovered = discovery::discover(bundle_path)?;
205 let setup_targets = discovered.setup_targets();
206
207 if setup_targets.is_empty() {
208 println!("No setup packs found in bundle. Nothing to configure.");
209 return Ok(LoadedAnswers {
210 tenant: None,
211 team: None,
212 env: None,
213 platform_setup: PlatformSetupAnswers {
214 static_routes: Some(static_routes.to_answers()),
215 deployment_targets,
216 tunnel,
217 telemetry: None,
218 },
219 setup_answers: all_answers,
220 providers: Vec::new(),
221 });
222 }
223
224 println!("Found {} pack(s) to configure:", setup_targets.len());
225 for provider in &setup_targets {
226 println!(" - {} ({})", provider.provider_id, provider.domain);
227 }
228 println!();
229
230 let provider_form_specs: Vec<wizard::ProviderFormSpec> = setup_targets
233 .iter()
234 .filter_map(|provider| {
235 setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id).map(
236 |form_spec| wizard::ProviderFormSpec {
237 provider_id: provider.provider_id.clone(),
238 form_spec,
239 },
240 )
241 })
242 .collect();
243
244 let shared_answers = if provider_form_specs.len() > 1 {
247 let shared_result = wizard::collect_shared_questions(&provider_form_specs);
248 if !shared_result.shared_questions.is_empty() {
249 let empty = Value::Object(serde_json::Map::new());
250 wizard::prompt_shared_questions(&shared_result, advanced, &empty)?
251 } else {
252 Value::Object(serde_json::Map::new())
253 }
254 } else {
255 Value::Object(serde_json::Map::new())
256 };
257
258 for provider in &setup_targets {
260 let provider_id = &provider.provider_id;
261 let form_spec = setup_to_formspec::pack_to_form_spec(&provider.pack_path, provider_id);
262
263 if let Some(spec) = form_spec {
264 if spec.questions.is_empty() {
265 println!("Provider {}: No configuration required.", provider_id);
266 all_answers.insert(provider_id.clone(), Value::Object(serde_json::Map::new()));
267 continue;
268 }
269
270 let answers = wizard::prompt_form_spec_answers_with_existing(
273 &spec,
274 provider_id,
275 advanced,
276 &shared_answers,
277 None,
278 )?;
279 all_answers.insert(provider_id.clone(), answers);
280 } else {
281 println!(
282 "Provider {}: No setup questions found (may use flow-based setup).",
283 provider_id
284 );
285 all_answers.insert(provider_id.clone(), Value::Object(serde_json::Map::new()));
286 }
287
288 println!();
289 }
290
291 Ok(LoadedAnswers {
292 tenant: None,
293 team: None,
294 env: None,
295 platform_setup: PlatformSetupAnswers {
296 static_routes: Some(static_routes.to_answers()),
297 deployment_targets,
298 tunnel,
299 telemetry: None,
300 },
301 setup_answers: all_answers,
302 providers: Vec::new(),
303 })
304}
305
306pub fn complete_loaded_answers_with_prompts(
313 bundle_path: &Path,
314 tenant: &str,
315 team: Option<&str>,
316 env: &str,
317 advanced: bool,
318 non_interactive: bool,
319 mut loaded: LoadedAnswers,
320) -> Result<LoadedAnswers> {
321 let existing_static_routes = load_effective_static_routes_defaults(bundle_path, tenant, team)?;
322 let static_routes_need_prompt = match loaded.platform_setup.static_routes.as_ref() {
323 None => true,
324 Some(answers) => StaticRoutesPolicy::normalize(Some(answers), env).is_err(),
325 };
326 if static_routes_need_prompt && !non_interactive {
327 let static_routes =
328 if let Some(current_answers) = loaded.platform_setup.static_routes.as_ref() {
329 prompt_static_routes_policy_with_answers(
330 env,
331 Some(current_answers),
332 existing_static_routes.as_ref(),
333 )?
334 } else {
335 prompt_static_routes_policy(env, existing_static_routes.as_ref())?
336 };
337 loaded.platform_setup.static_routes = Some(static_routes.to_answers());
338 }
339 let deployer_candidates =
340 crate::deployment_targets::discover_deployer_pack_candidates(bundle_path)?;
341 loaded.platform_setup.deployment_targets =
342 crate::deployment_targets::reconcile_deployment_targets(
343 bundle_path,
344 &loaded.platform_setup.deployment_targets,
345 )?;
346 if has_cloud_deployment_target(&loaded.platform_setup.deployment_targets) {
347 loaded.platform_setup.tunnel = Some(default_no_tunnel_answers());
348 } else if deployer_candidates.is_empty()
349 && loaded.platform_setup.tunnel.is_none()
350 && !non_interactive
351 {
352 loaded.platform_setup.tunnel = Some(crate::platform_setup::prompt_tunnel_mode(None)?);
353 }
354
355 if !non_interactive {
361 let env_placeholders = collect_env_var_placeholders(&loaded);
362 if !env_placeholders.is_empty() {
363 let resolved_env_vars = confirm_env_var_placeholders(&env_placeholders)?;
364
365 if !resolved_env_vars.is_empty() {
367 apply_resolved_env_vars(&mut loaded, &resolved_env_vars);
368 }
369 }
370 }
371
372 let discovered = discovery::discover(bundle_path)?;
373 let setup_targets = discovered.setup_targets();
374
375 let all_provider_form_specs: Vec<wizard::ProviderFormSpec> = setup_targets
378 .iter()
379 .filter(|provider| {
380 loaded
381 .setup_answers
382 .get(&provider.provider_id)
383 .map(crate::provider_state::provider_enabled)
384 .unwrap_or(true)
385 })
386 .filter_map(|provider| {
387 setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id).map(
388 |form_spec| wizard::ProviderFormSpec {
389 provider_id: provider.provider_id.clone(),
390 form_spec,
391 },
392 )
393 })
394 .collect();
395
396 let mut existing_shared_values = serde_json::Map::new();
399 let shared_result = if all_provider_form_specs.len() > 1 {
400 let result = wizard::collect_shared_questions(&all_provider_form_specs);
401 for question in &result.shared_questions {
403 for (_provider_id, provider_answers) in &loaded.setup_answers {
404 if let Some(value) = provider_answers.get(&question.id) {
405 if !(value.is_null() || value.is_string() && value.as_str() == Some("")) {
407 existing_shared_values.insert(question.id.clone(), value.clone());
408 break;
409 }
410 }
411 }
412 }
413 Some(result)
414 } else {
415 None
416 };
417
418 let shared_answers = if !non_interactive {
425 if let Some(ref result) = shared_result {
426 if !result.shared_questions.is_empty() {
427 let existing = serde_json::Value::Object(existing_shared_values);
428 wizard::prompt_shared_questions(result, advanced, &existing)?
429 } else {
430 serde_json::Value::Object(serde_json::Map::new())
431 }
432 } else {
433 serde_json::Value::Object(serde_json::Map::new())
434 }
435 } else {
436 serde_json::Value::Object(serde_json::Map::new())
437 };
438
439 for provider in &setup_targets {
441 let provider_id = &provider.provider_id;
442 let existing = loaded
443 .setup_answers
444 .get(provider_id)
445 .cloned()
446 .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
447 if !crate::provider_state::provider_enabled(&existing) {
448 loaded.setup_answers.insert(provider_id.clone(), existing);
449 continue;
450 }
451
452 if non_interactive {
456 loaded.setup_answers.insert(provider_id.clone(), existing);
457 continue;
458 }
459
460 let mut merged = existing.as_object().cloned().unwrap_or_default();
463 if let Some(shared_obj) = shared_answers.as_object() {
464 for (key, value) in shared_obj {
465 let is_non_empty =
467 !(value.is_null() || value.is_string() && value.as_str() == Some(""));
468 if is_non_empty {
469 merged.insert(key.clone(), value.clone());
470 }
471 }
472 }
473 let merged_value = serde_json::Value::Object(merged);
474
475 let form_spec = setup_to_formspec::pack_to_form_spec(&provider.pack_path, provider_id);
476 let completed = if let Some(spec) = form_spec {
477 if spec.questions.is_empty() {
478 existing
479 } else {
480 wizard::prompt_form_spec_answers_with_existing(
481 &spec,
482 provider_id,
483 advanced,
484 &merged_value,
485 None,
486 )?
487 }
488 } else {
489 existing
490 };
491 loaded.setup_answers.insert(provider_id.clone(), completed);
492 }
493
494 Ok(loaded)
495}
496
497pub fn ensure_deployment_targets_present(bundle_path: &Path, loaded: &LoadedAnswers) -> Result<()> {
499 let _ = crate::deployment_targets::reconcile_deployment_targets(
500 bundle_path,
501 &loaded.platform_setup.deployment_targets,
502 )?;
503 Ok(())
504}
505
506pub fn ensure_required_setup_answers_present(
508 bundle_path: &Path,
509 loaded: &LoadedAnswers,
510) -> Result<()> {
511 let discovered = discovery::discover(bundle_path)?;
512 for provider in discovered.setup_targets() {
513 let Some(spec) =
514 setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id)
515 else {
516 continue;
517 };
518 if spec.questions.is_empty() {
519 continue;
520 }
521
522 let answers = loaded
523 .setup_answers
524 .get(&provider.provider_id)
525 .cloned()
526 .unwrap_or_else(|| Value::Object(Default::default()));
527 if !crate::provider_state::provider_enabled(&answers) {
528 continue;
529 }
530 let answer_map = answers.as_object().ok_or_else(|| {
531 anyhow::anyhow!("answers for {} must be an object", provider.provider_id)
532 })?;
533 let visibility = resolve_visibility(&spec, &answers, VisibilityMode::Visible);
534
535 for question in spec.questions.iter().filter(|question| question.required) {
536 if !visibility.get(&question.id).copied().unwrap_or(true) {
537 continue;
538 }
539 let Some(value) = answer_map.get(&question.id) else {
540 anyhow::bail!(
541 "missing required setup answer for {}.{}",
542 provider.provider_id,
543 question.id
544 );
545 };
546 if !wizard::answer_satisfies_question(question, value) {
547 anyhow::bail!(
548 "missing required setup answer for {}.{}",
549 provider.provider_id,
550 question.id
551 );
552 }
553 }
554 }
555 Ok(())
556}
557
558#[cfg(test)]
559mod tests {
560 use super::{
561 default_no_tunnel_answers, has_cloud_deployment_target, resolve_setup_scope,
562 resolve_setup_scope_with_bundle,
563 };
564 use crate::deployment_targets::DeploymentTargetRecord;
565 use crate::engine::LoadedAnswers;
566
567 #[test]
568 fn resolve_setup_scope_prefers_answers_when_cli_is_default() {
569 let loaded = LoadedAnswers {
570 tenant: Some("acme".to_string()),
571 team: Some("core".to_string()),
572 env: Some("prod".to_string()),
573 ..Default::default()
574 };
575 let resolved = resolve_setup_scope("demo".to_string(), None, "dev".to_string(), &loaded);
576 assert_eq!(resolved.0, "acme");
577 assert_eq!(resolved.1.as_deref(), Some("core"));
578 assert_eq!(resolved.2, "prod");
579 }
580
581 #[test]
582 fn resolve_setup_scope_keeps_explicit_cli_values() {
583 let loaded = LoadedAnswers {
584 tenant: Some("acme".to_string()),
585 team: Some("core".to_string()),
586 env: Some("prod".to_string()),
587 ..Default::default()
588 };
589 let resolved = resolve_setup_scope(
590 "sandbox".to_string(),
591 Some("ops".to_string()),
592 "staging".to_string(),
593 &loaded,
594 );
595 assert_eq!(resolved.0, "sandbox");
596 assert_eq!(resolved.1.as_deref(), Some("ops"));
597 assert_eq!(resolved.2, "staging");
598 }
599
600 #[test]
601 fn bundle_detection_does_not_override_answers_tenant_demo() {
602 let temp = tempfile::tempdir().expect("tempdir");
603 std::fs::create_dir_all(temp.path().join("tenants").join("default")).expect("tenant dir");
604
605 let loaded = LoadedAnswers {
606 tenant: Some("demo".to_string()),
607 ..Default::default()
608 };
609
610 let (tenant, team, env) = resolve_setup_scope_with_bundle(
611 "demo".to_string(),
612 None,
613 "dev".to_string(),
614 &loaded,
615 temp.path(),
616 );
617
618 assert_eq!(tenant, "demo");
619 assert_eq!(team, None);
620 assert_eq!(env, "dev");
621 }
622
623 #[test]
624 fn resolve_setup_scope_treats_local_default_like_dev_default() {
625 let loaded = crate::engine::LoadedAnswers {
628 tenant: Some("acme".to_string()),
629 team: None,
630 env: Some("prod".to_string()),
631 ..Default::default()
632 };
633 let resolved = resolve_setup_scope("demo".to_string(), None, "local".to_string(), &loaded);
634 assert_eq!(resolved.2, "prod");
635 }
636
637 #[test]
638 fn resolve_setup_scope_local_default_with_no_loaded_env_stays_local() {
639 let loaded = crate::engine::LoadedAnswers::default();
640 let resolved = resolve_setup_scope("demo".to_string(), None, "local".to_string(), &loaded);
641 assert_eq!(resolved.2, "local");
642 }
643
644 #[test]
645 fn detects_cloud_deployment_targets() {
646 assert!(has_cloud_deployment_target(&[
647 DeploymentTargetRecord {
648 target: "aws".to_string(),
649 provider_pack: None,
650 default: None,
651 },
652 DeploymentTargetRecord {
653 target: "runtime".to_string(),
654 provider_pack: None,
655 default: None,
656 },
657 ]));
658 assert!(!has_cloud_deployment_target(&[
659 DeploymentTargetRecord {
660 target: "runtime".to_string(),
661 provider_pack: None,
662 default: None,
663 },
664 DeploymentTargetRecord {
665 target: "single-vm".to_string(),
666 provider_pack: None,
667 default: None,
668 },
669 ]));
670 }
671
672 #[test]
673 fn default_no_tunnel_answers_for_cloud_sets_off_mode() {
674 assert_eq!(default_no_tunnel_answers().mode.as_deref(), Some("off"));
675 }
676}