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