greentic_setup_dev/cli_helpers/
mod.rs1mod 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
20pub 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
31pub 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
61pub 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 == "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
84fn 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 entries
102 .iter()
103 .find(|t| t.as_str() != "demo")
104 .cloned()
105 .or_else(|| entries.first().cloned())
106 }
107 }
108}
109
110pub 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 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 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 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 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 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
231pub 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 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 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 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 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 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 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 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 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 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 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 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
377pub 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}