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::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
22// Re-export from submodules
23pub 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
33/// Resolve tenant/team/env for setup.
34///
35/// When CLI values are still defaults (`demo`, unset team, `dev`) and an answers
36/// file includes tenant/team/env metadata, prefer metadata values.
37/// Also detects tenant from existing bundle `tenants/` directory when neither
38/// CLI nor answers provide a tenant.
39pub 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
63/// Like [`resolve_setup_scope`] but also checks the bundle's `tenants/` directory
64/// for existing tenants when the CLI value is still the default.
65pub 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 is still the CLI default ("demo") and it did not come from the
75    // answers file, detect the actual tenant from existing directories.
76    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
86/// Detect tenant from the bundle's `tenants/` directory.
87/// Returns the single tenant if exactly one exists, or the first non-"demo"
88/// tenant if multiple exist.
89fn 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            // Multiple tenants — prefer non-"demo" if exists
103            entries
104                .iter()
105                .find(|t| t.as_str() != "demo")
106                .cloned()
107                .or_else(|| entries.first().cloned())
108        }
109    }
110}
111
112/// Run interactive wizard for all discovered packs in the bundle.
113pub 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    // Prompt for tunnel mode when no deployer packs are present (local dev).
131    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    // ── Collect and prompt shared questions once ────────────────────────────
162    // Build FormSpecs for all providers to identify shared questions
163    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    // Prompt for shared questions (like public_base_url) once at the start
176    // In interactive mode, we have no existing answers so pass empty Value
177    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    // ── Configure each provider ─────────────────────────────────────────────
190    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            // Use shared answers as initial values - already-answered questions will be skipped
202            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
233/// Complete loaded answers by prompting for missing values.
234///
235/// When `non_interactive` is true, prompts are skipped so a missing
236/// `platform_setup` field doesn't deadlock automation runs on a hidden
237/// TTY prompt — the value is left for the runtime defaults (or for
238/// `ensure_required_setup_answers_present` to flag downstream).
239pub 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    // ── Confirm environment variable placeholders ────────────────────────────
278    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        // Apply resolved env vars to the loaded answers
283        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    // ── Collect and prompt shared questions once ────────────────────────────
292    // Build FormSpecs for ALL providers to identify shared questions
293    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    // Extract existing shared values from loaded answers
306    // Look for values across all providers that might have shared questions
307    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        // Find existing values for shared questions from any provider
311        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                    // Use first non-empty value found
315                    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    // Prompt for shared questions (like public_base_url) once at the start
328    // Pass existing values so already-answered questions are skipped
329    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    // ── Complete answers for each provider ──────────────────────────────────
341    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        // Merge shared answers with existing answers.
350        // Shared answers (user just entered) take precedence over existing values.
351        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                // Only apply shared answer if it's non-empty
355                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
385/// Ensure deployment targets are present if bundle has deployer packs.
386pub 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
404/// Ensure loaded answers satisfy all visible required setup questions.
405pub 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}