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(
235 bundle_path: &Path,
236 tenant: &str,
237 team: Option<&str>,
238 env: &str,
239 advanced: bool,
240 mut loaded: LoadedAnswers,
241) -> Result<LoadedAnswers> {
242 let existing_static_routes = load_effective_static_routes_defaults(bundle_path, tenant, team)?;
243 let static_routes_need_prompt = match loaded.platform_setup.static_routes.as_ref() {
244 None => true,
245 Some(answers) => StaticRoutesPolicy::normalize(Some(answers), env).is_err(),
246 };
247 if static_routes_need_prompt {
248 let static_routes =
249 if let Some(current_answers) = loaded.platform_setup.static_routes.as_ref() {
250 prompt_static_routes_policy_with_answers(
251 env,
252 Some(current_answers),
253 existing_static_routes.as_ref(),
254 )?
255 } else {
256 prompt_static_routes_policy(env, existing_static_routes.as_ref())?
257 };
258 loaded.platform_setup.static_routes = Some(static_routes.to_answers());
259 }
260 let deployer_candidates =
261 crate::deployment_targets::discover_deployer_pack_candidates(bundle_path)?;
262 if loaded.platform_setup.deployment_targets.is_empty() {
263 loaded.platform_setup.deployment_targets =
264 crate::deployment_targets::prompt_deployment_targets(&deployer_candidates)?;
265 }
266 if deployer_candidates.is_empty() && loaded.platform_setup.tunnel.is_none() {
268 loaded.platform_setup.tunnel = Some(crate::platform_setup::prompt_tunnel_mode(None)?);
269 }
270
271 let env_placeholders = collect_env_var_placeholders(&loaded);
273 if !env_placeholders.is_empty() {
274 let resolved_env_vars = confirm_env_var_placeholders(&env_placeholders)?;
275
276 if !resolved_env_vars.is_empty() {
278 apply_resolved_env_vars(&mut loaded, &resolved_env_vars);
279 }
280 }
281
282 let discovered = discovery::discover(bundle_path)?;
283 let setup_targets = discovered.setup_targets();
284
285 let all_provider_form_specs: Vec<wizard::ProviderFormSpec> = setup_targets
288 .iter()
289 .filter_map(|provider| {
290 setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id).map(
291 |form_spec| wizard::ProviderFormSpec {
292 provider_id: provider.provider_id.clone(),
293 form_spec,
294 },
295 )
296 })
297 .collect();
298
299 let mut existing_shared_values = serde_json::Map::new();
302 let shared_result = if all_provider_form_specs.len() > 1 {
303 let result = wizard::collect_shared_questions(&all_provider_form_specs);
304 for question in &result.shared_questions {
306 for (_provider_id, provider_answers) in &loaded.setup_answers {
307 if let Some(value) = provider_answers.get(&question.id) {
308 if !(value.is_null() || value.is_string() && value.as_str() == Some("")) {
310 existing_shared_values.insert(question.id.clone(), value.clone());
311 break;
312 }
313 }
314 }
315 }
316 Some(result)
317 } else {
318 None
319 };
320
321 let shared_answers = if let Some(ref result) = shared_result {
324 if !result.shared_questions.is_empty() {
325 let existing = serde_json::Value::Object(existing_shared_values);
326 wizard::prompt_shared_questions(result, advanced, &existing)?
327 } else {
328 serde_json::Value::Object(serde_json::Map::new())
329 }
330 } else {
331 serde_json::Value::Object(serde_json::Map::new())
332 };
333
334 for provider in &setup_targets {
336 let provider_id = &provider.provider_id;
337 let existing = loaded
338 .setup_answers
339 .get(provider_id)
340 .cloned()
341 .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
342
343 let mut merged = existing.as_object().cloned().unwrap_or_default();
346 if let Some(shared_obj) = shared_answers.as_object() {
347 for (key, value) in shared_obj {
348 let is_non_empty =
350 !(value.is_null() || value.is_string() && value.as_str() == Some(""));
351 if is_non_empty {
352 merged.insert(key.clone(), value.clone());
353 }
354 }
355 }
356 let merged_value = serde_json::Value::Object(merged);
357
358 let form_spec = setup_to_formspec::pack_to_form_spec(&provider.pack_path, provider_id);
359 let completed = if let Some(spec) = form_spec {
360 if spec.questions.is_empty() {
361 existing
362 } else {
363 wizard::prompt_form_spec_answers_with_existing(
364 &spec,
365 provider_id,
366 advanced,
367 &merged_value,
368 )?
369 }
370 } else {
371 existing
372 };
373 loaded.setup_answers.insert(provider_id.clone(), completed);
374 }
375
376 Ok(loaded)
377}
378
379pub fn ensure_deployment_targets_present(bundle_path: &Path, loaded: &LoadedAnswers) -> Result<()> {
381 if !loaded.platform_setup.deployment_targets.is_empty() {
382 return Ok(());
383 }
384 let candidates = crate::deployment_targets::discover_deployer_pack_candidates(bundle_path)?;
385 if candidates.is_empty() {
386 return Ok(());
387 }
388 anyhow::bail!(
389 "bundle contains deployer packs ({}) but answers did not define platform_setup.deployment_targets",
390 candidates
391 .iter()
392 .map(|value| value.display().to_string())
393 .collect::<Vec<_>>()
394 .join(", ")
395 )
396}
397
398pub fn ensure_required_setup_answers_present(
400 bundle_path: &Path,
401 loaded: &LoadedAnswers,
402) -> Result<()> {
403 let discovered = discovery::discover(bundle_path)?;
404 for provider in discovered.setup_targets() {
405 let Some(spec) =
406 setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id)
407 else {
408 continue;
409 };
410 if spec.questions.is_empty() {
411 continue;
412 }
413
414 let answers = loaded
415 .setup_answers
416 .get(&provider.provider_id)
417 .cloned()
418 .unwrap_or_else(|| Value::Object(Default::default()));
419 let answer_map = answers.as_object().ok_or_else(|| {
420 anyhow::anyhow!("answers for {} must be an object", provider.provider_id)
421 })?;
422 let visibility = resolve_visibility(&spec, &answers, VisibilityMode::Visible);
423
424 for question in spec.questions.iter().filter(|question| question.required) {
425 if !visibility.get(&question.id).copied().unwrap_or(true) {
426 continue;
427 }
428 let Some(value) = answer_map.get(&question.id) else {
429 anyhow::bail!(
430 "missing required setup answer for {}.{}",
431 provider.provider_id,
432 question.id
433 );
434 };
435 if !wizard::answer_satisfies_question(question, value) {
436 anyhow::bail!(
437 "missing required setup answer for {}.{}",
438 provider.provider_id,
439 question.id
440 );
441 }
442 }
443 }
444 Ok(())
445}
446
447#[cfg(test)]
448mod tests {
449 use super::{resolve_setup_scope, resolve_setup_scope_with_bundle};
450 use crate::engine::LoadedAnswers;
451
452 #[test]
453 fn resolve_setup_scope_prefers_answers_when_cli_is_default() {
454 let loaded = LoadedAnswers {
455 tenant: Some("acme".to_string()),
456 team: Some("core".to_string()),
457 env: Some("prod".to_string()),
458 ..Default::default()
459 };
460 let resolved = resolve_setup_scope("demo".to_string(), None, "dev".to_string(), &loaded);
461 assert_eq!(resolved.0, "acme");
462 assert_eq!(resolved.1.as_deref(), Some("core"));
463 assert_eq!(resolved.2, "prod");
464 }
465
466 #[test]
467 fn resolve_setup_scope_keeps_explicit_cli_values() {
468 let loaded = LoadedAnswers {
469 tenant: Some("acme".to_string()),
470 team: Some("core".to_string()),
471 env: Some("prod".to_string()),
472 ..Default::default()
473 };
474 let resolved = resolve_setup_scope(
475 "sandbox".to_string(),
476 Some("ops".to_string()),
477 "staging".to_string(),
478 &loaded,
479 );
480 assert_eq!(resolved.0, "sandbox");
481 assert_eq!(resolved.1.as_deref(), Some("ops"));
482 assert_eq!(resolved.2, "staging");
483 }
484
485 #[test]
486 fn bundle_detection_does_not_override_answers_tenant_demo() {
487 let temp = tempfile::tempdir().expect("tempdir");
488 std::fs::create_dir_all(temp.path().join("tenants").join("default")).expect("tenant dir");
489
490 let loaded = LoadedAnswers {
491 tenant: Some("demo".to_string()),
492 ..Default::default()
493 };
494
495 let (tenant, team, env) = resolve_setup_scope_with_bundle(
496 "demo".to_string(),
497 None,
498 "dev".to_string(),
499 &loaded,
500 temp.path(),
501 );
502
503 assert_eq!(tenant, "demo");
504 assert_eq!(team, None);
505 assert_eq!(env, "dev");
506 }
507}