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