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::deployment_targets::DeploymentTargetRecord;
14use crate::discovery;
15use crate::engine::LoadedAnswers;
16use crate::platform_setup::{
17    PlatformSetupAnswers, StaticRoutesPolicy, TunnelAnswers, load_effective_static_routes_defaults,
18    prompt_static_routes_policy, prompt_static_routes_policy_with_answers,
19};
20use crate::qa::wizard;
21use crate::setup_to_formspec;
22use crate::setup_tunnel::{SetupTunnel, inject_setup_public_base_url, should_start_setup_tunnel};
23
24// Re-export from submodules
25pub use bundle::{
26    SetupOutputTarget, copy_dir_recursive, detect_domain_from_filename, resolve_bundle_dir,
27    resolve_bundle_source, resolve_pack_source, setup_output_target,
28};
29pub use env_vars::{
30    EnvVarPlaceholder, apply_resolved_env_vars, collect_env_var_placeholders,
31    confirm_env_var_placeholders,
32};
33pub use prompts::{SetupParams, prompt_setup_params};
34
35/// Start a setup-time tunnel for non-UI setup and inject its public URL into
36/// provider answers that need `public_base_url`.
37///
38/// Persists a [`crate::platform_setup::TunnelHandoff`] recording the local
39/// port this tunnel targets, so a later `gtc start` can bind its gateway to
40/// the same port and adopt this same tunnel via the shared record instead of
41/// minting a disconnected second one on its own default/fallback port.
42pub fn maybe_start_cli_setup_tunnel(
43    bundle_root: &Path,
44    loaded: &mut LoadedAnswers,
45    local_base_url: &str,
46) -> Result<Option<SetupTunnel>> {
47    let mode = loaded
48        .platform_setup
49        .tunnel
50        .as_ref()
51        .and_then(|tunnel| tunnel.mode.as_deref())
52        .unwrap_or("off")
53        .to_string();
54    if !should_start_setup_tunnel(&mode, &loaded.setup_answers) {
55        return Ok(None);
56    }
57
58    let tunnel = crate::setup_tunnel::start_setup_tunnel(&mode, local_base_url)?;
59    inject_setup_public_base_url(&mut loaded.setup_answers, &tunnel.public_base_url);
60    persist_tunnel_handoff(bundle_root, &tunnel);
61    Ok(Some(tunnel))
62}
63
64/// Persist a [`crate::platform_setup::TunnelHandoff`] for `tunnel`, best
65/// effort — a write failure here should never fail setup itself, it only
66/// means `greentic-start` falls back to its own port selection.
67fn persist_tunnel_handoff(bundle_root: &Path, tunnel: &SetupTunnel) {
68    let Some(local_port) = crate::shared_tunnel::local_port_from_base_url(&tunnel.local_base_url)
69    else {
70        return;
71    };
72    let handoff = crate::platform_setup::TunnelHandoff {
73        service: tunnel.mode.clone(),
74        local_port,
75        public_base_url: tunnel.public_base_url.clone(),
76    };
77    if let Err(err) = crate::platform_setup::persist_tunnel_handoff_artifact(bundle_root, &handoff)
78    {
79        tracing::warn!("failed to persist setup tunnel handoff: {err:#}");
80    }
81}
82
83/// Resolve tenant/team/env for setup.
84///
85/// When CLI values are still defaults (`demo`, unset team, `dev`) and an answers
86/// file includes tenant/team/env metadata, prefer metadata values.
87/// Also detects tenant from existing bundle `tenants/` directory when neither
88/// CLI nor answers provide a tenant.
89pub fn resolve_setup_scope(
90    tenant: String,
91    team: Option<String>,
92    env: String,
93    loaded: &LoadedAnswers,
94) -> (String, Option<String>, String) {
95    let tenant = if tenant == "demo" {
96        loaded.tenant.clone().unwrap_or(tenant)
97    } else {
98        tenant
99    };
100    let team = if team.is_none() {
101        loaded.team.clone()
102    } else {
103        team
104    };
105    // Both the legacy default (`dev`) and the new A4b default (`local`)
106    // are treated as the "no explicit CLI value — use the bundle answers'
107    // env if any" sentinel. Explicit non-default values stay verbatim.
108    let env = if env == crate::LEGACY_ENV_ID || env == crate::DEFAULT_ENV_ID {
109        loaded.env.clone().unwrap_or(env)
110    } else {
111        env
112    };
113    (tenant, team, env)
114}
115
116/// Like [`resolve_setup_scope`] but also checks the bundle's `tenants/` directory
117/// for existing tenants when the CLI value is still the default.
118pub fn resolve_setup_scope_with_bundle(
119    tenant: String,
120    team: Option<String>,
121    env: String,
122    loaded: &LoadedAnswers,
123    bundle_dir: &std::path::Path,
124) -> (String, Option<String>, String) {
125    let (mut tenant, team, env) = resolve_setup_scope(tenant, team, env, loaded);
126
127    // If tenant is still the CLI default ("demo") and it did not come from the
128    // answers file, detect the actual tenant from existing directories.
129    if tenant == "demo"
130        && loaded.tenant.is_none()
131        && let Some(detected) = detect_tenant_from_bundle(bundle_dir)
132    {
133        tenant = detected;
134    }
135
136    (tenant, team, env)
137}
138
139/// Detect tenant from the bundle's `tenants/` directory.
140/// Returns the single tenant if exactly one exists, or the first non-"demo"
141/// tenant if multiple exist.
142fn detect_tenant_from_bundle(bundle_dir: &std::path::Path) -> Option<String> {
143    let tenants_dir = bundle_dir.join("tenants");
144    let entries: Vec<String> = std::fs::read_dir(&tenants_dir)
145        .ok()?
146        .filter_map(|e| e.ok())
147        .filter(|e| e.path().is_dir())
148        .filter_map(|e| e.file_name().into_string().ok())
149        .collect();
150
151    match entries.len() {
152        0 => None,
153        1 => Some(entries[0].clone()),
154        _ => {
155            // Multiple tenants — prefer non-"demo" if exists
156            entries
157                .iter()
158                .find(|t| t.as_str() != "demo")
159                .cloned()
160                .or_else(|| entries.first().cloned())
161        }
162    }
163}
164
165fn has_cloud_deployment_target(targets: &[DeploymentTargetRecord]) -> bool {
166    targets
167        .iter()
168        .any(|record| matches!(record.target.as_str(), "aws" | "gcp" | "azure"))
169}
170
171fn default_no_tunnel_answers() -> TunnelAnswers {
172    TunnelAnswers {
173        mode: Some("off".to_string()),
174    }
175}
176
177/// Run interactive wizard for all discovered packs in the bundle.
178pub fn run_interactive_wizard(
179    bundle_path: &Path,
180    tenant: &str,
181    team: Option<&str>,
182    env: &str,
183    advanced: bool,
184) -> Result<LoadedAnswers> {
185    use serde_json::Value;
186
187    let mut all_answers = serde_json::Map::new();
188    let existing_static_routes = load_effective_static_routes_defaults(bundle_path, tenant, team)?;
189    let static_routes = prompt_static_routes_policy(env, existing_static_routes.as_ref())?;
190    let deployer_candidates =
191        crate::deployment_targets::discover_deployer_pack_candidates(bundle_path)?;
192    let deployment_targets =
193        crate::deployment_targets::reconcile_deployment_targets(bundle_path, &[])?;
194
195    // Prompt for tunnel mode when no deployer packs are present (local dev).
196    let tunnel = if has_cloud_deployment_target(&deployment_targets) {
197        Some(default_no_tunnel_answers())
198    } else if deployer_candidates.is_empty() {
199        Some(crate::platform_setup::prompt_tunnel_mode(None)?)
200    } else {
201        None
202    };
203
204    let discovered = discovery::discover(bundle_path)?;
205    let setup_targets = discovered.setup_targets();
206
207    if setup_targets.is_empty() {
208        println!("No setup packs found in bundle. Nothing to configure.");
209        return Ok(LoadedAnswers {
210            tenant: None,
211            team: None,
212            env: None,
213            platform_setup: PlatformSetupAnswers {
214                static_routes: Some(static_routes.to_answers()),
215                deployment_targets,
216                tunnel,
217                telemetry: None,
218            },
219            setup_answers: all_answers,
220            providers: Vec::new(),
221        });
222    }
223
224    println!("Found {} pack(s) to configure:", setup_targets.len());
225    for provider in &setup_targets {
226        println!("  - {} ({})", provider.provider_id, provider.domain);
227    }
228    println!();
229
230    // ── Collect and prompt shared questions once ────────────────────────────
231    // Build FormSpecs for all providers to identify shared questions
232    let provider_form_specs: Vec<wizard::ProviderFormSpec> = setup_targets
233        .iter()
234        .filter_map(|provider| {
235            setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id).map(
236                |form_spec| wizard::ProviderFormSpec {
237                    provider_id: provider.provider_id.clone(),
238                    form_spec,
239                },
240            )
241        })
242        .collect();
243
244    // Prompt for shared questions (like public_base_url) once at the start
245    // In interactive mode, we have no existing answers so pass empty Value
246    let shared_answers = if provider_form_specs.len() > 1 {
247        let shared_result = wizard::collect_shared_questions(&provider_form_specs);
248        if !shared_result.shared_questions.is_empty() {
249            let empty = Value::Object(serde_json::Map::new());
250            wizard::prompt_shared_questions(&shared_result, advanced, &empty)?
251        } else {
252            Value::Object(serde_json::Map::new())
253        }
254    } else {
255        Value::Object(serde_json::Map::new())
256    };
257
258    // ── Configure each provider ─────────────────────────────────────────────
259    for provider in &setup_targets {
260        let provider_id = &provider.provider_id;
261        let form_spec = setup_to_formspec::pack_to_form_spec(&provider.pack_path, provider_id);
262
263        if let Some(spec) = form_spec {
264            if spec.questions.is_empty() {
265                println!("Provider {}: No configuration required.", provider_id);
266                all_answers.insert(provider_id.clone(), Value::Object(serde_json::Map::new()));
267                continue;
268            }
269
270            // Use shared answers as initial values - already-answered questions will be skipped.
271            // None: provider-setup flow keeps English prompt chrome.
272            let answers = wizard::prompt_form_spec_answers_with_existing(
273                &spec,
274                provider_id,
275                advanced,
276                &shared_answers,
277                None,
278            )?;
279            all_answers.insert(provider_id.clone(), answers);
280        } else {
281            println!(
282                "Provider {}: No setup questions found (may use flow-based setup).",
283                provider_id
284            );
285            all_answers.insert(provider_id.clone(), Value::Object(serde_json::Map::new()));
286        }
287
288        println!();
289    }
290
291    Ok(LoadedAnswers {
292        tenant: None,
293        team: None,
294        env: None,
295        platform_setup: PlatformSetupAnswers {
296            static_routes: Some(static_routes.to_answers()),
297            deployment_targets,
298            tunnel,
299            telemetry: None,
300        },
301        setup_answers: all_answers,
302        providers: Vec::new(),
303    })
304}
305
306/// Complete loaded answers by prompting for missing values.
307///
308/// When `non_interactive` is true, prompts are skipped so a missing
309/// `platform_setup` field doesn't deadlock automation runs on a hidden
310/// TTY prompt — the value is left for the runtime defaults (or for
311/// `ensure_required_setup_answers_present` to flag downstream).
312pub fn complete_loaded_answers_with_prompts(
313    bundle_path: &Path,
314    tenant: &str,
315    team: Option<&str>,
316    env: &str,
317    advanced: bool,
318    non_interactive: bool,
319    mut loaded: LoadedAnswers,
320) -> Result<LoadedAnswers> {
321    let existing_static_routes = load_effective_static_routes_defaults(bundle_path, tenant, team)?;
322    let static_routes_need_prompt = match loaded.platform_setup.static_routes.as_ref() {
323        None => true,
324        Some(answers) => StaticRoutesPolicy::normalize(Some(answers), env).is_err(),
325    };
326    if static_routes_need_prompt && !non_interactive {
327        let static_routes =
328            if let Some(current_answers) = loaded.platform_setup.static_routes.as_ref() {
329                prompt_static_routes_policy_with_answers(
330                    env,
331                    Some(current_answers),
332                    existing_static_routes.as_ref(),
333                )?
334            } else {
335                prompt_static_routes_policy(env, existing_static_routes.as_ref())?
336            };
337        loaded.platform_setup.static_routes = Some(static_routes.to_answers());
338    }
339    let deployer_candidates =
340        crate::deployment_targets::discover_deployer_pack_candidates(bundle_path)?;
341    loaded.platform_setup.deployment_targets =
342        crate::deployment_targets::reconcile_deployment_targets(
343            bundle_path,
344            &loaded.platform_setup.deployment_targets,
345        )?;
346    if has_cloud_deployment_target(&loaded.platform_setup.deployment_targets) {
347        loaded.platform_setup.tunnel = Some(default_no_tunnel_answers());
348    } else if deployer_candidates.is_empty()
349        && loaded.platform_setup.tunnel.is_none()
350        && !non_interactive
351    {
352        loaded.platform_setup.tunnel = Some(crate::platform_setup::prompt_tunnel_mode(None)?);
353    }
354
355    // ── Confirm environment variable placeholders ────────────────────────────
356    // Skip in non-interactive mode: leave any unresolved `${VAR}` placeholders
357    // in place. `answer_satisfies_question` accepts placeholder strings as
358    // valid runtime-resolved values, and `ensure_required_setup_answers_present`
359    // will fail-fast downstream if anything truly required is missing.
360    if !non_interactive {
361        let env_placeholders = collect_env_var_placeholders(&loaded);
362        if !env_placeholders.is_empty() {
363            let resolved_env_vars = confirm_env_var_placeholders(&env_placeholders)?;
364
365            // Apply resolved env vars to the loaded answers
366            if !resolved_env_vars.is_empty() {
367                apply_resolved_env_vars(&mut loaded, &resolved_env_vars);
368            }
369        }
370    }
371
372    let discovered = discovery::discover(bundle_path)?;
373    let setup_targets = discovered.setup_targets();
374
375    // ── Collect and prompt shared questions once ────────────────────────────
376    // Build FormSpecs for ALL providers to identify shared questions
377    let all_provider_form_specs: Vec<wizard::ProviderFormSpec> = setup_targets
378        .iter()
379        .filter(|provider| {
380            loaded
381                .setup_answers
382                .get(&provider.provider_id)
383                .map(crate::provider_state::provider_enabled)
384                .unwrap_or(true)
385        })
386        .filter_map(|provider| {
387            setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id).map(
388                |form_spec| wizard::ProviderFormSpec {
389                    provider_id: provider.provider_id.clone(),
390                    form_spec,
391                },
392            )
393        })
394        .collect();
395
396    // Extract existing shared values from loaded answers
397    // Look for values across all providers that might have shared questions
398    let mut existing_shared_values = serde_json::Map::new();
399    let shared_result = if all_provider_form_specs.len() > 1 {
400        let result = wizard::collect_shared_questions(&all_provider_form_specs);
401        // Find existing values for shared questions from any provider
402        for question in &result.shared_questions {
403            for (_provider_id, provider_answers) in &loaded.setup_answers {
404                if let Some(value) = provider_answers.get(&question.id) {
405                    // Use first non-empty value found
406                    if !(value.is_null() || value.is_string() && value.as_str() == Some("")) {
407                        existing_shared_values.insert(question.id.clone(), value.clone());
408                        break;
409                    }
410                }
411            }
412        }
413        Some(result)
414    } else {
415        None
416    };
417
418    // Prompt for shared questions (like public_base_url) once at the start
419    // Pass existing values so already-answered questions are skipped.
420    // Skip entirely in non-interactive mode: the loaded answers file is
421    // expected to provide everything required, and the engine's
422    // `ensure_required_setup_answers_present()` will fail-fast downstream
423    // if anything required is missing.
424    let shared_answers = if !non_interactive {
425        if let Some(ref result) = shared_result {
426            if !result.shared_questions.is_empty() {
427                let existing = serde_json::Value::Object(existing_shared_values);
428                wizard::prompt_shared_questions(result, advanced, &existing)?
429            } else {
430                serde_json::Value::Object(serde_json::Map::new())
431            }
432        } else {
433            serde_json::Value::Object(serde_json::Map::new())
434        }
435    } else {
436        serde_json::Value::Object(serde_json::Map::new())
437    };
438
439    // ── Complete answers for each provider ──────────────────────────────────
440    for provider in &setup_targets {
441        let provider_id = &provider.provider_id;
442        let existing = loaded
443            .setup_answers
444            .get(provider_id)
445            .cloned()
446            .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
447        if !crate::provider_state::provider_enabled(&existing) {
448            loaded.setup_answers.insert(provider_id.clone(), existing);
449            continue;
450        }
451
452        // In non-interactive mode, never prompt. Preserve whatever was loaded
453        // from the answers file as-is; downstream validation fails fast on
454        // missing required fields.
455        if non_interactive {
456            loaded.setup_answers.insert(provider_id.clone(), existing);
457            continue;
458        }
459
460        // Merge shared answers with existing answers.
461        // Shared answers (user just entered) take precedence over existing values.
462        let mut merged = existing.as_object().cloned().unwrap_or_default();
463        if let Some(shared_obj) = shared_answers.as_object() {
464            for (key, value) in shared_obj {
465                // Only apply shared answer if it's non-empty
466                let is_non_empty =
467                    !(value.is_null() || value.is_string() && value.as_str() == Some(""));
468                if is_non_empty {
469                    merged.insert(key.clone(), value.clone());
470                }
471            }
472        }
473        let merged_value = serde_json::Value::Object(merged);
474
475        let form_spec = setup_to_formspec::pack_to_form_spec(&provider.pack_path, provider_id);
476        let completed = if let Some(spec) = form_spec {
477            if spec.questions.is_empty() {
478                existing
479            } else {
480                wizard::prompt_form_spec_answers_with_existing(
481                    &spec,
482                    provider_id,
483                    advanced,
484                    &merged_value,
485                    None,
486                )?
487            }
488        } else {
489            existing
490        };
491        loaded.setup_answers.insert(provider_id.clone(), completed);
492    }
493
494    Ok(loaded)
495}
496
497/// Ensure deployment targets are present if bundle has deployer packs.
498pub fn ensure_deployment_targets_present(bundle_path: &Path, loaded: &LoadedAnswers) -> Result<()> {
499    let _ = crate::deployment_targets::reconcile_deployment_targets(
500        bundle_path,
501        &loaded.platform_setup.deployment_targets,
502    )?;
503    Ok(())
504}
505
506/// Ensure loaded answers satisfy all visible required setup questions.
507pub fn ensure_required_setup_answers_present(
508    bundle_path: &Path,
509    loaded: &LoadedAnswers,
510) -> Result<()> {
511    let discovered = discovery::discover(bundle_path)?;
512    for provider in discovered.setup_targets() {
513        let Some(spec) =
514            setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id)
515        else {
516            continue;
517        };
518        if spec.questions.is_empty() {
519            continue;
520        }
521
522        let answers = loaded
523            .setup_answers
524            .get(&provider.provider_id)
525            .cloned()
526            .unwrap_or_else(|| Value::Object(Default::default()));
527        if !crate::provider_state::provider_enabled(&answers) {
528            continue;
529        }
530        let answer_map = answers.as_object().ok_or_else(|| {
531            anyhow::anyhow!("answers for {} must be an object", provider.provider_id)
532        })?;
533        let visibility = resolve_visibility(&spec, &answers, VisibilityMode::Visible);
534
535        for question in spec.questions.iter().filter(|question| question.required) {
536            if !visibility.get(&question.id).copied().unwrap_or(true) {
537                continue;
538            }
539            let Some(value) = answer_map.get(&question.id) else {
540                anyhow::bail!(
541                    "missing required setup answer for {}.{}",
542                    provider.provider_id,
543                    question.id
544                );
545            };
546            if !wizard::answer_satisfies_question(question, value) {
547                anyhow::bail!(
548                    "missing required setup answer for {}.{}",
549                    provider.provider_id,
550                    question.id
551                );
552            }
553        }
554    }
555    Ok(())
556}
557
558#[cfg(test)]
559mod tests {
560    use super::{
561        default_no_tunnel_answers, has_cloud_deployment_target, resolve_setup_scope,
562        resolve_setup_scope_with_bundle,
563    };
564    use crate::deployment_targets::DeploymentTargetRecord;
565    use crate::engine::LoadedAnswers;
566
567    #[test]
568    fn resolve_setup_scope_prefers_answers_when_cli_is_default() {
569        let loaded = LoadedAnswers {
570            tenant: Some("acme".to_string()),
571            team: Some("core".to_string()),
572            env: Some("prod".to_string()),
573            ..Default::default()
574        };
575        let resolved = resolve_setup_scope("demo".to_string(), None, "dev".to_string(), &loaded);
576        assert_eq!(resolved.0, "acme");
577        assert_eq!(resolved.1.as_deref(), Some("core"));
578        assert_eq!(resolved.2, "prod");
579    }
580
581    #[test]
582    fn resolve_setup_scope_keeps_explicit_cli_values() {
583        let loaded = LoadedAnswers {
584            tenant: Some("acme".to_string()),
585            team: Some("core".to_string()),
586            env: Some("prod".to_string()),
587            ..Default::default()
588        };
589        let resolved = resolve_setup_scope(
590            "sandbox".to_string(),
591            Some("ops".to_string()),
592            "staging".to_string(),
593            &loaded,
594        );
595        assert_eq!(resolved.0, "sandbox");
596        assert_eq!(resolved.1.as_deref(), Some("ops"));
597        assert_eq!(resolved.2, "staging");
598    }
599
600    #[test]
601    fn bundle_detection_does_not_override_answers_tenant_demo() {
602        let temp = tempfile::tempdir().expect("tempdir");
603        std::fs::create_dir_all(temp.path().join("tenants").join("default")).expect("tenant dir");
604
605        let loaded = LoadedAnswers {
606            tenant: Some("demo".to_string()),
607            ..Default::default()
608        };
609
610        let (tenant, team, env) = resolve_setup_scope_with_bundle(
611            "demo".to_string(),
612            None,
613            "dev".to_string(),
614            &loaded,
615            temp.path(),
616        );
617
618        assert_eq!(tenant, "demo");
619        assert_eq!(team, None);
620        assert_eq!(env, "dev");
621    }
622
623    #[test]
624    fn resolve_setup_scope_treats_local_default_like_dev_default() {
625        // A4b widening: both `dev` (legacy default) and `local` (new
626        // default) act as "no explicit CLI value — use loaded.env if any".
627        let loaded = crate::engine::LoadedAnswers {
628            tenant: Some("acme".to_string()),
629            team: None,
630            env: Some("prod".to_string()),
631            ..Default::default()
632        };
633        let resolved = resolve_setup_scope("demo".to_string(), None, "local".to_string(), &loaded);
634        assert_eq!(resolved.2, "prod");
635    }
636
637    #[test]
638    fn resolve_setup_scope_local_default_with_no_loaded_env_stays_local() {
639        let loaded = crate::engine::LoadedAnswers::default();
640        let resolved = resolve_setup_scope("demo".to_string(), None, "local".to_string(), &loaded);
641        assert_eq!(resolved.2, "local");
642    }
643
644    #[test]
645    fn detects_cloud_deployment_targets() {
646        assert!(has_cloud_deployment_target(&[
647            DeploymentTargetRecord {
648                target: "aws".to_string(),
649                provider_pack: None,
650                default: None,
651            },
652            DeploymentTargetRecord {
653                target: "runtime".to_string(),
654                provider_pack: None,
655                default: None,
656            },
657        ]));
658        assert!(!has_cloud_deployment_target(&[
659            DeploymentTargetRecord {
660                target: "runtime".to_string(),
661                provider_pack: None,
662                default: None,
663            },
664            DeploymentTargetRecord {
665                target: "single-vm".to_string(),
666                provider_pack: None,
667                default: None,
668            },
669        ]));
670    }
671
672    #[test]
673    fn default_no_tunnel_answers_for_cloud_sets_off_mode() {
674        assert_eq!(default_no_tunnel_answers().mode.as_deref(), Some("off"));
675    }
676}