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.
234pub fn complete_loaded_answers_with_prompts(
235    bundle_path: &Path,
236    tenant: &str,
237    team: Option<&str>,
238    env: &str,
239    advanced: bool,
240    mut loaded: LoadedAnswers,
241) -> Result<LoadedAnswers> {
242    let existing_static_routes = load_effective_static_routes_defaults(bundle_path, tenant, team)?;
243    let static_routes_need_prompt = match loaded.platform_setup.static_routes.as_ref() {
244        None => true,
245        Some(answers) => StaticRoutesPolicy::normalize(Some(answers), env).is_err(),
246    };
247    if static_routes_need_prompt {
248        let static_routes =
249            if let Some(current_answers) = loaded.platform_setup.static_routes.as_ref() {
250                prompt_static_routes_policy_with_answers(
251                    env,
252                    Some(current_answers),
253                    existing_static_routes.as_ref(),
254                )?
255            } else {
256                prompt_static_routes_policy(env, existing_static_routes.as_ref())?
257            };
258        loaded.platform_setup.static_routes = Some(static_routes.to_answers());
259    }
260    let deployer_candidates =
261        crate::deployment_targets::discover_deployer_pack_candidates(bundle_path)?;
262    if loaded.platform_setup.deployment_targets.is_empty() {
263        loaded.platform_setup.deployment_targets =
264            crate::deployment_targets::prompt_deployment_targets(&deployer_candidates)?;
265    }
266    // Prompt for tunnel mode when no deployer and not already configured.
267    if deployer_candidates.is_empty() && loaded.platform_setup.tunnel.is_none() {
268        loaded.platform_setup.tunnel = Some(crate::platform_setup::prompt_tunnel_mode(None)?);
269    }
270
271    // ── Confirm environment variable placeholders ────────────────────────────
272    let env_placeholders = collect_env_var_placeholders(&loaded);
273    if !env_placeholders.is_empty() {
274        let resolved_env_vars = confirm_env_var_placeholders(&env_placeholders)?;
275
276        // Apply resolved env vars to the loaded answers
277        if !resolved_env_vars.is_empty() {
278            apply_resolved_env_vars(&mut loaded, &resolved_env_vars);
279        }
280    }
281
282    let discovered = discovery::discover(bundle_path)?;
283    let setup_targets = discovered.setup_targets();
284
285    // ── Collect and prompt shared questions once ────────────────────────────
286    // Build FormSpecs for ALL providers to identify shared questions
287    let all_provider_form_specs: Vec<wizard::ProviderFormSpec> = setup_targets
288        .iter()
289        .filter_map(|provider| {
290            setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id).map(
291                |form_spec| wizard::ProviderFormSpec {
292                    provider_id: provider.provider_id.clone(),
293                    form_spec,
294                },
295            )
296        })
297        .collect();
298
299    // Extract existing shared values from loaded answers
300    // Look for values across all providers that might have shared questions
301    let mut existing_shared_values = serde_json::Map::new();
302    let shared_result = if all_provider_form_specs.len() > 1 {
303        let result = wizard::collect_shared_questions(&all_provider_form_specs);
304        // Find existing values for shared questions from any provider
305        for question in &result.shared_questions {
306            for (_provider_id, provider_answers) in &loaded.setup_answers {
307                if let Some(value) = provider_answers.get(&question.id) {
308                    // Use first non-empty value found
309                    if !(value.is_null() || value.is_string() && value.as_str() == Some("")) {
310                        existing_shared_values.insert(question.id.clone(), value.clone());
311                        break;
312                    }
313                }
314            }
315        }
316        Some(result)
317    } else {
318        None
319    };
320
321    // Prompt for shared questions (like public_base_url) once at the start
322    // Pass existing values so already-answered questions are skipped
323    let shared_answers = if let Some(ref result) = shared_result {
324        if !result.shared_questions.is_empty() {
325            let existing = serde_json::Value::Object(existing_shared_values);
326            wizard::prompt_shared_questions(result, advanced, &existing)?
327        } else {
328            serde_json::Value::Object(serde_json::Map::new())
329        }
330    } else {
331        serde_json::Value::Object(serde_json::Map::new())
332    };
333
334    // ── Complete answers for each provider ──────────────────────────────────
335    for provider in &setup_targets {
336        let provider_id = &provider.provider_id;
337        let existing = loaded
338            .setup_answers
339            .get(provider_id)
340            .cloned()
341            .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
342
343        // Merge shared answers with existing answers.
344        // Shared answers (user just entered) take precedence over existing values.
345        let mut merged = existing.as_object().cloned().unwrap_or_default();
346        if let Some(shared_obj) = shared_answers.as_object() {
347            for (key, value) in shared_obj {
348                // Only apply shared answer if it's non-empty
349                let is_non_empty =
350                    !(value.is_null() || value.is_string() && value.as_str() == Some(""));
351                if is_non_empty {
352                    merged.insert(key.clone(), value.clone());
353                }
354            }
355        }
356        let merged_value = serde_json::Value::Object(merged);
357
358        let form_spec = setup_to_formspec::pack_to_form_spec(&provider.pack_path, provider_id);
359        let completed = if let Some(spec) = form_spec {
360            if spec.questions.is_empty() {
361                existing
362            } else {
363                wizard::prompt_form_spec_answers_with_existing(
364                    &spec,
365                    provider_id,
366                    advanced,
367                    &merged_value,
368                )?
369            }
370        } else {
371            existing
372        };
373        loaded.setup_answers.insert(provider_id.clone(), completed);
374    }
375
376    Ok(loaded)
377}
378
379/// Ensure deployment targets are present if bundle has deployer packs.
380pub fn ensure_deployment_targets_present(bundle_path: &Path, loaded: &LoadedAnswers) -> Result<()> {
381    if !loaded.platform_setup.deployment_targets.is_empty() {
382        return Ok(());
383    }
384    let candidates = crate::deployment_targets::discover_deployer_pack_candidates(bundle_path)?;
385    if candidates.is_empty() {
386        return Ok(());
387    }
388    anyhow::bail!(
389        "bundle contains deployer packs ({}) but answers did not define platform_setup.deployment_targets",
390        candidates
391            .iter()
392            .map(|value| value.display().to_string())
393            .collect::<Vec<_>>()
394            .join(", ")
395    )
396}
397
398/// Ensure loaded answers satisfy all visible required setup questions.
399pub fn ensure_required_setup_answers_present(
400    bundle_path: &Path,
401    loaded: &LoadedAnswers,
402) -> Result<()> {
403    let discovered = discovery::discover(bundle_path)?;
404    for provider in discovered.setup_targets() {
405        let Some(spec) =
406            setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id)
407        else {
408            continue;
409        };
410        if spec.questions.is_empty() {
411            continue;
412        }
413
414        let answers = loaded
415            .setup_answers
416            .get(&provider.provider_id)
417            .cloned()
418            .unwrap_or_else(|| Value::Object(Default::default()));
419        let answer_map = answers.as_object().ok_or_else(|| {
420            anyhow::anyhow!("answers for {} must be an object", provider.provider_id)
421        })?;
422        let visibility = resolve_visibility(&spec, &answers, VisibilityMode::Visible);
423
424        for question in spec.questions.iter().filter(|question| question.required) {
425            if !visibility.get(&question.id).copied().unwrap_or(true) {
426                continue;
427            }
428            let Some(value) = answer_map.get(&question.id) else {
429                anyhow::bail!(
430                    "missing required setup answer for {}.{}",
431                    provider.provider_id,
432                    question.id
433                );
434            };
435            if !wizard::answer_satisfies_question(question, value) {
436                anyhow::bail!(
437                    "missing required setup answer for {}.{}",
438                    provider.provider_id,
439                    question.id
440                );
441            }
442        }
443    }
444    Ok(())
445}
446
447#[cfg(test)]
448mod tests {
449    use super::{resolve_setup_scope, resolve_setup_scope_with_bundle};
450    use crate::engine::LoadedAnswers;
451
452    #[test]
453    fn resolve_setup_scope_prefers_answers_when_cli_is_default() {
454        let loaded = LoadedAnswers {
455            tenant: Some("acme".to_string()),
456            team: Some("core".to_string()),
457            env: Some("prod".to_string()),
458            ..Default::default()
459        };
460        let resolved = resolve_setup_scope("demo".to_string(), None, "dev".to_string(), &loaded);
461        assert_eq!(resolved.0, "acme");
462        assert_eq!(resolved.1.as_deref(), Some("core"));
463        assert_eq!(resolved.2, "prod");
464    }
465
466    #[test]
467    fn resolve_setup_scope_keeps_explicit_cli_values() {
468        let loaded = LoadedAnswers {
469            tenant: Some("acme".to_string()),
470            team: Some("core".to_string()),
471            env: Some("prod".to_string()),
472            ..Default::default()
473        };
474        let resolved = resolve_setup_scope(
475            "sandbox".to_string(),
476            Some("ops".to_string()),
477            "staging".to_string(),
478            &loaded,
479        );
480        assert_eq!(resolved.0, "sandbox");
481        assert_eq!(resolved.1.as_deref(), Some("ops"));
482        assert_eq!(resolved.2, "staging");
483    }
484
485    #[test]
486    fn bundle_detection_does_not_override_answers_tenant_demo() {
487        let temp = tempfile::tempdir().expect("tempdir");
488        std::fs::create_dir_all(temp.path().join("tenants").join("default")).expect("tenant dir");
489
490        let loaded = LoadedAnswers {
491            tenant: Some("demo".to_string()),
492            ..Default::default()
493        };
494
495        let (tenant, team, env) = resolve_setup_scope_with_bundle(
496            "demo".to_string(),
497            None,
498            "dev".to_string(),
499            &loaded,
500            temp.path(),
501        );
502
503        assert_eq!(tenant, "demo");
504        assert_eq!(team, None);
505        assert_eq!(env, "dev");
506    }
507}