1use std::collections::{BTreeMap, BTreeSet};
39use std::io::{IsTerminal, Write};
40use std::path::{Path, PathBuf};
41
42use anyhow::{Context, Result, bail};
43use greentic_deployer::cli::env_manifest::{
44 ENV_MANIFEST_FORM_ID, ENV_MANIFEST_FORM_VERSION, EnvManifest, ManifestBundle,
45 TrustRootDirective, answers_to_manifest, manifest_form_spec_for_env,
46};
47use greentic_deployer::runtime_secrets::{
48 SecretValue, bundle_secret_requirements, manifest_secret_path,
49};
50use qa_spec::{AnswerSet, FormSpec, QuestionSpec};
51use rpassword::prompt_password;
52use serde_json::{Map as JsonMap, Value, json};
53
54use crate::cli_i18n::CliI18n;
55use crate::env_mode;
56use crate::qa::prompts::prompt_form_spec_answers_with_existing;
57
58pub fn run_env_wizard(
63 env: &str,
64 advanced: bool,
65 dry_run: bool,
66 non_interactive: bool,
67 i18n: &CliI18n,
68) -> Result<()> {
69 if non_interactive || !std::io::stdin().is_terminal() {
70 bail!(
71 "the environment wizard is interactive; in headless runs pass an env manifest via \
72 --answers <file> (generate a skeleton with `gtc op env apply --emit-answers-template`)"
73 );
74 }
75 let manifest_path = prompt_manifest_path(env, i18n)?;
76 let initial = load_initial_answers(&manifest_path, env)?;
77
78 let spec = manifest_form_spec_for_env(env);
87 let spec = spec_for_mode(&spec, advanced);
90 let form_spec = spec_without_question(&spec, "secrets");
91 let form_spec = localize_spec(form_spec, i18n);
96 if !advanced {
97 println!(
98 "\n{}",
99 i18n.t_or(
100 "env_wizard.basic_mode",
101 "Basic mode — pass --advanced to also set customer id, config \
102 overrides, route hosts, welcome flow, and endpoint secret refs.",
103 )
104 );
105 }
106 let prompted = prompt_form_spec_answers_with_existing(
107 &form_spec,
108 "environment",
109 advanced,
110 &Value::Object(initial),
111 Some(i18n),
112 )?;
113 let mut answers = prompted.as_object().cloned().unwrap_or_default();
114
115 let existing_from_env = existing_from_env_by_path(&answers);
118 let existing_source = existing_source_by_path(&answers);
120
121 let manifest_dir = manifest_path
124 .parent()
125 .filter(|parent| !parent.as_os_str().is_empty())
126 .map(Path::to_path_buf)
127 .unwrap_or_else(|| PathBuf::from("."));
128
129 let provisional = answers_to_manifest(&answer_set(answers.clone()))?;
133 let (secret_rows, prefilled_secrets) = derive_and_prompt_secrets(
134 &manifest_dir,
135 env,
136 &provisional.bundles,
137 &existing_from_env,
138 &existing_source,
139 i18n,
140 )?;
141 if secret_rows.is_empty() {
142 answers.remove("secrets");
143 } else {
144 answers.insert("secrets".to_string(), Value::Array(secret_rows));
145 }
146
147 let manifest = answers_to_manifest(&answer_set(answers))?;
148
149 let doc = serde_json::to_value(&manifest)?;
150 let mut rendered = serde_json::to_string_pretty(&doc)?;
151 rendered.push('\n');
152 std::fs::write(&manifest_path, rendered)
153 .with_context(|| format!("failed to write `{}`", manifest_path.display()))?;
154 println!(
155 "\n{}",
156 i18n.tf_or(
157 "env_wizard.wrote_manifest",
158 "Wrote `{}` — the manifest is the durable artifact; keep it in version control.",
159 &[&manifest_path.display().to_string()],
160 )
161 );
162
163 if dry_run && !prefilled_secrets.is_empty() {
169 println!(
170 "\n{}",
171 i18n.tf_or(
172 "env_wizard.dry_run_secrets_note",
173 "Note: --dry-run previews only — the {} pasted secret value(s) you entered are \
174 NOT written to the store. Re-run without --dry-run and confirm the plan to \
175 persist them.",
176 &[&prefilled_secrets.len().to_string()],
177 )
178 );
179 }
180
181 env_mode::run_env_apply(&manifest_path, &doc, env, dry_run, false, prefilled_secrets)
182}
183
184fn answer_set(answers: JsonMap<String, Value>) -> AnswerSet {
187 AnswerSet {
188 form_id: ENV_MANIFEST_FORM_ID.to_string(),
189 spec_version: ENV_MANIFEST_FORM_VERSION.to_string(),
190 answers: Value::Object(answers),
191 meta: None,
192 }
193}
194
195fn spec_without_question(spec: &FormSpec, id: &str) -> FormSpec {
199 let mut reduced = spec.clone();
200 reduced.questions.retain(|question| question.id != id);
201 reduced
202}
203
204fn localize_spec(mut spec: FormSpec, i18n: &CliI18n) -> FormSpec {
216 spec.title = i18n.t_or("env_wizard.form.title", &spec.title);
217 if let Some(desc) = spec.description.take() {
218 spec.description = Some(i18n.t_or("env_wizard.form.desc", &desc));
219 }
220 for question in &mut spec.questions {
221 localize_question(question, i18n);
222 }
223 spec
224}
225
226fn localize_question(question: &mut QuestionSpec, i18n: &CliI18n) {
229 question.title = i18n.t_or(
230 &format!("env_wizard.q.{}.title", question.id),
231 &question.title,
232 );
233 if let Some(desc) = question.description.take() {
234 question.description =
235 Some(i18n.t_or(&format!("env_wizard.q.{}.desc", question.id), &desc));
236 }
237 if let Some(list) = question.list.as_mut() {
238 if let Some(label) = list.item_label.take() {
239 list.item_label = Some(i18n.t_or(
240 &format!("env_wizard.list.{}.item_label", question.id),
241 &label,
242 ));
243 }
244 for field in &mut list.fields {
245 localize_question(field, i18n);
246 }
247 }
248}
249
250const ADVANCED_LIST_COLUMNS: &[(&str, &[&str])] = &[
256 (
257 "bundles",
258 &["customer_id", "config_overrides", "route_hosts"],
259 ),
260 (
261 "messaging_endpoints",
262 &[
263 "welcome_bundle_id",
264 "welcome_pack_id",
265 "welcome_flow_id",
266 "secret_refs",
267 ],
268 ),
269];
270
271fn spec_for_mode(spec: &FormSpec, advanced: bool) -> FormSpec {
277 if advanced {
278 return spec.clone();
279 }
280 let mut reduced = spec.clone();
281 for question in &mut reduced.questions {
282 let Some(hidden) = ADVANCED_LIST_COLUMNS
283 .iter()
284 .find(|(id, _)| *id == question.id)
285 .map(|(_, columns)| *columns)
286 else {
287 continue;
288 };
289 if let Some(list) = question.list.as_mut() {
290 list.fields
291 .retain(|field| !hidden.contains(&field.id.as_str()));
292 }
293 }
294 reduced
295}
296
297fn existing_from_env_by_path(answers: &JsonMap<String, Value>) -> BTreeMap<String, String> {
300 let mut map = BTreeMap::new();
301 if let Some(Value::Array(rows)) = answers.get("secrets") {
302 for row in rows {
303 if let (Some(path), Some(from_env)) = (
304 row.get("path").and_then(Value::as_str),
305 row.get("from_env").and_then(Value::as_str),
306 ) {
307 map.insert(path.to_string(), from_env.to_string());
308 }
309 }
310 }
311 map
312}
313
314fn existing_source_by_path(answers: &JsonMap<String, Value>) -> BTreeMap<String, String> {
319 let mut map = BTreeMap::new();
320 if let Some(Value::Array(rows)) = answers.get("secrets") {
321 for row in rows {
322 let Some(path) = row.get("path").and_then(Value::as_str) else {
323 continue;
324 };
325 let source = row.get("source").and_then(Value::as_str).unwrap_or(
326 if row.get("from_env").is_some() {
327 "env"
328 } else {
329 "paste"
330 },
331 );
332 map.insert(path.to_string(), source.to_string());
333 }
334 }
335 map
336}
337
338fn bundle_tenant(bundle: &ManifestBundle) -> String {
341 bundle
342 .route_binding
343 .as_ref()
344 .and_then(|binding| binding.tenant_selector.as_ref())
345 .map(|selector| selector.tenant.clone())
346 .unwrap_or_else(|| "default".to_string())
347}
348
349fn default_env_var_name(tenant: &str, key: &str) -> String {
353 fn sanitize(s: &str) -> String {
356 s.chars()
357 .map(|c| {
358 if c.is_ascii_alphanumeric() {
359 c.to_ascii_uppercase()
360 } else {
361 '_'
362 }
363 })
364 .collect()
365 }
366 let key = sanitize(key);
367 if tenant.is_empty() || tenant.eq_ignore_ascii_case("default") {
368 key
369 } else {
370 format!("{}_{}", sanitize(tenant), key)
371 }
372}
373
374struct DerivedSecret {
377 path: String,
379 provider_id: String,
381 key: String,
383 tenant: String,
385 required: bool,
387 bundle_ids: Vec<String>,
389}
390
391fn derive_required_secrets(
398 manifest_dir: &Path,
399 env: &str,
400 bundles: &[ManifestBundle],
401) -> (Vec<DerivedSecret>, bool) {
402 let mut order: Vec<String> = Vec::new();
403 let mut by_path: BTreeMap<String, DerivedSecret> = BTreeMap::new();
404 let mut skipped = false;
405
406 for bundle in bundles {
407 let tenant = bundle_tenant(bundle);
408 let Some(raw) = bundle.bundle_path.as_ref().or_else(|| {
413 bundle
414 .revisions
415 .as_ref()
416 .and_then(|revs| revs.first())
417 .map(|rev| &rev.bundle_path)
418 }) else {
419 skipped = true;
420 continue;
421 };
422 let artifact = if raw.is_absolute() {
423 raw.clone()
424 } else {
425 manifest_dir.join(raw)
426 };
427 let Some(bundle_root) = artifact.parent() else {
428 skipped = true;
429 continue;
430 };
431 if !artifact.exists() {
432 eprintln!(
433 " note: bundle `{}` artifact `{}` not found — build it before the \
434 wizard to auto-detect its secrets (skipping)",
435 bundle.bundle_id,
436 artifact.display()
437 );
438 skipped = true;
439 continue;
440 }
441 let requirements = match bundle_secret_requirements(bundle_root, env, &tenant) {
442 Ok(requirements) => requirements,
443 Err(err) => {
444 eprintln!(
445 " note: could not read secrets for bundle `{}`: {err} (skipping)",
446 bundle.bundle_id
447 );
448 skipped = true;
449 continue;
450 }
451 };
452 for requirement in requirements {
453 let Some(path) = manifest_secret_path(&requirement.uri, env) else {
454 continue;
455 };
456 match by_path.get_mut(&path) {
457 Some(existing) => {
458 existing.required |= requirement.required;
459 existing.bundle_ids.push(bundle.bundle_id.clone());
460 }
461 None => {
462 order.push(path.clone());
463 by_path.insert(
464 path.clone(),
465 DerivedSecret {
466 path,
467 provider_id: requirement.provider_id,
468 key: requirement.key,
469 tenant: tenant.clone(),
470 required: requirement.required,
471 bundle_ids: vec![bundle.bundle_id.clone()],
472 },
473 );
474 }
475 }
476 }
477 }
478
479 let derived = order
480 .into_iter()
481 .map(|path| by_path.remove(&path).expect("path was just inserted"))
482 .collect();
483 (derived, skipped)
484}
485
486fn derive_and_prompt_secrets(
496 manifest_dir: &Path,
497 env: &str,
498 bundles: &[ManifestBundle],
499 existing_from_env: &BTreeMap<String, String>,
500 existing_source: &BTreeMap<String, String>,
501 i18n: &CliI18n,
502) -> Result<(Vec<Value>, BTreeMap<String, SecretValue>)> {
503 let (derived, skipped) = derive_required_secrets(manifest_dir, env, bundles);
504
505 let preserving = skipped && !existing_source.is_empty();
508 if derived.is_empty() && !preserving {
509 println!(
510 "\n{}",
511 i18n.t_or(
512 "env_wizard.secrets.none",
513 "Secrets — the configured bundles declare no secrets; nothing to enter.",
514 )
515 );
516 return Ok((Vec::new(), BTreeMap::new()));
517 }
518
519 if !derived.is_empty() {
520 println!(
521 "\n{}",
522 i18n.tf_or(
523 "env_wizard.secrets.need",
524 "Secrets — the configured bundles need {} secret(s).",
525 &[&derived.len().to_string()],
526 )
527 );
528 println!(
529 "{}",
530 i18n.t_or(
531 "env_wizard.secrets.choose",
532 "For each, choose where the value comes from: a named environment\n\
533 variable, or paste it in now. Pasted values are stored in the\n\
534 environment's secrets store — never written to the manifest.",
535 )
536 );
537 }
538
539 let mut rows = Vec::with_capacity(derived.len());
540 let mut prefilled = BTreeMap::new();
541 let mut taken = BTreeSet::new();
542 for secret in &derived {
543 println!();
544 let optional_suffix = if secret.required {
545 String::new()
546 } else {
547 i18n.t_or("env_wizard.secrets.optional_suffix", " [optional]")
548 };
549 println!(
550 " {}",
551 i18n.tf_or(
552 "env_wizard.secrets.entry",
553 "{} — {} (bundle: {}){}",
554 &[
555 &secret.key,
556 &secret.provider_id,
557 &secret.bundle_ids.join(", "),
558 &optional_suffix,
559 ],
560 )
561 );
562 println!(
563 " {}",
564 i18n.tf_or(
565 "env_wizard.secrets.path",
566 "secret path: {}",
567 &[&secret.path]
568 )
569 );
570
571 let was_paste = existing_source.get(&secret.path).map(String::as_str) == Some("paste");
573 match prompt_secret_source(was_paste, i18n)? {
574 SecretSource::Env => {
575 let default = existing_from_env
576 .get(&secret.path)
577 .cloned()
578 .unwrap_or_else(|| default_env_var_name(&secret.tenant, &secret.key));
579 let from_env = prompt_env_var_name(&default, i18n)?;
580 rows.push(
581 json!({ "path": secret.path.clone(), "source": "env", "from_env": from_env }),
582 );
583 }
584 SecretSource::Paste => {
585 if let Some(value) = prompt_paste_value(was_paste, i18n)? {
589 prefilled.insert(secret.path.clone(), SecretValue::from(value));
590 }
591 rows.push(json!({ "path": secret.path.clone(), "source": "paste" }));
592 }
593 }
594 taken.insert(secret.path.clone());
595 }
596
597 if skipped {
602 for (path, source) in existing_source {
603 if taken.contains(path.as_str()) {
604 continue;
605 }
606 match source.as_str() {
607 "paste" => {
608 eprintln!(
609 " {}",
610 i18n.tf_or(
611 "env_wizard.secrets.keep_paste_note",
612 "note: keeping existing pasted secret `{}` (bundle not rebuilt)",
613 &[path],
614 )
615 );
616 rows.push(json!({ "path": path, "source": "paste" }));
617 }
618 _ => {
619 let Some(from_env) = existing_from_env.get(path) else {
620 continue;
621 };
622 eprintln!(
623 " {}",
624 i18n.tf_or(
625 "env_wizard.secrets.keep_env_note",
626 "note: keeping existing secret `{}` (bundle not rebuilt)",
627 &[path],
628 )
629 );
630 rows.push(json!({ "path": path, "source": "env", "from_env": from_env }));
631 }
632 }
633 }
634 }
635
636 Ok((rows, prefilled))
637}
638
639enum SecretSource {
641 Env,
643 Paste,
645}
646
647fn prompt_secret_source(default_paste: bool, i18n: &CliI18n) -> Result<SecretSource> {
651 let default = if default_paste { "2" } else { "1" };
652 loop {
653 print!(
656 " > {}",
657 i18n.tf_or(
658 "env_wizard.secrets.source_prompt",
659 "value from [1] environment variable or [2] paste it now? [{}]: ",
660 &[default],
661 )
662 );
663 std::io::stdout().flush()?;
664 let mut line = String::new();
665 let n = std::io::stdin().read_line(&mut line)?;
666 if n == 0 {
667 bail!("unexpected end of input while choosing a secret source");
668 }
669 let trimmed = line.trim();
670 let choice = if trimmed.is_empty() { default } else { trimmed };
671 match choice.to_ascii_lowercase().as_str() {
672 "1" | "env" | "e" => return Ok(SecretSource::Env),
673 "2" | "paste" | "p" => return Ok(SecretSource::Paste),
674 _ => println!(
675 " {}",
676 i18n.t_or(
677 "env_wizard.secrets.source_invalid",
678 "Enter 1 (environment variable) or 2 (paste).",
679 )
680 ),
681 }
682 }
683}
684
685fn prompt_paste_value(keep_stored: bool, i18n: &CliI18n) -> Result<Option<String>> {
695 loop {
696 let prompt = if keep_stored {
697 format!(
698 " > {}",
699 i18n.t_or(
700 "env_wizard.secrets.paste_prompt_keep",
701 "paste value (hidden, single line; empty keeps the stored value): ",
702 )
703 )
704 } else {
705 format!(
706 " > {}",
707 i18n.t_or(
708 "env_wizard.secrets.paste_prompt",
709 "paste value (hidden, single line): ",
710 )
711 )
712 };
713 let value = prompt_password(&prompt)?;
714 if value.is_empty() {
715 if keep_stored {
716 return Ok(None);
717 }
718 println!(
719 " {}",
720 i18n.t_or("env_wizard.secrets.paste_required", "A value is required.")
721 );
722 continue;
723 }
724 return Ok(Some(value));
725 }
726}
727
728fn prompt_env_var_name(default: &str, i18n: &CliI18n) -> Result<String> {
731 loop {
732 print!(
733 " > {}",
734 i18n.tf_or(
735 "env_wizard.secrets.envvar_prompt",
736 "env var name [{}]: ",
737 &[default]
738 )
739 );
740 std::io::stdout().flush()?;
741 let mut line = String::new();
742 let n = std::io::stdin().read_line(&mut line)?;
743 if n == 0 {
744 bail!("unexpected end of input while prompting for env var name");
745 }
746 let trimmed = line.trim();
747 let value = if trimmed.is_empty() { default } else { trimmed };
748 if value.is_empty() {
749 println!(
750 " {}",
751 i18n.t_or(
752 "env_wizard.secrets.envvar_required",
753 "An environment variable name is required.",
754 )
755 );
756 continue;
757 }
758 return Ok(value.to_string());
759 }
760}
761
762fn prompt_manifest_path(env: &str, i18n: &CliI18n) -> Result<PathBuf> {
765 let default = format!("./{env}.env.json");
766 print!(
767 "{}",
768 i18n.tf_or(
769 "env_wizard.manifest_prompt",
770 "Manifest file [{}]: ",
771 &[&default]
772 )
773 );
774 std::io::stdout().flush()?;
775 let mut line = String::new();
776 std::io::stdin().read_line(&mut line)?;
777 let trimmed = line.trim();
778 Ok(PathBuf::from(if trimmed.is_empty() {
779 default.as_str()
780 } else {
781 trimmed
782 }))
783}
784
785fn load_initial_answers(path: &Path, env: &str) -> Result<JsonMap<String, Value>> {
793 if !path.exists() {
794 let mut map = JsonMap::new();
795 map.insert("environment_id".to_string(), Value::String(env.to_string()));
796 return Ok(map);
797 }
798 let Some(doc) = env_mode::sniff_env_manifest(path) else {
799 bail!(
800 "`{}` exists and is not a greentic.env-manifest.v1 document; refusing to overwrite \
801 it — pick another path or remove the file",
802 path.display()
803 );
804 };
805 let manifest: EnvManifest = serde_json::from_value(doc)
806 .with_context(|| format!("`{}` is not a valid env manifest", path.display()))?;
807 if manifest.environment.id != env {
808 bail!(
809 "`{}` targets environment `{}` but --env resolves to `{env}`; pass `--env {}` to \
810 edit it (the manifest is never silently overridden)",
811 path.display(),
812 manifest.environment.id,
813 manifest.environment.id,
814 );
815 }
816 println!(
817 "Editing `{}` — existing answers are kept; only missing ones are asked.",
818 path.display()
819 );
820 let answers = manifest_to_answers(&manifest)?;
821 Ok(answers
822 .answers
823 .as_object()
824 .cloned()
825 .expect("manifest_to_answers always produces an Object"))
826}
827
828pub fn manifest_to_answers(manifest: &EnvManifest) -> Result<AnswerSet> {
840 let mut map = JsonMap::new();
841 map.insert(
842 "environment_id".to_string(),
843 Value::String(manifest.environment.id.clone()),
844 );
845 if let Some(url) = &manifest.environment.public_base_url {
846 map.insert("public_base_url".to_string(), Value::String(url.clone()));
847 }
848 map.insert(
849 "trust_root_bootstrap".to_string(),
850 Value::Bool(match manifest.trust_root {
851 Some(TrustRootDirective::Bootstrap) => true,
852 None => false,
853 }),
854 );
855 if let Some(gui_enabled) = manifest.environment.gui_enabled {
860 map.insert("webchat_gui".to_string(), Value::Bool(gui_enabled));
861 }
862 if !manifest.secrets.is_empty() {
863 let rows = manifest
864 .secrets
865 .iter()
866 .map(|s| match &s.from_env {
867 Some(from_env) => json!({"path": s.path, "source": "env", "from_env": from_env}),
868 None => json!({"path": s.path, "source": "paste"}),
869 })
870 .collect();
871 map.insert("secrets".to_string(), Value::Array(rows));
872 }
873 if !manifest.bundles.is_empty() {
874 let rows = manifest
875 .bundles
876 .iter()
877 .map(|b| -> Result<Value> {
878 let mut row = JsonMap::new();
879 row.insert("bundle_id".to_string(), Value::String(b.bundle_id.clone()));
880 if let Some(bp) = b.bundle_path.as_ref().or_else(|| {
884 b.revisions
885 .as_ref()
886 .and_then(|revs| revs.first())
887 .map(|rev| &rev.bundle_path)
888 }) {
889 row.insert(
890 "bundle_path".to_string(),
891 Value::String(bp.display().to_string()),
892 );
893 }
894 if let Some(customer) = &b.customer_id {
895 row.insert("customer_id".to_string(), Value::String(customer.clone()));
896 }
897 if let Some(overrides) = &b.config_overrides {
898 row.insert(
899 "config_overrides".to_string(),
900 Value::String(serde_json::to_string(overrides)?),
901 );
902 }
903 if let Some(binding) = &b.route_binding {
904 if !binding.hosts.is_empty() {
905 row.insert(
906 "route_hosts".to_string(),
907 Value::String(binding.hosts.join(", ")),
908 );
909 }
910 if !binding.path_prefixes.is_empty() {
911 row.insert(
912 "route_path_prefixes".to_string(),
913 Value::String(binding.path_prefixes.join(", ")),
914 );
915 }
916 if let Some(selector) = &binding.tenant_selector {
917 row.insert(
918 "route_tenant".to_string(),
919 Value::String(selector.tenant.clone()),
920 );
921 row.insert(
922 "route_team".to_string(),
923 Value::String(selector.team.clone()),
924 );
925 }
926 }
927 Ok(Value::Object(row))
928 })
929 .collect::<Result<Vec<_>>>()?;
930 map.insert("bundles".to_string(), Value::Array(rows));
931 }
932 if !manifest.messaging_endpoints.is_empty() {
933 let rows = manifest
934 .messaging_endpoints
935 .iter()
936 .map(|ep| {
937 let mut row = JsonMap::new();
938 row.insert("name".to_string(), Value::String(ep.name.clone()));
939 row.insert(
940 "provider_type".to_string(),
941 Value::String(ep.provider_type.clone()),
942 );
943 if !ep.links.is_empty() {
944 row.insert("links".to_string(), Value::String(ep.links.join(", ")));
945 }
946 if let Some(flow) = &ep.welcome_flow {
947 row.insert(
948 "welcome_bundle_id".to_string(),
949 Value::String(flow.bundle_id.clone()),
950 );
951 row.insert(
952 "welcome_pack_id".to_string(),
953 Value::String(flow.pack_id.clone()),
954 );
955 row.insert(
956 "welcome_flow_id".to_string(),
957 Value::String(flow.flow_id.clone()),
958 );
959 }
960 if !ep.secret_refs.is_empty() {
961 row.insert(
962 "secret_refs".to_string(),
963 Value::String(ep.secret_refs.join(", ")),
964 );
965 }
966 Value::Object(row)
967 })
968 .collect();
969 map.insert("messaging_endpoints".to_string(), Value::Array(rows));
970 }
971 Ok(AnswerSet {
972 form_id: ENV_MANIFEST_FORM_ID.to_string(),
973 spec_version: ENV_MANIFEST_FORM_VERSION.to_string(),
974 answers: Value::Object(map),
975 meta: None,
976 })
977}
978
979#[cfg(test)]
980mod tests {
981 use super::*;
982 use greentic_deployer::cli::bundles::{RouteBindingPayload, TenantSelectorPayload};
983 use greentic_deployer::cli::env_manifest::{
984 ENV_MANIFEST_SCHEMA_V1, ManifestBundle, ManifestEndpoint, ManifestEnvironment,
985 ManifestSecret, ManifestWelcomeFlow, manifest_form_spec,
986 };
987 use std::collections::BTreeMap;
988
989 fn full_manifest() -> EnvManifest {
990 EnvManifest {
991 schema: ENV_MANIFEST_SCHEMA_V1.to_string(),
992 environment: ManifestEnvironment {
993 id: "demo".to_string(),
994 public_base_url: Some("https://demo.example.com".to_string()),
995 gui_enabled: Some(true),
998 name: None,
1001 region: None,
1002 tenant_org_id: None,
1003 listen_addr: None,
1004 },
1005 trust_root: Some(TrustRootDirective::Bootstrap),
1006 secrets: vec![ManifestSecret {
1007 path: "default/_/messaging-telegram/telegram_bot_token".to_string(),
1008 from_env: Some("DEMO_BOT_TOKEN".to_string()),
1009 }],
1010 bundles: vec![ManifestBundle {
1011 bundle_id: "realbot".to_string(),
1012 bundle_path: Some(PathBuf::from("./bundles/realbot.gtbundle")),
1013 revisions: None,
1014 revenue_share: None,
1015 status: None,
1016 customer_id: Some("acme".to_string()),
1017 config_overrides: Some(BTreeMap::from([(
1018 "pack-a".to_string(),
1019 BTreeMap::from([("greeting".to_string(), json!("hi"))]),
1020 )])),
1021 route_binding: Some(RouteBindingPayload {
1022 hosts: vec![
1023 "demo.example.com".to_string(),
1024 "alt.example.com".to_string(),
1025 ],
1026 path_prefixes: vec!["/bot".to_string(), "/api".to_string()],
1027 tenant_selector: Some(TenantSelectorPayload {
1028 tenant: "acme".to_string(),
1029 team: "support".to_string(),
1030 }),
1031 }),
1032 }],
1033 packs: Vec::new(),
1035 extensions: Vec::new(),
1036 messaging_endpoints: vec![ManifestEndpoint {
1037 name: "demo-telegram".to_string(),
1038 provider_type: "messaging.telegram.bot".to_string(),
1039 links: vec!["realbot".to_string(), "auditbot".to_string()],
1040 welcome_flow: Some(ManifestWelcomeFlow {
1041 bundle_id: "realbot".to_string(),
1042 pack_id: "pack-a".to_string(),
1043 flow_id: "welcome".to_string(),
1044 }),
1045 secret_refs: vec![
1046 "secret://local/realbot/telegram/token".to_string(),
1047 "secret://local/realbot/telegram/webhook".to_string(),
1048 ],
1049 }],
1050 }
1051 }
1052
1053 fn round_trip(manifest: &EnvManifest) -> EnvManifest {
1054 let answers = manifest_to_answers(manifest).expect("manifest converts to answers");
1055 answers_to_manifest(&answers).expect("answers convert back to a manifest")
1056 }
1057
1058 #[test]
1059 fn full_manifest_round_trips_through_the_deployer_converter() {
1060 let original = full_manifest();
1061 let back = round_trip(&original);
1062 assert_eq!(
1063 serde_json::to_value(&original).unwrap(),
1064 serde_json::to_value(&back).unwrap(),
1065 );
1066 }
1067
1068 #[test]
1069 fn gui_enabled_maps_to_webchat_gui_answer() {
1070 let answers = manifest_to_answers(&full_manifest()).unwrap();
1073 assert_eq!(answers.answers["webchat_gui"], json!(true));
1074
1075 let mut manifest = full_manifest();
1078 manifest.environment.gui_enabled = Some(false);
1079 let answers = manifest_to_answers(&manifest).unwrap();
1080 assert_eq!(answers.answers["webchat_gui"], json!(false));
1081 assert_eq!(round_trip(&manifest).environment.gui_enabled, Some(false));
1082
1083 let mut manifest = full_manifest();
1087 manifest.environment.gui_enabled = None;
1088 let answers = manifest_to_answers(&manifest).unwrap();
1089 assert!(answers.answers.get("webchat_gui").is_none());
1090 assert_eq!(round_trip(&manifest).environment.gui_enabled, None);
1091 }
1092
1093 #[test]
1094 fn paste_and_env_secrets_round_trip_through_the_converter() {
1095 let mut manifest = full_manifest();
1099 manifest.secrets = vec![
1100 ManifestSecret {
1101 path: "default/_/messaging-telegram/telegram_bot_token".to_string(),
1102 from_env: Some("DEMO_BOT_TOKEN".to_string()),
1103 },
1104 ManifestSecret {
1105 path: "default/_/messaging-slack/slack_bot_token".to_string(),
1106 from_env: None,
1107 },
1108 ];
1109 let back = round_trip(&manifest);
1110 assert_eq!(back.secrets[0].from_env.as_deref(), Some("DEMO_BOT_TOKEN"));
1111 assert_eq!(back.secrets[1].from_env, None);
1112
1113 let answers = manifest_to_answers(&manifest).unwrap();
1116 let rows = answers.answers["secrets"].as_array().unwrap();
1117 assert_eq!(rows[0]["source"], "env");
1118 assert_eq!(rows[0]["from_env"], "DEMO_BOT_TOKEN");
1119 assert_eq!(rows[1]["source"], "paste");
1120 assert!(rows[1].get("from_env").is_none());
1121 }
1122
1123 #[test]
1124 fn existing_source_by_path_reads_and_infers() {
1125 let answers = json!({
1126 "secrets": [
1127 {"path": "a/_/p/tok", "source": "paste"},
1128 {"path": "b/_/p/tok", "source": "env", "from_env": "B"},
1129 {"path": "c/_/p/tok", "from_env": "C"},
1131 {"path": "d/_/p/tok"}
1132 ]
1133 });
1134 let map = existing_source_by_path(answers.as_object().unwrap());
1135 assert_eq!(map.get("a/_/p/tok").map(String::as_str), Some("paste"));
1136 assert_eq!(map.get("b/_/p/tok").map(String::as_str), Some("env"));
1137 assert_eq!(map.get("c/_/p/tok").map(String::as_str), Some("env"));
1138 assert_eq!(map.get("d/_/p/tok").map(String::as_str), Some("paste"));
1139 }
1140
1141 #[test]
1142 fn minimal_manifest_round_trips() {
1143 let original = EnvManifest {
1144 schema: ENV_MANIFEST_SCHEMA_V1.to_string(),
1145 environment: ManifestEnvironment {
1146 id: "local".to_string(),
1147 public_base_url: None,
1148 gui_enabled: None,
1149 name: None,
1150 region: None,
1151 tenant_org_id: None,
1152 listen_addr: None,
1153 },
1154 trust_root: None,
1155 secrets: Vec::new(),
1156 packs: Vec::new(),
1157 bundles: Vec::new(),
1158 extensions: Vec::new(),
1159 messaging_endpoints: Vec::new(),
1160 };
1161 let back = round_trip(&original);
1162 assert_eq!(
1163 serde_json::to_value(&original).unwrap(),
1164 serde_json::to_value(&back).unwrap(),
1165 );
1166 }
1167
1168 #[test]
1169 fn empty_config_overrides_stays_an_explicit_clear() {
1170 let mut manifest = full_manifest();
1173 manifest.bundles[0].config_overrides = Some(BTreeMap::new());
1174 let back = round_trip(&manifest);
1175 assert_eq!(back.bundles[0].config_overrides, Some(BTreeMap::new()));
1176 }
1177
1178 #[test]
1179 fn generated_answers_pass_the_form_validation() {
1180 let answers = manifest_to_answers(&full_manifest()).unwrap();
1181 let result = qa_spec::validate(&manifest_form_spec(), &answers.answers);
1182 assert!(
1183 result.valid,
1184 "errors: {:?}, missing: {:?}, unknown: {:?}",
1185 result.errors, result.missing_required, result.unknown_fields
1186 );
1187 }
1188
1189 #[test]
1190 fn load_initial_answers_seeds_env_for_new_files() {
1191 let dir = tempfile::tempdir().unwrap();
1192 let map = load_initial_answers(&dir.path().join("demo.env.json"), "demo").unwrap();
1193 assert_eq!(map.get("environment_id"), Some(&json!("demo")));
1194 assert_eq!(map.len(), 1, "only the env id is pre-seeded: {map:?}");
1195 }
1196
1197 #[test]
1198 fn load_initial_answers_refuses_files_it_does_not_own() {
1199 let dir = tempfile::tempdir().unwrap();
1200 let path = dir.path().join("notes.json");
1201 std::fs::write(&path, r#"{"some": "other document"}"#).unwrap();
1202 let err = load_initial_answers(&path, "demo").unwrap_err();
1203 assert!(
1204 format!("{err:#}").contains("refusing to overwrite"),
1205 "got: {err:#}"
1206 );
1207 }
1208
1209 #[test]
1210 fn load_initial_answers_rejects_env_mismatch() {
1211 let dir = tempfile::tempdir().unwrap();
1212 let path = dir.path().join("demo.env.json");
1213 let manifest = serde_json::to_string(&full_manifest()).unwrap();
1214 std::fs::write(&path, manifest).unwrap();
1215 let err = load_initial_answers(&path, "local").unwrap_err();
1216 let msg = format!("{err:#}");
1217 assert!(msg.contains("`demo`"), "names the manifest env: {msg}");
1218 assert!(msg.contains("`local`"), "names the --env value: {msg}");
1219 }
1220
1221 #[test]
1222 fn load_initial_answers_preloads_a_matching_manifest() {
1223 let dir = tempfile::tempdir().unwrap();
1224 let path = dir.path().join("demo.env.json");
1225 std::fs::write(&path, serde_json::to_string(&full_manifest()).unwrap()).unwrap();
1226 let map = load_initial_answers(&path, "demo").unwrap();
1227 assert_eq!(map.get("environment_id"), Some(&json!("demo")));
1228 assert!(map.get("secrets").is_some_and(Value::is_array));
1229 assert!(map.get("bundles").is_some_and(Value::is_array));
1230 }
1231
1232 #[test]
1233 fn wizard_is_interactive_only() {
1234 let i18n = CliI18n::from_request(None).unwrap();
1235 let err = run_env_wizard("demo", false, false, true, &i18n).unwrap_err();
1236 assert!(
1237 format!("{err:#}").contains("--answers"),
1238 "points at the headless alternative: {err:#}"
1239 );
1240 }
1241
1242 #[test]
1243 fn localize_spec_translates_questions_and_list_labels_to_dutch() {
1244 let i18n = CliI18n::from_request(Some("nl")).unwrap();
1245 let spec = localize_spec(manifest_form_spec_for_env("local"), &i18n);
1246
1247 assert_eq!(spec.title, "Omgeving instellen");
1249 let bundles = spec.questions.iter().find(|q| q.id == "bundles").unwrap();
1250 assert_eq!(bundles.title, "Bundels");
1251
1252 let list = bundles.list.as_ref().unwrap();
1253 assert_eq!(list.item_label.as_deref(), Some("bundel"));
1255 let bundle_id = list.fields.iter().find(|c| c.id == "bundle_id").unwrap();
1257 assert_eq!(bundle_id.title, "Bundel-id");
1258
1259 let secrets = spec.questions.iter().find(|q| q.id == "secrets").unwrap();
1261 let source = secrets
1262 .list
1263 .as_ref()
1264 .unwrap()
1265 .fields
1266 .iter()
1267 .find(|c| c.id == "source")
1268 .unwrap();
1269 assert_eq!(
1270 source.choices.as_deref(),
1271 Some(["env".to_string(), "paste".to_string()].as_slice()),
1272 "choice values must not be translated"
1273 );
1274 }
1275
1276 #[test]
1277 fn localize_spec_falls_back_to_english_for_unknown_locale() {
1278 let i18n = CliI18n::from_request(Some("zz")).unwrap();
1281 let spec = localize_spec(manifest_form_spec_for_env("local"), &i18n);
1282 assert_eq!(spec.title, "Environment setup");
1283 let bundles = spec.questions.iter().find(|q| q.id == "bundles").unwrap();
1284 assert_eq!(bundles.title, "Bundles");
1285 }
1286
1287 #[test]
1288 fn spec_without_question_drops_only_the_named_question() {
1289 let spec = manifest_form_spec();
1290 let reduced = spec_without_question(&spec, "secrets");
1291 assert!(
1292 spec.questions.iter().any(|q| q.id == "secrets"),
1293 "fixture has the secrets question"
1294 );
1295 assert!(
1296 reduced.questions.iter().all(|q| q.id != "secrets"),
1297 "secrets question is dropped"
1298 );
1299 assert_eq!(
1300 reduced.questions.len(),
1301 spec.questions.len() - 1,
1302 "exactly one question removed"
1303 );
1304 }
1305
1306 #[test]
1307 fn existing_from_env_by_path_indexes_preloaded_secrets() {
1308 let answers = json!({
1309 "secrets": [
1310 {"path": "legal/_/messaging-telegram/telegram_bot_token", "from_env": "LEGAL_TOK"},
1311 {"path": "acct/_/messaging-telegram/telegram_bot_token", "from_env": "ACCT_TOK"}
1312 ]
1313 });
1314 let map = existing_from_env_by_path(answers.as_object().unwrap());
1315 assert_eq!(
1316 map.get("legal/_/messaging-telegram/telegram_bot_token")
1317 .map(String::as_str),
1318 Some("LEGAL_TOK")
1319 );
1320 assert_eq!(map.len(), 2);
1321 assert!(existing_from_env_by_path(&JsonMap::new()).is_empty());
1323 }
1324
1325 #[test]
1326 fn default_env_var_name_prefixes_non_default_tenant() {
1327 assert_eq!(
1328 default_env_var_name("legal", "telegram_bot_token"),
1329 "LEGAL_TELEGRAM_BOT_TOKEN"
1330 );
1331 assert_eq!(
1332 default_env_var_name("default", "telegram_bot_token"),
1333 "TELEGRAM_BOT_TOKEN"
1334 );
1335 assert_eq!(default_env_var_name("", "api_key"), "API_KEY");
1336 assert_eq!(
1339 default_env_var_name("my-tenant", "bot.token"),
1340 "MY_TENANT_BOT_TOKEN"
1341 );
1342 }
1343
1344 fn bundle_from(value: Value) -> ManifestBundle {
1345 serde_json::from_value(value).expect("valid manifest bundle")
1346 }
1347
1348 fn built_bundle_with_telegram_secret(root: &Path, workspace: &str) {
1351 let pack_dir = root.join(workspace).join("packs/messaging-telegram");
1352 std::fs::create_dir_all(pack_dir.join("assets")).unwrap();
1353 std::fs::write(pack_dir.join("pack.yaml"), "id: messaging-telegram\n").unwrap();
1354 std::fs::write(
1355 pack_dir.join("assets/secret-requirements.json"),
1356 r#"[{"key":"TELEGRAM_BOT_TOKEN","required":true}]"#,
1357 )
1358 .unwrap();
1359 std::fs::write(
1360 root.join(workspace).join("realbot.gtbundle"),
1361 b"squashfs-placeholder",
1362 )
1363 .unwrap();
1364 }
1365
1366 #[test]
1367 fn derive_required_secrets_reads_built_bundle_packs() {
1368 let dir = tempfile::tempdir().unwrap();
1369 built_bundle_with_telegram_secret(dir.path(), "ws-legal");
1370 let bundle = bundle_from(json!({
1371 "bundle_id": "realbot-legal",
1372 "bundle_path": "ws-legal/realbot.gtbundle",
1373 "route_binding": {
1374 "hosts": [],
1375 "path_prefixes": ["/legal"],
1376 "tenant_selector": {"tenant": "legal", "team": "default"}
1377 }
1378 }));
1379
1380 let (derived, skipped) =
1381 derive_required_secrets(dir.path(), "local", std::slice::from_ref(&bundle));
1382 assert!(!skipped);
1383 assert_eq!(derived.len(), 1);
1384 assert_eq!(
1385 derived[0].path,
1386 "legal/_/messaging-telegram/telegram_bot_token"
1387 );
1388 assert_eq!(derived[0].tenant, "legal");
1389 assert_eq!(derived[0].bundle_ids, vec!["realbot-legal".to_string()]);
1390 }
1391
1392 #[test]
1393 fn derive_required_secrets_dedups_same_path_across_bundles() {
1394 let dir = tempfile::tempdir().unwrap();
1397 built_bundle_with_telegram_secret(dir.path(), "ws-a");
1398 built_bundle_with_telegram_secret(dir.path(), "ws-b");
1399 let bundles = [
1400 bundle_from(json!({
1401 "bundle_id": "a", "bundle_path": "ws-a/realbot.gtbundle",
1402 "route_binding": {"hosts": [], "path_prefixes": ["/a"],
1403 "tenant_selector": {"tenant": "shared", "team": "default"}}
1404 })),
1405 bundle_from(json!({
1406 "bundle_id": "b", "bundle_path": "ws-b/realbot.gtbundle",
1407 "route_binding": {"hosts": [], "path_prefixes": ["/b"],
1408 "tenant_selector": {"tenant": "shared", "team": "default"}}
1409 })),
1410 ];
1411 let (derived, _) = derive_required_secrets(dir.path(), "local", &bundles);
1412 assert_eq!(derived.len(), 1, "deduped by path");
1413 assert_eq!(
1414 derived[0].bundle_ids,
1415 vec!["a".to_string(), "b".to_string()]
1416 );
1417 }
1418
1419 #[test]
1420 fn derive_required_secrets_skips_unbuilt_bundle() {
1421 let dir = tempfile::tempdir().unwrap();
1422 let bundle = bundle_from(json!({
1423 "bundle_id": "missing",
1424 "bundle_path": "ws-missing/realbot.gtbundle"
1425 }));
1426 let (derived, skipped) =
1427 derive_required_secrets(dir.path(), "local", std::slice::from_ref(&bundle));
1428 assert!(derived.is_empty());
1429 assert!(skipped, "missing artifact flags skipped");
1430 }
1431
1432 fn list_field_ids(spec: &FormSpec, id: &str) -> Vec<String> {
1434 let mut ids: Vec<String> = spec
1435 .questions
1436 .iter()
1437 .find(|q| q.id == id)
1438 .and_then(|q| q.list.as_ref())
1439 .map(|list| list.fields.iter().map(|f| f.id.clone()).collect())
1440 .unwrap_or_default();
1441 ids.sort();
1442 ids
1443 }
1444
1445 #[test]
1446 fn spec_for_mode_basic_hides_only_the_curated_columns() {
1447 let basic = spec_for_mode(&manifest_form_spec(), false);
1448 assert_eq!(
1449 list_field_ids(&basic, "bundles"),
1450 [
1451 "bundle_id",
1452 "bundle_path",
1453 "route_path_prefixes",
1454 "route_team",
1455 "route_tenant",
1456 ],
1457 "basic bundles keep id/path + route path/tenant/team only"
1458 );
1459 assert_eq!(
1460 list_field_ids(&basic, "messaging_endpoints"),
1461 ["links", "name", "provider_type"],
1462 "basic endpoints keep name/provider_type/links only"
1463 );
1464 }
1465
1466 #[test]
1467 fn spec_for_mode_advanced_is_a_noop() {
1468 let spec = manifest_form_spec();
1469 let advanced = spec_for_mode(&spec, true);
1470 assert_eq!(
1473 list_field_ids(&advanced, "bundles"),
1474 list_field_ids(&spec, "bundles"),
1475 );
1476 assert_eq!(
1477 list_field_ids(&advanced, "messaging_endpoints"),
1478 list_field_ids(&spec, "messaging_endpoints"),
1479 );
1480 for col in ["customer_id", "config_overrides", "route_hosts"] {
1481 assert!(
1482 list_field_ids(&advanced, "bundles").contains(&col.to_string()),
1483 "advanced keeps bundles.{col}"
1484 );
1485 }
1486 for col in [
1487 "welcome_bundle_id",
1488 "welcome_pack_id",
1489 "welcome_flow_id",
1490 "secret_refs",
1491 ] {
1492 assert!(
1493 list_field_ids(&advanced, "messaging_endpoints").contains(&col.to_string()),
1494 "advanced keeps messaging_endpoints.{col}"
1495 );
1496 }
1497 }
1498
1499 #[test]
1500 fn basic_spec_answers_convert_to_a_valid_manifest() {
1501 let basic = spec_for_mode(&manifest_form_spec(), false);
1505 let raw = json!({
1506 "environment_id": "local",
1507 "trust_root_bootstrap": true,
1508 "webchat_gui": true,
1509 "bundles": [{
1510 "bundle_id": "legal",
1511 "bundle_path": "ws-legal/realbot.gtbundle",
1512 "route_path_prefixes": "/legal",
1513 "route_tenant": "legal",
1514 "route_team": "default"
1515 }],
1516 "messaging_endpoints": [{
1517 "name": "legal",
1518 "provider_type": "messaging.telegram.bot",
1519 "links": "legal"
1520 }]
1521 });
1522 let set = answer_set(raw.as_object().unwrap().clone());
1523
1524 let report = qa_spec::validate(&basic, &set.answers);
1525 assert!(
1526 report.valid,
1527 "basic answers must pass the basic spec: {report:?}"
1528 );
1529
1530 let manifest = answers_to_manifest(&set).expect("converts");
1531 manifest.validate_shape().expect("valid shape");
1532 let bundle = &manifest.bundles[0];
1533 assert!(bundle.customer_id.is_none());
1534 assert!(bundle.config_overrides.is_none());
1535 let rb = bundle.route_binding.as_ref().expect("route binding built");
1536 assert_eq!(rb.path_prefixes, ["/legal"]);
1537 assert!(rb.hosts.is_empty(), "route_hosts stays empty in basic mode");
1538 assert!(manifest.messaging_endpoints[0].welcome_flow.is_none());
1539 assert!(manifest.messaging_endpoints[0].secret_refs.is_empty());
1540 }
1541}