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::discovery;
14use crate::engine::LoadedAnswers;
15use crate::platform_setup::{
16 PlatformSetupAnswers, StaticRoutesPolicy, load_effective_static_routes_defaults,
17 prompt_static_routes_policy, prompt_static_routes_policy_with_answers,
18};
19use crate::qa::wizard;
20use crate::setup_to_formspec;
21
22pub use bundle::{
24 SetupOutputTarget, copy_dir_recursive, detect_domain_from_filename, resolve_bundle_dir,
25 resolve_bundle_source, resolve_pack_source, setup_output_target,
26};
27pub use env_vars::{
28 EnvVarPlaceholder, apply_resolved_env_vars, collect_env_var_placeholders,
29 confirm_env_var_placeholders,
30};
31pub use prompts::{SetupParams, prompt_setup_params};
32
33pub fn resolve_setup_scope(
40 tenant: String,
41 team: Option<String>,
42 env: String,
43 loaded: &LoadedAnswers,
44) -> (String, Option<String>, String) {
45 let tenant = if tenant == "demo" {
46 loaded.tenant.clone().unwrap_or(tenant)
47 } else {
48 tenant
49 };
50 let team = if team.is_none() {
51 loaded.team.clone()
52 } else {
53 team
54 };
55 let env = if env == "dev" {
56 loaded.env.clone().unwrap_or(env)
57 } else {
58 env
59 };
60 (tenant, team, env)
61}
62
63pub fn resolve_setup_scope_with_bundle(
66 tenant: String,
67 team: Option<String>,
68 env: String,
69 loaded: &LoadedAnswers,
70 bundle_dir: &std::path::Path,
71) -> (String, Option<String>, String) {
72 let (mut tenant, team, env) = resolve_setup_scope(tenant, team, env, loaded);
73
74 if tenant == "demo"
77 && loaded.tenant.is_none()
78 && let Some(detected) = detect_tenant_from_bundle(bundle_dir)
79 {
80 tenant = detected;
81 }
82
83 (tenant, team, env)
84}
85
86fn detect_tenant_from_bundle(bundle_dir: &std::path::Path) -> Option<String> {
90 let tenants_dir = bundle_dir.join("tenants");
91 let entries: Vec<String> = std::fs::read_dir(&tenants_dir)
92 .ok()?
93 .filter_map(|e| e.ok())
94 .filter(|e| e.path().is_dir())
95 .filter_map(|e| e.file_name().into_string().ok())
96 .collect();
97
98 match entries.len() {
99 0 => None,
100 1 => Some(entries[0].clone()),
101 _ => {
102 entries
104 .iter()
105 .find(|t| t.as_str() != "demo")
106 .cloned()
107 .or_else(|| entries.first().cloned())
108 }
109 }
110}
111
112pub fn run_interactive_wizard(
114 bundle_path: &Path,
115 tenant: &str,
116 team: Option<&str>,
117 env: &str,
118 advanced: bool,
119) -> Result<LoadedAnswers> {
120 use serde_json::Value;
121
122 let mut all_answers = serde_json::Map::new();
123 let existing_static_routes = load_effective_static_routes_defaults(bundle_path, tenant, team)?;
124 let static_routes = prompt_static_routes_policy(env, existing_static_routes.as_ref())?;
125 let deployer_candidates =
126 crate::deployment_targets::discover_deployer_pack_candidates(bundle_path)?;
127 let deployment_targets =
128 crate::deployment_targets::prompt_deployment_targets(&deployer_candidates)?;
129
130 let tunnel = if deployer_candidates.is_empty() {
132 Some(crate::platform_setup::prompt_tunnel_mode(None)?)
133 } else {
134 None
135 };
136
137 let discovered = discovery::discover(bundle_path)?;
138 let setup_targets = discovered.setup_targets();
139
140 if setup_targets.is_empty() {
141 println!("No setup packs found in bundle. Nothing to configure.");
142 return Ok(LoadedAnswers {
143 tenant: None,
144 team: None,
145 env: None,
146 platform_setup: PlatformSetupAnswers {
147 static_routes: Some(static_routes.to_answers()),
148 deployment_targets,
149 tunnel,
150 },
151 setup_answers: all_answers,
152 });
153 }
154
155 println!("Found {} pack(s) to configure:", setup_targets.len());
156 for provider in &setup_targets {
157 println!(" - {} ({})", provider.provider_id, provider.domain);
158 }
159 println!();
160
161 let provider_form_specs: Vec<wizard::ProviderFormSpec> = setup_targets
164 .iter()
165 .filter_map(|provider| {
166 setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id).map(
167 |form_spec| wizard::ProviderFormSpec {
168 provider_id: provider.provider_id.clone(),
169 form_spec,
170 },
171 )
172 })
173 .collect();
174
175 let shared_answers = if provider_form_specs.len() > 1 {
178 let shared_result = wizard::collect_shared_questions(&provider_form_specs);
179 if !shared_result.shared_questions.is_empty() {
180 let empty = Value::Object(serde_json::Map::new());
181 wizard::prompt_shared_questions(&shared_result, advanced, &empty)?
182 } else {
183 Value::Object(serde_json::Map::new())
184 }
185 } else {
186 Value::Object(serde_json::Map::new())
187 };
188
189 for provider in &setup_targets {
191 let provider_id = &provider.provider_id;
192 let form_spec = setup_to_formspec::pack_to_form_spec(&provider.pack_path, provider_id);
193
194 if let Some(spec) = form_spec {
195 if spec.questions.is_empty() {
196 println!("Provider {}: No configuration required.", provider_id);
197 all_answers.insert(provider_id.clone(), Value::Object(serde_json::Map::new()));
198 continue;
199 }
200
201 let answers = wizard::prompt_form_spec_answers_with_existing(
203 &spec,
204 provider_id,
205 advanced,
206 &shared_answers,
207 )?;
208 all_answers.insert(provider_id.clone(), answers);
209 } else {
210 println!(
211 "Provider {}: No setup questions found (may use flow-based setup).",
212 provider_id
213 );
214 all_answers.insert(provider_id.clone(), Value::Object(serde_json::Map::new()));
215 }
216
217 println!();
218 }
219
220 Ok(LoadedAnswers {
221 tenant: None,
222 team: None,
223 env: None,
224 platform_setup: PlatformSetupAnswers {
225 static_routes: Some(static_routes.to_answers()),
226 deployment_targets,
227 tunnel,
228 },
229 setup_answers: all_answers,
230 })
231}
232
233pub fn complete_loaded_answers_with_prompts(
240 bundle_path: &Path,
241 tenant: &str,
242 team: Option<&str>,
243 env: &str,
244 advanced: bool,
245 non_interactive: bool,
246 mut loaded: LoadedAnswers,
247) -> Result<LoadedAnswers> {
248 let existing_static_routes = load_effective_static_routes_defaults(bundle_path, tenant, team)?;
249 let static_routes_need_prompt = match loaded.platform_setup.static_routes.as_ref() {
250 None => true,
251 Some(answers) => StaticRoutesPolicy::normalize(Some(answers), env).is_err(),
252 };
253 if static_routes_need_prompt && !non_interactive {
254 let static_routes =
255 if let Some(current_answers) = loaded.platform_setup.static_routes.as_ref() {
256 prompt_static_routes_policy_with_answers(
257 env,
258 Some(current_answers),
259 existing_static_routes.as_ref(),
260 )?
261 } else {
262 prompt_static_routes_policy(env, existing_static_routes.as_ref())?
263 };
264 loaded.platform_setup.static_routes = Some(static_routes.to_answers());
265 }
266 let deployer_candidates =
267 crate::deployment_targets::discover_deployer_pack_candidates(bundle_path)?;
268 if loaded.platform_setup.deployment_targets.is_empty() && !non_interactive {
269 loaded.platform_setup.deployment_targets =
270 crate::deployment_targets::prompt_deployment_targets(&deployer_candidates)?;
271 }
272 if deployer_candidates.is_empty() && loaded.platform_setup.tunnel.is_none() && !non_interactive
273 {
274 loaded.platform_setup.tunnel = Some(crate::platform_setup::prompt_tunnel_mode(None)?);
275 }
276
277 let env_placeholders = collect_env_var_placeholders(&loaded);
279 if !env_placeholders.is_empty() {
280 let resolved_env_vars = confirm_env_var_placeholders(&env_placeholders)?;
281
282 if !resolved_env_vars.is_empty() {
284 apply_resolved_env_vars(&mut loaded, &resolved_env_vars);
285 }
286 }
287
288 let discovered = discovery::discover(bundle_path)?;
289 let setup_targets = discovered.setup_targets();
290
291 let all_provider_form_specs: Vec<wizard::ProviderFormSpec> = setup_targets
294 .iter()
295 .filter_map(|provider| {
296 setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id).map(
297 |form_spec| wizard::ProviderFormSpec {
298 provider_id: provider.provider_id.clone(),
299 form_spec,
300 },
301 )
302 })
303 .collect();
304
305 let mut existing_shared_values = serde_json::Map::new();
308 let shared_result = if all_provider_form_specs.len() > 1 {
309 let result = wizard::collect_shared_questions(&all_provider_form_specs);
310 for question in &result.shared_questions {
312 for (_provider_id, provider_answers) in &loaded.setup_answers {
313 if let Some(value) = provider_answers.get(&question.id) {
314 if !(value.is_null() || value.is_string() && value.as_str() == Some("")) {
316 existing_shared_values.insert(question.id.clone(), value.clone());
317 break;
318 }
319 }
320 }
321 }
322 Some(result)
323 } else {
324 None
325 };
326
327 let shared_answers = if let Some(ref result) = shared_result {
330 if !result.shared_questions.is_empty() {
331 let existing = serde_json::Value::Object(existing_shared_values);
332 wizard::prompt_shared_questions(result, advanced, &existing)?
333 } else {
334 serde_json::Value::Object(serde_json::Map::new())
335 }
336 } else {
337 serde_json::Value::Object(serde_json::Map::new())
338 };
339
340 for provider in &setup_targets {
342 let provider_id = &provider.provider_id;
343 let existing = loaded
344 .setup_answers
345 .get(provider_id)
346 .cloned()
347 .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
348
349 let mut merged = existing.as_object().cloned().unwrap_or_default();
352 if let Some(shared_obj) = shared_answers.as_object() {
353 for (key, value) in shared_obj {
354 let is_non_empty =
356 !(value.is_null() || value.is_string() && value.as_str() == Some(""));
357 if is_non_empty {
358 merged.insert(key.clone(), value.clone());
359 }
360 }
361 }
362 let merged_value = serde_json::Value::Object(merged);
363
364 let form_spec = setup_to_formspec::pack_to_form_spec(&provider.pack_path, provider_id);
365 let completed = if let Some(spec) = form_spec {
366 if spec.questions.is_empty() {
367 existing
368 } else {
369 wizard::prompt_form_spec_answers_with_existing(
370 &spec,
371 provider_id,
372 advanced,
373 &merged_value,
374 )?
375 }
376 } else {
377 existing
378 };
379 loaded.setup_answers.insert(provider_id.clone(), completed);
380 }
381
382 Ok(loaded)
383}
384
385pub fn ensure_deployment_targets_present(bundle_path: &Path, loaded: &LoadedAnswers) -> Result<()> {
387 if !loaded.platform_setup.deployment_targets.is_empty() {
388 return Ok(());
389 }
390 let candidates = crate::deployment_targets::discover_deployer_pack_candidates(bundle_path)?;
391 if candidates.is_empty() {
392 return Ok(());
393 }
394 anyhow::bail!(
395 "bundle contains deployer packs ({}) but answers did not define platform_setup.deployment_targets",
396 candidates
397 .iter()
398 .map(|value| value.display().to_string())
399 .collect::<Vec<_>>()
400 .join(", ")
401 )
402}
403
404pub fn ensure_required_setup_answers_present(
406 bundle_path: &Path,
407 loaded: &LoadedAnswers,
408) -> Result<()> {
409 let discovered = discovery::discover(bundle_path)?;
410 for provider in discovered.setup_targets() {
411 let Some(spec) =
412 setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id)
413 else {
414 continue;
415 };
416 if spec.questions.is_empty() {
417 continue;
418 }
419
420 let answers = loaded
421 .setup_answers
422 .get(&provider.provider_id)
423 .cloned()
424 .unwrap_or_else(|| Value::Object(Default::default()));
425 let answer_map = answers.as_object().ok_or_else(|| {
426 anyhow::anyhow!("answers for {} must be an object", provider.provider_id)
427 })?;
428 let visibility = resolve_visibility(&spec, &answers, VisibilityMode::Visible);
429
430 for question in spec.questions.iter().filter(|question| question.required) {
431 if !visibility.get(&question.id).copied().unwrap_or(true) {
432 continue;
433 }
434 let Some(value) = answer_map.get(&question.id) else {
435 anyhow::bail!(
436 "missing required setup answer for {}.{}",
437 provider.provider_id,
438 question.id
439 );
440 };
441 if !wizard::answer_satisfies_question(question, value) {
442 anyhow::bail!(
443 "missing required setup answer for {}.{}",
444 provider.provider_id,
445 question.id
446 );
447 }
448 }
449 }
450 Ok(())
451}
452
453#[cfg(test)]
454mod tests {
455 use super::{resolve_setup_scope, resolve_setup_scope_with_bundle};
456 use crate::engine::LoadedAnswers;
457
458 #[test]
459 fn resolve_setup_scope_prefers_answers_when_cli_is_default() {
460 let loaded = LoadedAnswers {
461 tenant: Some("acme".to_string()),
462 team: Some("core".to_string()),
463 env: Some("prod".to_string()),
464 ..Default::default()
465 };
466 let resolved = resolve_setup_scope("demo".to_string(), None, "dev".to_string(), &loaded);
467 assert_eq!(resolved.0, "acme");
468 assert_eq!(resolved.1.as_deref(), Some("core"));
469 assert_eq!(resolved.2, "prod");
470 }
471
472 #[test]
473 fn resolve_setup_scope_keeps_explicit_cli_values() {
474 let loaded = LoadedAnswers {
475 tenant: Some("acme".to_string()),
476 team: Some("core".to_string()),
477 env: Some("prod".to_string()),
478 ..Default::default()
479 };
480 let resolved = resolve_setup_scope(
481 "sandbox".to_string(),
482 Some("ops".to_string()),
483 "staging".to_string(),
484 &loaded,
485 );
486 assert_eq!(resolved.0, "sandbox");
487 assert_eq!(resolved.1.as_deref(), Some("ops"));
488 assert_eq!(resolved.2, "staging");
489 }
490
491 #[test]
492 fn bundle_detection_does_not_override_answers_tenant_demo() {
493 let temp = tempfile::tempdir().expect("tempdir");
494 std::fs::create_dir_all(temp.path().join("tenants").join("default")).expect("tenant dir");
495
496 let loaded = LoadedAnswers {
497 tenant: Some("demo".to_string()),
498 ..Default::default()
499 };
500
501 let (tenant, team, env) = resolve_setup_scope_with_bundle(
502 "demo".to_string(),
503 None,
504 "dev".to_string(),
505 &loaded,
506 temp.path(),
507 );
508
509 assert_eq!(tenant, "demo");
510 assert_eq!(team, None);
511 assert_eq!(env, "dev");
512 }
513}