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(
38 loaded: &mut LoadedAnswers,
39 local_base_url: &str,
40) -> Result<Option<SetupTunnel>> {
41 let mode = loaded
42 .platform_setup
43 .tunnel
44 .as_ref()
45 .and_then(|tunnel| tunnel.mode.as_deref())
46 .unwrap_or("off")
47 .to_string();
48 if !should_start_setup_tunnel(&mode, &loaded.setup_answers) {
49 return Ok(None);
50 }
51
52 let tunnel = crate::setup_tunnel::start_setup_tunnel(&mode, local_base_url)?;
53 inject_setup_public_base_url(&mut loaded.setup_answers, &tunnel.public_base_url);
54 Ok(Some(tunnel))
55}
56
57pub fn resolve_setup_scope(
64 tenant: String,
65 team: Option<String>,
66 env: String,
67 loaded: &LoadedAnswers,
68) -> (String, Option<String>, String) {
69 let tenant = if tenant == "demo" {
70 loaded.tenant.clone().unwrap_or(tenant)
71 } else {
72 tenant
73 };
74 let team = if team.is_none() {
75 loaded.team.clone()
76 } else {
77 team
78 };
79 let env = if env == crate::LEGACY_ENV_ID || env == crate::DEFAULT_ENV_ID {
83 loaded.env.clone().unwrap_or(env)
84 } else {
85 env
86 };
87 (tenant, team, env)
88}
89
90pub fn resolve_setup_scope_with_bundle(
93 tenant: String,
94 team: Option<String>,
95 env: String,
96 loaded: &LoadedAnswers,
97 bundle_dir: &std::path::Path,
98) -> (String, Option<String>, String) {
99 let (mut tenant, team, env) = resolve_setup_scope(tenant, team, env, loaded);
100
101 if tenant == "demo"
104 && loaded.tenant.is_none()
105 && let Some(detected) = detect_tenant_from_bundle(bundle_dir)
106 {
107 tenant = detected;
108 }
109
110 (tenant, team, env)
111}
112
113fn detect_tenant_from_bundle(bundle_dir: &std::path::Path) -> Option<String> {
117 let tenants_dir = bundle_dir.join("tenants");
118 let entries: Vec<String> = std::fs::read_dir(&tenants_dir)
119 .ok()?
120 .filter_map(|e| e.ok())
121 .filter(|e| e.path().is_dir())
122 .filter_map(|e| e.file_name().into_string().ok())
123 .collect();
124
125 match entries.len() {
126 0 => None,
127 1 => Some(entries[0].clone()),
128 _ => {
129 entries
131 .iter()
132 .find(|t| t.as_str() != "demo")
133 .cloned()
134 .or_else(|| entries.first().cloned())
135 }
136 }
137}
138
139fn has_cloud_deployment_target(targets: &[DeploymentTargetRecord]) -> bool {
140 targets
141 .iter()
142 .any(|record| matches!(record.target.as_str(), "aws" | "gcp" | "azure"))
143}
144
145fn default_no_tunnel_answers() -> TunnelAnswers {
146 TunnelAnswers {
147 mode: Some("off".to_string()),
148 }
149}
150
151pub fn run_interactive_wizard(
153 bundle_path: &Path,
154 tenant: &str,
155 team: Option<&str>,
156 env: &str,
157 advanced: bool,
158) -> Result<LoadedAnswers> {
159 use serde_json::Value;
160
161 let mut all_answers = serde_json::Map::new();
162 let existing_static_routes = load_effective_static_routes_defaults(bundle_path, tenant, team)?;
163 let static_routes = prompt_static_routes_policy(env, existing_static_routes.as_ref())?;
164 let deployer_candidates =
165 crate::deployment_targets::discover_deployer_pack_candidates(bundle_path)?;
166 let deployment_targets =
167 crate::deployment_targets::prompt_deployment_targets(&deployer_candidates)?;
168
169 let tunnel = if has_cloud_deployment_target(&deployment_targets) {
171 Some(default_no_tunnel_answers())
172 } else if deployer_candidates.is_empty() {
173 Some(crate::platform_setup::prompt_tunnel_mode(None)?)
174 } else {
175 None
176 };
177
178 let discovered = discovery::discover(bundle_path)?;
179 let setup_targets = discovered.setup_targets();
180
181 if setup_targets.is_empty() {
182 println!("No setup packs found in bundle. Nothing to configure.");
183 return Ok(LoadedAnswers {
184 tenant: None,
185 team: None,
186 env: None,
187 platform_setup: PlatformSetupAnswers {
188 static_routes: Some(static_routes.to_answers()),
189 deployment_targets,
190 tunnel,
191 telemetry: None,
192 },
193 setup_answers: all_answers,
194 });
195 }
196
197 println!("Found {} pack(s) to configure:", setup_targets.len());
198 for provider in &setup_targets {
199 println!(" - {} ({})", provider.provider_id, provider.domain);
200 }
201 println!();
202
203 let provider_form_specs: Vec<wizard::ProviderFormSpec> = setup_targets
206 .iter()
207 .filter_map(|provider| {
208 setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id).map(
209 |form_spec| wizard::ProviderFormSpec {
210 provider_id: provider.provider_id.clone(),
211 form_spec,
212 },
213 )
214 })
215 .collect();
216
217 let shared_answers = if provider_form_specs.len() > 1 {
220 let shared_result = wizard::collect_shared_questions(&provider_form_specs);
221 if !shared_result.shared_questions.is_empty() {
222 let empty = Value::Object(serde_json::Map::new());
223 wizard::prompt_shared_questions(&shared_result, advanced, &empty)?
224 } else {
225 Value::Object(serde_json::Map::new())
226 }
227 } else {
228 Value::Object(serde_json::Map::new())
229 };
230
231 for provider in &setup_targets {
233 let provider_id = &provider.provider_id;
234 let form_spec = setup_to_formspec::pack_to_form_spec(&provider.pack_path, provider_id);
235
236 if let Some(spec) = form_spec {
237 if spec.questions.is_empty() {
238 println!("Provider {}: No configuration required.", provider_id);
239 all_answers.insert(provider_id.clone(), Value::Object(serde_json::Map::new()));
240 continue;
241 }
242
243 let answers = wizard::prompt_form_spec_answers_with_existing(
246 &spec,
247 provider_id,
248 advanced,
249 &shared_answers,
250 None,
251 )?;
252 all_answers.insert(provider_id.clone(), answers);
253 } else {
254 println!(
255 "Provider {}: No setup questions found (may use flow-based setup).",
256 provider_id
257 );
258 all_answers.insert(provider_id.clone(), Value::Object(serde_json::Map::new()));
259 }
260
261 println!();
262 }
263
264 Ok(LoadedAnswers {
265 tenant: None,
266 team: None,
267 env: None,
268 platform_setup: PlatformSetupAnswers {
269 static_routes: Some(static_routes.to_answers()),
270 deployment_targets,
271 tunnel,
272 telemetry: None,
273 },
274 setup_answers: all_answers,
275 })
276}
277
278pub fn complete_loaded_answers_with_prompts(
285 bundle_path: &Path,
286 tenant: &str,
287 team: Option<&str>,
288 env: &str,
289 advanced: bool,
290 non_interactive: bool,
291 mut loaded: LoadedAnswers,
292) -> Result<LoadedAnswers> {
293 let existing_static_routes = load_effective_static_routes_defaults(bundle_path, tenant, team)?;
294 let static_routes_need_prompt = match loaded.platform_setup.static_routes.as_ref() {
295 None => true,
296 Some(answers) => StaticRoutesPolicy::normalize(Some(answers), env).is_err(),
297 };
298 if static_routes_need_prompt && !non_interactive {
299 let static_routes =
300 if let Some(current_answers) = loaded.platform_setup.static_routes.as_ref() {
301 prompt_static_routes_policy_with_answers(
302 env,
303 Some(current_answers),
304 existing_static_routes.as_ref(),
305 )?
306 } else {
307 prompt_static_routes_policy(env, existing_static_routes.as_ref())?
308 };
309 loaded.platform_setup.static_routes = Some(static_routes.to_answers());
310 }
311 let deployer_candidates =
312 crate::deployment_targets::discover_deployer_pack_candidates(bundle_path)?;
313 if loaded.platform_setup.deployment_targets.is_empty() && !non_interactive {
314 loaded.platform_setup.deployment_targets =
315 crate::deployment_targets::prompt_deployment_targets(&deployer_candidates)?;
316 }
317 if has_cloud_deployment_target(&loaded.platform_setup.deployment_targets) {
318 loaded.platform_setup.tunnel = Some(default_no_tunnel_answers());
319 } else if deployer_candidates.is_empty()
320 && loaded.platform_setup.tunnel.is_none()
321 && !non_interactive
322 {
323 loaded.platform_setup.tunnel = Some(crate::platform_setup::prompt_tunnel_mode(None)?);
324 }
325
326 if !non_interactive {
332 let env_placeholders = collect_env_var_placeholders(&loaded);
333 if !env_placeholders.is_empty() {
334 let resolved_env_vars = confirm_env_var_placeholders(&env_placeholders)?;
335
336 if !resolved_env_vars.is_empty() {
338 apply_resolved_env_vars(&mut loaded, &resolved_env_vars);
339 }
340 }
341 }
342
343 let discovered = discovery::discover(bundle_path)?;
344 let setup_targets = discovered.setup_targets();
345
346 let all_provider_form_specs: Vec<wizard::ProviderFormSpec> = setup_targets
349 .iter()
350 .filter(|provider| {
351 loaded
352 .setup_answers
353 .get(&provider.provider_id)
354 .map(crate::provider_state::provider_enabled)
355 .unwrap_or(true)
356 })
357 .filter_map(|provider| {
358 setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id).map(
359 |form_spec| wizard::ProviderFormSpec {
360 provider_id: provider.provider_id.clone(),
361 form_spec,
362 },
363 )
364 })
365 .collect();
366
367 let mut existing_shared_values = serde_json::Map::new();
370 let shared_result = if all_provider_form_specs.len() > 1 {
371 let result = wizard::collect_shared_questions(&all_provider_form_specs);
372 for question in &result.shared_questions {
374 for (_provider_id, provider_answers) in &loaded.setup_answers {
375 if let Some(value) = provider_answers.get(&question.id) {
376 if !(value.is_null() || value.is_string() && value.as_str() == Some("")) {
378 existing_shared_values.insert(question.id.clone(), value.clone());
379 break;
380 }
381 }
382 }
383 }
384 Some(result)
385 } else {
386 None
387 };
388
389 let shared_answers = if !non_interactive {
396 if let Some(ref result) = shared_result {
397 if !result.shared_questions.is_empty() {
398 let existing = serde_json::Value::Object(existing_shared_values);
399 wizard::prompt_shared_questions(result, advanced, &existing)?
400 } else {
401 serde_json::Value::Object(serde_json::Map::new())
402 }
403 } else {
404 serde_json::Value::Object(serde_json::Map::new())
405 }
406 } else {
407 serde_json::Value::Object(serde_json::Map::new())
408 };
409
410 for provider in &setup_targets {
412 let provider_id = &provider.provider_id;
413 let existing = loaded
414 .setup_answers
415 .get(provider_id)
416 .cloned()
417 .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
418 if !crate::provider_state::provider_enabled(&existing) {
419 loaded.setup_answers.insert(provider_id.clone(), existing);
420 continue;
421 }
422
423 if non_interactive {
427 loaded.setup_answers.insert(provider_id.clone(), existing);
428 continue;
429 }
430
431 let mut merged = existing.as_object().cloned().unwrap_or_default();
434 if let Some(shared_obj) = shared_answers.as_object() {
435 for (key, value) in shared_obj {
436 let is_non_empty =
438 !(value.is_null() || value.is_string() && value.as_str() == Some(""));
439 if is_non_empty {
440 merged.insert(key.clone(), value.clone());
441 }
442 }
443 }
444 let merged_value = serde_json::Value::Object(merged);
445
446 let form_spec = setup_to_formspec::pack_to_form_spec(&provider.pack_path, provider_id);
447 let completed = if let Some(spec) = form_spec {
448 if spec.questions.is_empty() {
449 existing
450 } else {
451 wizard::prompt_form_spec_answers_with_existing(
452 &spec,
453 provider_id,
454 advanced,
455 &merged_value,
456 None,
457 )?
458 }
459 } else {
460 existing
461 };
462 loaded.setup_answers.insert(provider_id.clone(), completed);
463 }
464
465 Ok(loaded)
466}
467
468pub fn ensure_deployment_targets_present(bundle_path: &Path, loaded: &LoadedAnswers) -> Result<()> {
470 if !loaded.platform_setup.deployment_targets.is_empty() {
471 return Ok(());
472 }
473 let candidates = crate::deployment_targets::discover_deployer_pack_candidates(bundle_path)?;
474 if candidates.is_empty() {
475 return Ok(());
476 }
477 anyhow::bail!(
478 "bundle contains deployer packs ({}) but answers did not define platform_setup.deployment_targets",
479 candidates
480 .iter()
481 .map(|value| value.display().to_string())
482 .collect::<Vec<_>>()
483 .join(", ")
484 )
485}
486
487pub fn ensure_required_setup_answers_present(
489 bundle_path: &Path,
490 loaded: &LoadedAnswers,
491) -> Result<()> {
492 let discovered = discovery::discover(bundle_path)?;
493 for provider in discovered.setup_targets() {
494 let Some(spec) =
495 setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id)
496 else {
497 continue;
498 };
499 if spec.questions.is_empty() {
500 continue;
501 }
502
503 let answers = loaded
504 .setup_answers
505 .get(&provider.provider_id)
506 .cloned()
507 .unwrap_or_else(|| Value::Object(Default::default()));
508 if !crate::provider_state::provider_enabled(&answers) {
509 continue;
510 }
511 let answer_map = answers.as_object().ok_or_else(|| {
512 anyhow::anyhow!("answers for {} must be an object", provider.provider_id)
513 })?;
514 let visibility = resolve_visibility(&spec, &answers, VisibilityMode::Visible);
515
516 for question in spec.questions.iter().filter(|question| question.required) {
517 if !visibility.get(&question.id).copied().unwrap_or(true) {
518 continue;
519 }
520 let Some(value) = answer_map.get(&question.id) else {
521 anyhow::bail!(
522 "missing required setup answer for {}.{}",
523 provider.provider_id,
524 question.id
525 );
526 };
527 if !wizard::answer_satisfies_question(question, value) {
528 anyhow::bail!(
529 "missing required setup answer for {}.{}",
530 provider.provider_id,
531 question.id
532 );
533 }
534 }
535 }
536 Ok(())
537}
538
539#[cfg(test)]
540mod tests {
541 use super::{
542 default_no_tunnel_answers, has_cloud_deployment_target, resolve_setup_scope,
543 resolve_setup_scope_with_bundle,
544 };
545 use crate::deployment_targets::DeploymentTargetRecord;
546 use crate::engine::LoadedAnswers;
547
548 #[test]
549 fn resolve_setup_scope_prefers_answers_when_cli_is_default() {
550 let loaded = LoadedAnswers {
551 tenant: Some("acme".to_string()),
552 team: Some("core".to_string()),
553 env: Some("prod".to_string()),
554 ..Default::default()
555 };
556 let resolved = resolve_setup_scope("demo".to_string(), None, "dev".to_string(), &loaded);
557 assert_eq!(resolved.0, "acme");
558 assert_eq!(resolved.1.as_deref(), Some("core"));
559 assert_eq!(resolved.2, "prod");
560 }
561
562 #[test]
563 fn resolve_setup_scope_keeps_explicit_cli_values() {
564 let loaded = LoadedAnswers {
565 tenant: Some("acme".to_string()),
566 team: Some("core".to_string()),
567 env: Some("prod".to_string()),
568 ..Default::default()
569 };
570 let resolved = resolve_setup_scope(
571 "sandbox".to_string(),
572 Some("ops".to_string()),
573 "staging".to_string(),
574 &loaded,
575 );
576 assert_eq!(resolved.0, "sandbox");
577 assert_eq!(resolved.1.as_deref(), Some("ops"));
578 assert_eq!(resolved.2, "staging");
579 }
580
581 #[test]
582 fn bundle_detection_does_not_override_answers_tenant_demo() {
583 let temp = tempfile::tempdir().expect("tempdir");
584 std::fs::create_dir_all(temp.path().join("tenants").join("default")).expect("tenant dir");
585
586 let loaded = LoadedAnswers {
587 tenant: Some("demo".to_string()),
588 ..Default::default()
589 };
590
591 let (tenant, team, env) = resolve_setup_scope_with_bundle(
592 "demo".to_string(),
593 None,
594 "dev".to_string(),
595 &loaded,
596 temp.path(),
597 );
598
599 assert_eq!(tenant, "demo");
600 assert_eq!(team, None);
601 assert_eq!(env, "dev");
602 }
603
604 #[test]
605 fn resolve_setup_scope_treats_local_default_like_dev_default() {
606 let loaded = crate::engine::LoadedAnswers {
609 tenant: Some("acme".to_string()),
610 team: None,
611 env: Some("prod".to_string()),
612 ..Default::default()
613 };
614 let resolved = resolve_setup_scope("demo".to_string(), None, "local".to_string(), &loaded);
615 assert_eq!(resolved.2, "prod");
616 }
617
618 #[test]
619 fn resolve_setup_scope_local_default_with_no_loaded_env_stays_local() {
620 let loaded = crate::engine::LoadedAnswers::default();
621 let resolved = resolve_setup_scope("demo".to_string(), None, "local".to_string(), &loaded);
622 assert_eq!(resolved.2, "local");
623 }
624
625 #[test]
626 fn detects_cloud_deployment_targets() {
627 assert!(has_cloud_deployment_target(&[
628 DeploymentTargetRecord {
629 target: "aws".to_string(),
630 provider_pack: None,
631 default: None,
632 },
633 DeploymentTargetRecord {
634 target: "runtime".to_string(),
635 provider_pack: None,
636 default: None,
637 },
638 ]));
639 assert!(!has_cloud_deployment_target(&[
640 DeploymentTargetRecord {
641 target: "runtime".to_string(),
642 provider_pack: None,
643 default: None,
644 },
645 DeploymentTargetRecord {
646 target: "single-vm".to_string(),
647 provider_pack: None,
648 default: None,
649 },
650 ]));
651 }
652
653 #[test]
654 fn default_no_tunnel_answers_for_cloud_sets_off_mode() {
655 assert_eq!(default_no_tunnel_answers().mode.as_deref(), Some("off"));
656 }
657}