Skip to main content

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