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 clear_bundle_cache(bundle_dir: &Path, env: &str) {
45 if let Some(store) = crate::secrets::env_store_dev_secrets_path(env) {
49 remove_cached_path(&store);
50 }
51
52 remove_cached_path(&bundle_dir.join(".greentic").join("dev"));
54 remove_cached_path(&bundle_dir.join(".greentic").join("state").join("dev"));
55
56 remove_cached_path(&bundle_dir.join(".greentic").join("setup-state"));
59 remove_cached_path(&bundle_dir.join("state").join("setup"));
60 remove_cached_path(
61 &bundle_dir
62 .join("state")
63 .join("config")
64 .join("setup-actions"),
65 );
66}
67
68fn remove_cached_path(path: &Path) {
69 let result = if path.is_dir() {
70 std::fs::remove_dir_all(path)
71 } else if path.exists() {
72 std::fs::remove_file(path)
73 } else {
74 Ok(())
75 };
76 if let Err(err) = result {
77 tracing::warn!("--new-cache: failed to remove {}: {err:#}", path.display());
78 }
79}
80
81pub fn maybe_start_cli_setup_tunnel(
89 bundle_root: &Path,
90 loaded: &mut LoadedAnswers,
91 local_base_url: &str,
92) -> Result<Option<SetupTunnel>> {
93 let mode = loaded
94 .platform_setup
95 .tunnel
96 .as_ref()
97 .and_then(|tunnel| tunnel.mode.as_deref())
98 .unwrap_or("off")
99 .to_string();
100 if !should_start_setup_tunnel(&mode, &loaded.setup_answers) {
101 return Ok(None);
102 }
103
104 let tunnel = crate::setup_tunnel::start_setup_tunnel(&mode, local_base_url, None)?;
107 inject_setup_public_base_url(&mut loaded.setup_answers, &tunnel.public_base_url);
108 persist_tunnel_handoff(bundle_root, &tunnel);
109 Ok(Some(tunnel))
110}
111
112fn persist_tunnel_handoff(bundle_root: &Path, tunnel: &SetupTunnel) {
116 let Some(local_port) = crate::shared_tunnel::local_port_from_base_url(&tunnel.local_base_url)
117 else {
118 return;
119 };
120 let handoff = crate::platform_setup::TunnelHandoff {
121 service: tunnel.mode.clone(),
122 local_port,
123 public_base_url: tunnel.public_base_url.clone(),
124 };
125 if let Err(err) = crate::platform_setup::persist_tunnel_handoff_artifact(bundle_root, &handoff)
126 {
127 tracing::warn!("failed to persist setup tunnel handoff: {err:#}");
128 }
129}
130
131pub fn resolve_setup_scope(
138 tenant: String,
139 team: Option<String>,
140 env: String,
141 loaded: &LoadedAnswers,
142) -> (String, Option<String>, String) {
143 let tenant = if tenant == "demo" {
144 loaded.tenant.clone().unwrap_or(tenant)
145 } else {
146 tenant
147 };
148 let team = if team.is_none() {
149 loaded.team.clone()
150 } else {
151 team
152 };
153 let env = if env == crate::LEGACY_ENV_ID || env == crate::DEFAULT_ENV_ID {
157 loaded.env.clone().unwrap_or(env)
158 } else {
159 env
160 };
161 (tenant, team, env)
162}
163
164pub fn resolve_setup_scope_with_bundle(
167 tenant: String,
168 team: Option<String>,
169 env: String,
170 loaded: &LoadedAnswers,
171 bundle_dir: &std::path::Path,
172) -> (String, Option<String>, String) {
173 let (mut tenant, team, env) = resolve_setup_scope(tenant, team, env, loaded);
174
175 if tenant == "demo"
178 && loaded.tenant.is_none()
179 && let Some(detected) = detect_tenant_from_bundle(bundle_dir)
180 {
181 tenant = detected;
182 }
183
184 (tenant, team, env)
185}
186
187fn detect_tenant_from_bundle(bundle_dir: &std::path::Path) -> Option<String> {
191 let tenants_dir = bundle_dir.join("tenants");
192 let entries: Vec<String> = std::fs::read_dir(&tenants_dir)
193 .ok()?
194 .filter_map(|e| e.ok())
195 .filter(|e| e.path().is_dir())
196 .filter_map(|e| e.file_name().into_string().ok())
197 .collect();
198
199 match entries.len() {
200 0 => None,
201 1 => Some(entries[0].clone()),
202 _ => {
203 entries
205 .iter()
206 .find(|t| t.as_str() != "demo")
207 .cloned()
208 .or_else(|| entries.first().cloned())
209 }
210 }
211}
212
213fn has_cloud_deployment_target(targets: &[DeploymentTargetRecord]) -> bool {
214 targets
215 .iter()
216 .any(|record| matches!(record.target.as_str(), "aws" | "gcp" | "azure"))
217}
218
219fn default_no_tunnel_answers() -> TunnelAnswers {
220 TunnelAnswers {
221 mode: Some("off".to_string()),
222 ..Default::default()
223 }
224}
225
226pub fn run_interactive_wizard(
228 bundle_path: &Path,
229 tenant: &str,
230 team: Option<&str>,
231 env: &str,
232 advanced: bool,
233) -> Result<LoadedAnswers> {
234 use serde_json::Value;
235
236 let mut all_answers = serde_json::Map::new();
237 let existing_static_routes = load_effective_static_routes_defaults(bundle_path, tenant, team)?;
238 let static_routes = prompt_static_routes_policy(env, existing_static_routes.as_ref())?;
239 let deployer_candidates =
240 crate::deployment_targets::discover_deployer_pack_candidates(bundle_path)?;
241 let deployment_targets =
242 crate::deployment_targets::reconcile_deployment_targets(bundle_path, &[])?;
243
244 let tunnel = if has_cloud_deployment_target(&deployment_targets) {
246 Some(default_no_tunnel_answers())
247 } else if deployer_candidates.is_empty() {
248 Some(crate::platform_setup::prompt_tunnel_mode(None)?)
249 } else {
250 None
251 };
252
253 let discovered = discovery::discover(bundle_path)?;
254 let setup_targets = discovered.setup_targets();
255
256 if setup_targets.is_empty() {
257 println!("No setup packs found in bundle. Nothing to configure.");
258 return Ok(LoadedAnswers {
259 tenant: None,
260 team: None,
261 env: None,
262 platform_setup: PlatformSetupAnswers {
263 static_routes: Some(static_routes.to_answers()),
264 deployment_targets,
265 tunnel,
266 telemetry: None,
267 },
268 setup_answers: all_answers,
269 providers: Vec::new(),
270 });
271 }
272
273 println!("Found {} pack(s) to configure:", setup_targets.len());
274 for provider in &setup_targets {
275 println!(" - {} ({})", provider.provider_id, provider.domain);
276 }
277 println!();
278
279 let provider_form_specs: Vec<wizard::ProviderFormSpec> = setup_targets
282 .iter()
283 .filter_map(|provider| {
284 setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id).map(
285 |form_spec| wizard::ProviderFormSpec {
286 provider_id: provider.provider_id.clone(),
287 form_spec,
288 },
289 )
290 })
291 .collect();
292
293 let shared_answers = if provider_form_specs.len() > 1 {
296 let shared_result = wizard::collect_shared_questions(&provider_form_specs);
297 if !shared_result.shared_questions.is_empty() {
298 let empty = Value::Object(serde_json::Map::new());
299 wizard::prompt_shared_questions(&shared_result, advanced, &empty)?
300 } else {
301 Value::Object(serde_json::Map::new())
302 }
303 } else {
304 Value::Object(serde_json::Map::new())
305 };
306
307 for provider in &setup_targets {
309 let provider_id = &provider.provider_id;
310 let form_spec = setup_to_formspec::pack_to_form_spec(&provider.pack_path, provider_id);
311
312 if let Some(spec) = form_spec {
313 if spec.questions.is_empty() {
314 println!("Provider {}: No configuration required.", provider_id);
315 all_answers.insert(provider_id.clone(), Value::Object(serde_json::Map::new()));
316 continue;
317 }
318
319 let answers = wizard::prompt_form_spec_answers_with_existing(
322 &spec,
323 provider_id,
324 advanced,
325 &shared_answers,
326 None,
327 )?;
328 all_answers.insert(provider_id.clone(), answers);
329 } else {
330 println!(
331 "Provider {}: No setup questions found (may use flow-based setup).",
332 provider_id
333 );
334 all_answers.insert(provider_id.clone(), Value::Object(serde_json::Map::new()));
335 }
336
337 println!();
338 }
339
340 Ok(LoadedAnswers {
341 tenant: None,
342 team: None,
343 env: None,
344 platform_setup: PlatformSetupAnswers {
345 static_routes: Some(static_routes.to_answers()),
346 deployment_targets,
347 tunnel,
348 telemetry: None,
349 },
350 setup_answers: all_answers,
351 providers: Vec::new(),
352 })
353}
354
355pub fn complete_loaded_answers_with_prompts(
362 bundle_path: &Path,
363 tenant: &str,
364 team: Option<&str>,
365 env: &str,
366 advanced: bool,
367 non_interactive: bool,
368 mut loaded: LoadedAnswers,
369) -> Result<LoadedAnswers> {
370 let existing_static_routes = load_effective_static_routes_defaults(bundle_path, tenant, team)?;
371 let static_routes_need_prompt = match loaded.platform_setup.static_routes.as_ref() {
372 None => true,
373 Some(answers) => StaticRoutesPolicy::normalize(Some(answers), env).is_err(),
374 };
375 if static_routes_need_prompt && !non_interactive {
376 let static_routes =
377 if let Some(current_answers) = loaded.platform_setup.static_routes.as_ref() {
378 prompt_static_routes_policy_with_answers(
379 env,
380 Some(current_answers),
381 existing_static_routes.as_ref(),
382 )?
383 } else {
384 prompt_static_routes_policy(env, existing_static_routes.as_ref())?
385 };
386 loaded.platform_setup.static_routes = Some(static_routes.to_answers());
387 }
388 let deployer_candidates =
389 crate::deployment_targets::discover_deployer_pack_candidates(bundle_path)?;
390 loaded.platform_setup.deployment_targets =
391 crate::deployment_targets::reconcile_deployment_targets(
392 bundle_path,
393 &loaded.platform_setup.deployment_targets,
394 )?;
395 if has_cloud_deployment_target(&loaded.platform_setup.deployment_targets) {
396 loaded.platform_setup.tunnel = Some(default_no_tunnel_answers());
397 } else if deployer_candidates.is_empty()
398 && loaded.platform_setup.tunnel.is_none()
399 && !non_interactive
400 {
401 loaded.platform_setup.tunnel = Some(crate::platform_setup::prompt_tunnel_mode(None)?);
402 }
403
404 if !non_interactive {
410 let env_placeholders = collect_env_var_placeholders(&loaded);
411 if !env_placeholders.is_empty() {
412 let resolved_env_vars = confirm_env_var_placeholders(&env_placeholders)?;
413
414 if !resolved_env_vars.is_empty() {
416 apply_resolved_env_vars(&mut loaded, &resolved_env_vars);
417 }
418 }
419 }
420
421 let discovered = discovery::discover(bundle_path)?;
422 let setup_targets = discovered.setup_targets();
423
424 let all_provider_form_specs: Vec<wizard::ProviderFormSpec> = setup_targets
427 .iter()
428 .filter(|provider| {
429 loaded
430 .setup_answers
431 .get(&provider.provider_id)
432 .map(crate::provider_state::provider_enabled)
433 .unwrap_or(true)
434 })
435 .filter_map(|provider| {
436 setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id).map(
437 |form_spec| wizard::ProviderFormSpec {
438 provider_id: provider.provider_id.clone(),
439 form_spec,
440 },
441 )
442 })
443 .collect();
444
445 let mut existing_shared_values = serde_json::Map::new();
448 let shared_result = if all_provider_form_specs.len() > 1 {
449 let result = wizard::collect_shared_questions(&all_provider_form_specs);
450 for question in &result.shared_questions {
452 for (_provider_id, provider_answers) in &loaded.setup_answers {
453 if let Some(value) = provider_answers.get(&question.id) {
454 if !(value.is_null() || value.is_string() && value.as_str() == Some("")) {
456 existing_shared_values.insert(question.id.clone(), value.clone());
457 break;
458 }
459 }
460 }
461 }
462 Some(result)
463 } else {
464 None
465 };
466
467 let shared_answers = if !non_interactive {
474 if let Some(ref result) = shared_result {
475 if !result.shared_questions.is_empty() {
476 let existing = serde_json::Value::Object(existing_shared_values);
477 wizard::prompt_shared_questions(result, advanced, &existing)?
478 } else {
479 serde_json::Value::Object(serde_json::Map::new())
480 }
481 } else {
482 serde_json::Value::Object(serde_json::Map::new())
483 }
484 } else {
485 serde_json::Value::Object(serde_json::Map::new())
486 };
487
488 for provider in &setup_targets {
490 let provider_id = &provider.provider_id;
491 let existing = loaded
492 .setup_answers
493 .get(provider_id)
494 .cloned()
495 .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
496 if !crate::provider_state::provider_enabled(&existing) {
497 loaded.setup_answers.insert(provider_id.clone(), existing);
498 continue;
499 }
500
501 if non_interactive {
505 loaded.setup_answers.insert(provider_id.clone(), existing);
506 continue;
507 }
508
509 let mut merged = existing.as_object().cloned().unwrap_or_default();
512 if let Some(shared_obj) = shared_answers.as_object() {
513 for (key, value) in shared_obj {
514 let is_non_empty =
516 !(value.is_null() || value.is_string() && value.as_str() == Some(""));
517 if is_non_empty {
518 merged.insert(key.clone(), value.clone());
519 }
520 }
521 }
522 let merged_value = serde_json::Value::Object(merged);
523
524 let form_spec = setup_to_formspec::pack_to_form_spec(&provider.pack_path, provider_id);
525 let completed = if let Some(spec) = form_spec {
526 if spec.questions.is_empty() {
527 existing
528 } else {
529 wizard::prompt_form_spec_answers_with_existing(
530 &spec,
531 provider_id,
532 advanced,
533 &merged_value,
534 None,
535 )?
536 }
537 } else {
538 existing
539 };
540 loaded.setup_answers.insert(provider_id.clone(), completed);
541 }
542
543 Ok(loaded)
544}
545
546pub fn ensure_deployment_targets_present(bundle_path: &Path, loaded: &LoadedAnswers) -> Result<()> {
548 let _ = crate::deployment_targets::reconcile_deployment_targets(
549 bundle_path,
550 &loaded.platform_setup.deployment_targets,
551 )?;
552 Ok(())
553}
554
555pub fn ensure_required_setup_answers_present(
557 bundle_path: &Path,
558 loaded: &LoadedAnswers,
559) -> Result<()> {
560 let discovered = discovery::discover(bundle_path)?;
561 for provider in discovered.setup_targets() {
562 let Some(spec) =
563 setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id)
564 else {
565 continue;
566 };
567 if spec.questions.is_empty() {
568 continue;
569 }
570
571 let answers = loaded
572 .setup_answers
573 .get(&provider.provider_id)
574 .cloned()
575 .unwrap_or_else(|| Value::Object(Default::default()));
576 if !crate::provider_state::provider_enabled(&answers) {
577 continue;
578 }
579 let answer_map = answers.as_object().ok_or_else(|| {
580 anyhow::anyhow!("answers for {} must be an object", provider.provider_id)
581 })?;
582 let visibility = resolve_visibility(&spec, &answers, VisibilityMode::Visible);
583
584 for question in spec.questions.iter().filter(|question| question.required) {
585 if !visibility.get(&question.id).copied().unwrap_or(true) {
586 continue;
587 }
588 let Some(value) = answer_map.get(&question.id) else {
589 anyhow::bail!(
590 "missing required setup answer for {}.{}",
591 provider.provider_id,
592 question.id
593 );
594 };
595 if !wizard::answer_satisfies_question(question, value) {
596 anyhow::bail!(
597 "missing required setup answer for {}.{}",
598 provider.provider_id,
599 question.id
600 );
601 }
602 }
603 }
604 Ok(())
605}
606
607#[cfg(test)]
608mod tests {
609 use super::{
610 default_no_tunnel_answers, has_cloud_deployment_target, resolve_setup_scope,
611 resolve_setup_scope_with_bundle,
612 };
613 use crate::deployment_targets::DeploymentTargetRecord;
614 use crate::engine::LoadedAnswers;
615
616 #[test]
617 fn resolve_setup_scope_prefers_answers_when_cli_is_default() {
618 let loaded = LoadedAnswers {
619 tenant: Some("acme".to_string()),
620 team: Some("core".to_string()),
621 env: Some("prod".to_string()),
622 ..Default::default()
623 };
624 let resolved = resolve_setup_scope("demo".to_string(), None, "dev".to_string(), &loaded);
625 assert_eq!(resolved.0, "acme");
626 assert_eq!(resolved.1.as_deref(), Some("core"));
627 assert_eq!(resolved.2, "prod");
628 }
629
630 #[test]
631 fn resolve_setup_scope_keeps_explicit_cli_values() {
632 let loaded = LoadedAnswers {
633 tenant: Some("acme".to_string()),
634 team: Some("core".to_string()),
635 env: Some("prod".to_string()),
636 ..Default::default()
637 };
638 let resolved = resolve_setup_scope(
639 "sandbox".to_string(),
640 Some("ops".to_string()),
641 "staging".to_string(),
642 &loaded,
643 );
644 assert_eq!(resolved.0, "sandbox");
645 assert_eq!(resolved.1.as_deref(), Some("ops"));
646 assert_eq!(resolved.2, "staging");
647 }
648
649 #[test]
650 fn bundle_detection_does_not_override_answers_tenant_demo() {
651 let temp = tempfile::tempdir().expect("tempdir");
652 std::fs::create_dir_all(temp.path().join("tenants").join("default")).expect("tenant dir");
653
654 let loaded = LoadedAnswers {
655 tenant: Some("demo".to_string()),
656 ..Default::default()
657 };
658
659 let (tenant, team, env) = resolve_setup_scope_with_bundle(
660 "demo".to_string(),
661 None,
662 "dev".to_string(),
663 &loaded,
664 temp.path(),
665 );
666
667 assert_eq!(tenant, "demo");
668 assert_eq!(team, None);
669 assert_eq!(env, "dev");
670 }
671
672 #[test]
673 fn resolve_setup_scope_treats_local_default_like_dev_default() {
674 let loaded = crate::engine::LoadedAnswers {
677 tenant: Some("acme".to_string()),
678 team: None,
679 env: Some("prod".to_string()),
680 ..Default::default()
681 };
682 let resolved = resolve_setup_scope("demo".to_string(), None, "local".to_string(), &loaded);
683 assert_eq!(resolved.2, "prod");
684 }
685
686 #[test]
687 fn resolve_setup_scope_local_default_with_no_loaded_env_stays_local() {
688 let loaded = crate::engine::LoadedAnswers::default();
689 let resolved = resolve_setup_scope("demo".to_string(), None, "local".to_string(), &loaded);
690 assert_eq!(resolved.2, "local");
691 }
692
693 #[test]
694 fn detects_cloud_deployment_targets() {
695 assert!(has_cloud_deployment_target(&[
696 DeploymentTargetRecord {
697 target: "aws".to_string(),
698 provider_pack: None,
699 default: None,
700 },
701 DeploymentTargetRecord {
702 target: "runtime".to_string(),
703 provider_pack: None,
704 default: None,
705 },
706 ]));
707 assert!(!has_cloud_deployment_target(&[
708 DeploymentTargetRecord {
709 target: "runtime".to_string(),
710 provider_pack: None,
711 default: None,
712 },
713 DeploymentTargetRecord {
714 target: "single-vm".to_string(),
715 provider_pack: None,
716 default: None,
717 },
718 ]));
719 }
720
721 #[test]
722 fn default_no_tunnel_answers_for_cloud_sets_off_mode() {
723 assert_eq!(default_no_tunnel_answers().mode.as_deref(), Some("off"));
724 }
725}