greentic_setup/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 && let Some(detected) = detect_tenant_from_bundle(bundle_dir)
76 {
77 tenant = detected;
78 }
79
80 (tenant, team, env)
81}
82
83fn detect_tenant_from_bundle(bundle_dir: &std::path::Path) -> Option<String> {
87 let tenants_dir = bundle_dir.join("tenants");
88 let entries: Vec<String> = std::fs::read_dir(&tenants_dir)
89 .ok()?
90 .filter_map(|e| e.ok())
91 .filter(|e| e.path().is_dir())
92 .filter_map(|e| e.file_name().into_string().ok())
93 .collect();
94
95 match entries.len() {
96 0 => None,
97 1 => Some(entries[0].clone()),
98 _ => {
99 entries
101 .iter()
102 .find(|t| t.as_str() != "demo")
103 .cloned()
104 .or_else(|| entries.first().cloned())
105 }
106 }
107}
108
109pub fn run_interactive_wizard(
111 bundle_path: &Path,
112 tenant: &str,
113 team: Option<&str>,
114 env: &str,
115 advanced: bool,
116) -> Result<LoadedAnswers> {
117 use serde_json::Value;
118
119 let mut all_answers = serde_json::Map::new();
120 let existing_static_routes = load_effective_static_routes_defaults(bundle_path, tenant, team)?;
121 let static_routes = prompt_static_routes_policy(env, existing_static_routes.as_ref())?;
122 let deployment_targets = crate::deployment_targets::prompt_deployment_targets(
123 &crate::deployment_targets::discover_deployer_pack_candidates(bundle_path)?,
124 )?;
125
126 let discovered = discovery::discover(bundle_path)?;
127
128 if discovered.providers.is_empty() {
129 println!("No providers found in bundle. Nothing to configure.");
130 return Ok(LoadedAnswers {
131 tenant: None,
132 team: None,
133 env: None,
134 platform_setup: PlatformSetupAnswers {
135 static_routes: Some(static_routes.to_answers()),
136 deployment_targets,
137 },
138 setup_answers: all_answers,
139 });
140 }
141
142 println!(
143 "Found {} provider(s) to configure:",
144 discovered.providers.len()
145 );
146 for provider in &discovered.providers {
147 println!(" - {} ({})", provider.provider_id, provider.domain);
148 }
149 println!();
150
151 let provider_form_specs: Vec<wizard::ProviderFormSpec> = discovered
154 .providers
155 .iter()
156 .filter_map(|provider| {
157 setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id).map(
158 |form_spec| wizard::ProviderFormSpec {
159 provider_id: provider.provider_id.clone(),
160 form_spec,
161 },
162 )
163 })
164 .collect();
165
166 let shared_answers = if provider_form_specs.len() > 1 {
169 let shared_result = wizard::collect_shared_questions(&provider_form_specs);
170 if !shared_result.shared_questions.is_empty() {
171 let empty = Value::Object(serde_json::Map::new());
172 wizard::prompt_shared_questions(&shared_result, advanced, &empty)?
173 } else {
174 Value::Object(serde_json::Map::new())
175 }
176 } else {
177 Value::Object(serde_json::Map::new())
178 };
179
180 for provider in &discovered.providers {
182 let provider_id = &provider.provider_id;
183 let form_spec = setup_to_formspec::pack_to_form_spec(&provider.pack_path, provider_id);
184
185 if let Some(spec) = form_spec {
186 if spec.questions.is_empty() {
187 println!("Provider {}: No configuration required.", provider_id);
188 all_answers.insert(provider_id.clone(), Value::Object(serde_json::Map::new()));
189 continue;
190 }
191
192 let answers = wizard::prompt_form_spec_answers_with_existing(
194 &spec,
195 provider_id,
196 advanced,
197 &shared_answers,
198 )?;
199 all_answers.insert(provider_id.clone(), answers);
200 } else {
201 println!(
202 "Provider {}: No setup questions found (may use flow-based setup).",
203 provider_id
204 );
205 all_answers.insert(provider_id.clone(), Value::Object(serde_json::Map::new()));
206 }
207
208 println!();
209 }
210
211 Ok(LoadedAnswers {
212 tenant: None,
213 team: None,
214 env: None,
215 platform_setup: PlatformSetupAnswers {
216 static_routes: Some(static_routes.to_answers()),
217 deployment_targets,
218 },
219 setup_answers: all_answers,
220 })
221}
222
223pub fn complete_loaded_answers_with_prompts(
225 bundle_path: &Path,
226 tenant: &str,
227 team: Option<&str>,
228 env: &str,
229 advanced: bool,
230 mut loaded: LoadedAnswers,
231) -> Result<LoadedAnswers> {
232 let existing_static_routes = load_effective_static_routes_defaults(bundle_path, tenant, team)?;
233 let static_routes_need_prompt = match loaded.platform_setup.static_routes.as_ref() {
234 None => true,
235 Some(answers) => StaticRoutesPolicy::normalize(Some(answers), env).is_err(),
236 };
237 if static_routes_need_prompt {
238 let static_routes =
239 if let Some(current_answers) = loaded.platform_setup.static_routes.as_ref() {
240 prompt_static_routes_policy_with_answers(
241 env,
242 Some(current_answers),
243 existing_static_routes.as_ref(),
244 )?
245 } else {
246 prompt_static_routes_policy(env, existing_static_routes.as_ref())?
247 };
248 loaded.platform_setup.static_routes = Some(static_routes.to_answers());
249 }
250 if loaded.platform_setup.deployment_targets.is_empty() {
251 loaded.platform_setup.deployment_targets =
252 crate::deployment_targets::prompt_deployment_targets(
253 &crate::deployment_targets::discover_deployer_pack_candidates(bundle_path)?,
254 )?;
255 }
256
257 let env_placeholders = collect_env_var_placeholders(&loaded);
259 if !env_placeholders.is_empty() {
260 let resolved_env_vars = confirm_env_var_placeholders(&env_placeholders)?;
261
262 if !resolved_env_vars.is_empty() {
264 apply_resolved_env_vars(&mut loaded, &resolved_env_vars);
265 }
266 }
267
268 let discovered = discovery::discover(bundle_path)?;
269
270 let all_provider_form_specs: Vec<wizard::ProviderFormSpec> = discovered
273 .providers
274 .iter()
275 .filter_map(|provider| {
276 setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id).map(
277 |form_spec| wizard::ProviderFormSpec {
278 provider_id: provider.provider_id.clone(),
279 form_spec,
280 },
281 )
282 })
283 .collect();
284
285 let mut existing_shared_values = serde_json::Map::new();
288 let shared_result = if all_provider_form_specs.len() > 1 {
289 let result = wizard::collect_shared_questions(&all_provider_form_specs);
290 for question in &result.shared_questions {
292 for (_provider_id, provider_answers) in &loaded.setup_answers {
293 if let Some(value) = provider_answers.get(&question.id) {
294 if !(value.is_null() || value.is_string() && value.as_str() == Some("")) {
296 existing_shared_values.insert(question.id.clone(), value.clone());
297 break;
298 }
299 }
300 }
301 }
302 Some(result)
303 } else {
304 None
305 };
306
307 let shared_answers = if let Some(ref result) = shared_result {
310 if !result.shared_questions.is_empty() {
311 let existing = serde_json::Value::Object(existing_shared_values);
312 wizard::prompt_shared_questions(result, advanced, &existing)?
313 } else {
314 serde_json::Value::Object(serde_json::Map::new())
315 }
316 } else {
317 serde_json::Value::Object(serde_json::Map::new())
318 };
319
320 for provider in &discovered.providers {
322 let provider_id = &provider.provider_id;
323 let existing = loaded
324 .setup_answers
325 .get(provider_id)
326 .cloned()
327 .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
328
329 let mut merged = existing.as_object().cloned().unwrap_or_default();
332 if let Some(shared_obj) = shared_answers.as_object() {
333 for (key, value) in shared_obj {
334 let is_non_empty =
336 !(value.is_null() || value.is_string() && value.as_str() == Some(""));
337 if is_non_empty {
338 merged.insert(key.clone(), value.clone());
339 }
340 }
341 }
342 let merged_value = serde_json::Value::Object(merged);
343
344 let form_spec = setup_to_formspec::pack_to_form_spec(&provider.pack_path, provider_id);
345 let completed = if let Some(spec) = form_spec {
346 if spec.questions.is_empty() {
347 existing
348 } else {
349 wizard::prompt_form_spec_answers_with_existing(
350 &spec,
351 provider_id,
352 advanced,
353 &merged_value,
354 )?
355 }
356 } else {
357 existing
358 };
359 loaded.setup_answers.insert(provider_id.clone(), completed);
360 }
361
362 Ok(loaded)
363}
364
365pub fn ensure_deployment_targets_present(bundle_path: &Path, loaded: &LoadedAnswers) -> Result<()> {
367 if !loaded.platform_setup.deployment_targets.is_empty() {
368 return Ok(());
369 }
370 let candidates = crate::deployment_targets::discover_deployer_pack_candidates(bundle_path)?;
371 if candidates.is_empty() {
372 return Ok(());
373 }
374 anyhow::bail!(
375 "bundle contains deployer packs ({}) but answers did not define platform_setup.deployment_targets",
376 candidates
377 .iter()
378 .map(|value| value.display().to_string())
379 .collect::<Vec<_>>()
380 .join(", ")
381 )
382}
383
384#[cfg(test)]
385mod tests {
386 use super::resolve_setup_scope;
387 use crate::engine::LoadedAnswers;
388
389 #[test]
390 fn resolve_setup_scope_prefers_answers_when_cli_is_default() {
391 let loaded = LoadedAnswers {
392 tenant: Some("acme".to_string()),
393 team: Some("core".to_string()),
394 env: Some("prod".to_string()),
395 ..Default::default()
396 };
397 let resolved = resolve_setup_scope("demo".to_string(), None, "dev".to_string(), &loaded);
398 assert_eq!(resolved.0, "acme");
399 assert_eq!(resolved.1.as_deref(), Some("core"));
400 assert_eq!(resolved.2, "prod");
401 }
402
403 #[test]
404 fn resolve_setup_scope_keeps_explicit_cli_values() {
405 let loaded = LoadedAnswers {
406 tenant: Some("acme".to_string()),
407 team: Some("core".to_string()),
408 env: Some("prod".to_string()),
409 ..Default::default()
410 };
411 let resolved = resolve_setup_scope(
412 "sandbox".to_string(),
413 Some("ops".to_string()),
414 "staging".to_string(),
415 &loaded,
416 );
417 assert_eq!(resolved.0, "sandbox");
418 assert_eq!(resolved.1.as_deref(), Some("ops"));
419 assert_eq!(resolved.2, "staging");
420 }
421}