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;
10
11use crate::discovery;
12use crate::engine::LoadedAnswers;
13use crate::platform_setup::{
14    PlatformSetupAnswers, StaticRoutesPolicy, load_effective_static_routes_defaults,
15    prompt_static_routes_policy, prompt_static_routes_policy_with_answers,
16};
17use crate::qa::wizard;
18use crate::setup_to_formspec;
19
20// Re-export from submodules
21pub use bundle::{
22    SetupOutputTarget, copy_dir_recursive, detect_domain_from_filename, resolve_bundle_dir,
23    resolve_bundle_source, resolve_pack_source, setup_output_target,
24};
25pub use env_vars::{
26    EnvVarPlaceholder, apply_resolved_env_vars, collect_env_var_placeholders,
27    confirm_env_var_placeholders,
28};
29pub use prompts::{SetupParams, prompt_setup_params};
30
31/// Resolve tenant/team/env for setup.
32///
33/// When CLI values are still defaults (`demo`, unset team, `dev`) and an answers
34/// file includes tenant/team/env metadata, prefer metadata values.
35pub fn resolve_setup_scope(
36    tenant: String,
37    team: Option<String>,
38    env: String,
39    loaded: &LoadedAnswers,
40) -> (String, Option<String>, String) {
41    let tenant = if tenant == "demo" {
42        loaded.tenant.clone().unwrap_or(tenant)
43    } else {
44        tenant
45    };
46    let team = if team.is_none() {
47        loaded.team.clone()
48    } else {
49        team
50    };
51    let env = if env == "dev" {
52        loaded.env.clone().unwrap_or(env)
53    } else {
54        env
55    };
56    (tenant, team, env)
57}
58
59/// Run interactive wizard for all discovered packs in the bundle.
60pub fn run_interactive_wizard(
61    bundle_path: &Path,
62    tenant: &str,
63    team: Option<&str>,
64    env: &str,
65    advanced: bool,
66) -> Result<LoadedAnswers> {
67    use serde_json::Value;
68
69    let mut all_answers = serde_json::Map::new();
70    let existing_static_routes = load_effective_static_routes_defaults(bundle_path, tenant, team)?;
71    let static_routes = prompt_static_routes_policy(env, existing_static_routes.as_ref())?;
72    let deployment_targets = crate::deployment_targets::prompt_deployment_targets(
73        &crate::deployment_targets::discover_deployer_pack_candidates(bundle_path)?,
74    )?;
75
76    let discovered = discovery::discover(bundle_path)?;
77
78    if discovered.providers.is_empty() {
79        println!("No providers found in bundle. Nothing to configure.");
80        return Ok(LoadedAnswers {
81            tenant: None,
82            team: None,
83            env: None,
84            platform_setup: PlatformSetupAnswers {
85                static_routes: Some(static_routes.to_answers()),
86                deployment_targets,
87            },
88            setup_answers: all_answers,
89        });
90    }
91
92    println!(
93        "Found {} provider(s) to configure:",
94        discovered.providers.len()
95    );
96    for provider in &discovered.providers {
97        println!("  - {} ({})", provider.provider_id, provider.domain);
98    }
99    println!();
100
101    // ── Collect and prompt shared questions once ────────────────────────────
102    // Build FormSpecs for all providers to identify shared questions
103    let provider_form_specs: Vec<wizard::ProviderFormSpec> = discovered
104        .providers
105        .iter()
106        .filter_map(|provider| {
107            setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id).map(
108                |form_spec| wizard::ProviderFormSpec {
109                    provider_id: provider.provider_id.clone(),
110                    form_spec,
111                },
112            )
113        })
114        .collect();
115
116    // Prompt for shared questions (like public_base_url) once at the start
117    // In interactive mode, we have no existing answers so pass empty Value
118    let shared_answers = if provider_form_specs.len() > 1 {
119        let shared_result = wizard::collect_shared_questions(&provider_form_specs);
120        if !shared_result.shared_questions.is_empty() {
121            let empty = Value::Object(serde_json::Map::new());
122            wizard::prompt_shared_questions(&shared_result, advanced, &empty)?
123        } else {
124            Value::Object(serde_json::Map::new())
125        }
126    } else {
127        Value::Object(serde_json::Map::new())
128    };
129
130    // ── Configure each provider ─────────────────────────────────────────────
131    for provider in &discovered.providers {
132        let provider_id = &provider.provider_id;
133        let form_spec = setup_to_formspec::pack_to_form_spec(&provider.pack_path, provider_id);
134
135        if let Some(spec) = form_spec {
136            if spec.questions.is_empty() {
137                println!("Provider {}: No configuration required.", provider_id);
138                all_answers.insert(provider_id.clone(), Value::Object(serde_json::Map::new()));
139                continue;
140            }
141
142            // Use shared answers as initial values - already-answered questions will be skipped
143            let answers = wizard::prompt_form_spec_answers_with_existing(
144                &spec,
145                provider_id,
146                advanced,
147                &shared_answers,
148            )?;
149            all_answers.insert(provider_id.clone(), answers);
150        } else {
151            println!(
152                "Provider {}: No setup questions found (may use flow-based setup).",
153                provider_id
154            );
155            all_answers.insert(provider_id.clone(), Value::Object(serde_json::Map::new()));
156        }
157
158        println!();
159    }
160
161    Ok(LoadedAnswers {
162        tenant: None,
163        team: None,
164        env: None,
165        platform_setup: PlatformSetupAnswers {
166            static_routes: Some(static_routes.to_answers()),
167            deployment_targets,
168        },
169        setup_answers: all_answers,
170    })
171}
172
173/// Complete loaded answers by prompting for missing values.
174pub fn complete_loaded_answers_with_prompts(
175    bundle_path: &Path,
176    tenant: &str,
177    team: Option<&str>,
178    env: &str,
179    advanced: bool,
180    mut loaded: LoadedAnswers,
181) -> Result<LoadedAnswers> {
182    let existing_static_routes = load_effective_static_routes_defaults(bundle_path, tenant, team)?;
183    let static_routes_need_prompt = match loaded.platform_setup.static_routes.as_ref() {
184        None => true,
185        Some(answers) => StaticRoutesPolicy::normalize(Some(answers), env).is_err(),
186    };
187    if static_routes_need_prompt {
188        let static_routes =
189            if let Some(current_answers) = loaded.platform_setup.static_routes.as_ref() {
190                prompt_static_routes_policy_with_answers(
191                    env,
192                    Some(current_answers),
193                    existing_static_routes.as_ref(),
194                )?
195            } else {
196                prompt_static_routes_policy(env, existing_static_routes.as_ref())?
197            };
198        loaded.platform_setup.static_routes = Some(static_routes.to_answers());
199    }
200    if loaded.platform_setup.deployment_targets.is_empty() {
201        loaded.platform_setup.deployment_targets =
202            crate::deployment_targets::prompt_deployment_targets(
203                &crate::deployment_targets::discover_deployer_pack_candidates(bundle_path)?,
204            )?;
205    }
206
207    // ── Confirm environment variable placeholders ────────────────────────────
208    let env_placeholders = collect_env_var_placeholders(&loaded);
209    if !env_placeholders.is_empty() {
210        let resolved_env_vars = confirm_env_var_placeholders(&env_placeholders)?;
211
212        // Apply resolved env vars to the loaded answers
213        if !resolved_env_vars.is_empty() {
214            apply_resolved_env_vars(&mut loaded, &resolved_env_vars);
215        }
216    }
217
218    let discovered = discovery::discover(bundle_path)?;
219
220    // ── Collect and prompt shared questions once ────────────────────────────
221    // Build FormSpecs for ALL providers to identify shared questions
222    let all_provider_form_specs: Vec<wizard::ProviderFormSpec> = discovered
223        .providers
224        .iter()
225        .filter_map(|provider| {
226            setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id).map(
227                |form_spec| wizard::ProviderFormSpec {
228                    provider_id: provider.provider_id.clone(),
229                    form_spec,
230                },
231            )
232        })
233        .collect();
234
235    // Extract existing shared values from loaded answers
236    // Look for values across all providers that might have shared questions
237    let mut existing_shared_values = serde_json::Map::new();
238    let shared_result = if all_provider_form_specs.len() > 1 {
239        let result = wizard::collect_shared_questions(&all_provider_form_specs);
240        // Find existing values for shared questions from any provider
241        for question in &result.shared_questions {
242            for (_provider_id, provider_answers) in &loaded.setup_answers {
243                if let Some(value) = provider_answers.get(&question.id) {
244                    // Use first non-empty value found
245                    if !(value.is_null() || value.is_string() && value.as_str() == Some("")) {
246                        existing_shared_values.insert(question.id.clone(), value.clone());
247                        break;
248                    }
249                }
250            }
251        }
252        Some(result)
253    } else {
254        None
255    };
256
257    // Prompt for shared questions (like public_base_url) once at the start
258    // Pass existing values so already-answered questions are skipped
259    let shared_answers = if let Some(ref result) = shared_result {
260        if !result.shared_questions.is_empty() {
261            let existing = serde_json::Value::Object(existing_shared_values);
262            wizard::prompt_shared_questions(result, advanced, &existing)?
263        } else {
264            serde_json::Value::Object(serde_json::Map::new())
265        }
266    } else {
267        serde_json::Value::Object(serde_json::Map::new())
268    };
269
270    // ── Complete answers for each provider ──────────────────────────────────
271    for provider in &discovered.providers {
272        let provider_id = &provider.provider_id;
273        let existing = loaded
274            .setup_answers
275            .get(provider_id)
276            .cloned()
277            .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
278
279        // Merge shared answers with existing answers.
280        // Shared answers (user just entered) take precedence over existing values.
281        let mut merged = existing.as_object().cloned().unwrap_or_default();
282        if let Some(shared_obj) = shared_answers.as_object() {
283            for (key, value) in shared_obj {
284                // Only apply shared answer if it's non-empty
285                let is_non_empty =
286                    !(value.is_null() || value.is_string() && value.as_str() == Some(""));
287                if is_non_empty {
288                    merged.insert(key.clone(), value.clone());
289                }
290            }
291        }
292        let merged_value = serde_json::Value::Object(merged);
293
294        let form_spec = setup_to_formspec::pack_to_form_spec(&provider.pack_path, provider_id);
295        let completed = if let Some(spec) = form_spec {
296            if spec.questions.is_empty() {
297                existing
298            } else {
299                wizard::prompt_form_spec_answers_with_existing(
300                    &spec,
301                    provider_id,
302                    advanced,
303                    &merged_value,
304                )?
305            }
306        } else {
307            existing
308        };
309        loaded.setup_answers.insert(provider_id.clone(), completed);
310    }
311
312    Ok(loaded)
313}
314
315/// Ensure deployment targets are present if bundle has deployer packs.
316pub fn ensure_deployment_targets_present(bundle_path: &Path, loaded: &LoadedAnswers) -> Result<()> {
317    if !loaded.platform_setup.deployment_targets.is_empty() {
318        return Ok(());
319    }
320    let candidates = crate::deployment_targets::discover_deployer_pack_candidates(bundle_path)?;
321    if candidates.is_empty() {
322        return Ok(());
323    }
324    anyhow::bail!(
325        "bundle contains deployer packs ({}) but answers did not define platform_setup.deployment_targets",
326        candidates
327            .iter()
328            .map(|value| value.display().to_string())
329            .collect::<Vec<_>>()
330            .join(", ")
331    )
332}
333
334#[cfg(test)]
335mod tests {
336    use super::resolve_setup_scope;
337    use crate::engine::LoadedAnswers;
338
339    #[test]
340    fn resolve_setup_scope_prefers_answers_when_cli_is_default() {
341        let loaded = LoadedAnswers {
342            tenant: Some("acme".to_string()),
343            team: Some("core".to_string()),
344            env: Some("prod".to_string()),
345            ..Default::default()
346        };
347        let resolved = resolve_setup_scope("demo".to_string(), None, "dev".to_string(), &loaded);
348        assert_eq!(resolved.0, "acme");
349        assert_eq!(resolved.1.as_deref(), Some("core"));
350        assert_eq!(resolved.2, "prod");
351    }
352
353    #[test]
354    fn resolve_setup_scope_keeps_explicit_cli_values() {
355        let loaded = LoadedAnswers {
356            tenant: Some("acme".to_string()),
357            team: Some("core".to_string()),
358            env: Some("prod".to_string()),
359            ..Default::default()
360        };
361        let resolved = resolve_setup_scope(
362            "sandbox".to_string(),
363            Some("ops".to_string()),
364            "staging".to_string(),
365            &loaded,
366        );
367        assert_eq!(resolved.0, "sandbox");
368        assert_eq!(resolved.1.as_deref(), Some("ops"));
369        assert_eq!(resolved.2, "staging");
370    }
371}