Skip to main content

greentic_setup/cli_helpers/
mod.rs

1//! CLI helper functions for greentic-setup.
2
3mod 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
24// Re-export from submodules
25pub 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
35/// Start a setup-time tunnel for non-UI setup and inject its public URL into
36/// provider answers that need `public_base_url`.
37pub 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
57/// Resolve tenant/team/env for setup.
58///
59/// When CLI values are still defaults (`demo`, unset team, `dev`) and an answers
60/// file includes tenant/team/env metadata, prefer metadata values.
61/// Also detects tenant from existing bundle `tenants/` directory when neither
62/// CLI nor answers provide a tenant.
63pub 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    // Both the legacy default (`dev`) and the new A4b default (`local`)
80    // are treated as the "no explicit CLI value — use the bundle answers'
81    // env if any" sentinel. Explicit non-default values stay verbatim.
82    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
90/// Like [`resolve_setup_scope`] but also checks the bundle's `tenants/` directory
91/// for existing tenants when the CLI value is still the default.
92pub 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 is still the CLI default ("demo") and it did not come from the
102    // answers file, detect the actual tenant from existing directories.
103    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
113/// Detect tenant from the bundle's `tenants/` directory.
114/// Returns the single tenant if exactly one exists, or the first non-"demo"
115/// tenant if multiple exist.
116fn 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            // Multiple tenants — prefer non-"demo" if exists
130            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
151/// Run interactive wizard for all discovered packs in the bundle.
152pub 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    // Prompt for tunnel mode when no deployer packs are present (local dev).
170    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    // ── Collect and prompt shared questions once ────────────────────────────
204    // Build FormSpecs for all providers to identify shared questions
205    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    // Prompt for shared questions (like public_base_url) once at the start
218    // In interactive mode, we have no existing answers so pass empty Value
219    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    // ── Configure each provider ─────────────────────────────────────────────
232    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            // Use shared answers as initial values - already-answered questions will be skipped.
244            // None: provider-setup flow keeps English prompt chrome.
245            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
278/// Complete loaded answers by prompting for missing values.
279///
280/// When `non_interactive` is true, prompts are skipped so a missing
281/// `platform_setup` field doesn't deadlock automation runs on a hidden
282/// TTY prompt — the value is left for the runtime defaults (or for
283/// `ensure_required_setup_answers_present` to flag downstream).
284pub 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    // ── Confirm environment variable placeholders ────────────────────────────
327    // Skip in non-interactive mode: leave any unresolved `${VAR}` placeholders
328    // in place. `answer_satisfies_question` accepts placeholder strings as
329    // valid runtime-resolved values, and `ensure_required_setup_answers_present`
330    // will fail-fast downstream if anything truly required is missing.
331    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            // Apply resolved env vars to the loaded answers
337            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    // ── Collect and prompt shared questions once ────────────────────────────
347    // Build FormSpecs for ALL providers to identify shared questions
348    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    // Extract existing shared values from loaded answers
368    // Look for values across all providers that might have shared questions
369    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        // Find existing values for shared questions from any provider
373        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                    // Use first non-empty value found
377                    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    // Prompt for shared questions (like public_base_url) once at the start
390    // Pass existing values so already-answered questions are skipped.
391    // Skip entirely in non-interactive mode: the loaded answers file is
392    // expected to provide everything required, and the engine's
393    // `ensure_required_setup_answers_present()` will fail-fast downstream
394    // if anything required is missing.
395    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    // ── Complete answers for each provider ──────────────────────────────────
411    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        // In non-interactive mode, never prompt. Preserve whatever was loaded
424        // from the answers file as-is; downstream validation fails fast on
425        // missing required fields.
426        if non_interactive {
427            loaded.setup_answers.insert(provider_id.clone(), existing);
428            continue;
429        }
430
431        // Merge shared answers with existing answers.
432        // Shared answers (user just entered) take precedence over existing values.
433        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                // Only apply shared answer if it's non-empty
437                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
468/// Ensure deployment targets are present if bundle has deployer packs.
469pub 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
487/// Ensure loaded answers satisfy all visible required setup questions.
488pub 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        // A4b widening: both `dev` (legacy default) and `local` (new
607        // default) act as "no explicit CLI value — use loaded.env if any".
608        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}