Skip to main content

greentic_setup/
env_wizard.rs

1//! Bare `--env` wizard (operator-surface PR-6): author or gap-fill a
2//! `greentic.env-manifest.v1` manifest interactively, persist it, and hand
3//! off to the deployer's env-apply engine via [`crate::env_mode`].
4//!
5//! `greentic-setup --env demo` (explicit `--env`, no bundle positional, no
6//! `--answers`, TTY) drives the deployer-owned `manifest_form_spec()`
7//! through the existing FormSpec prompt loop
8//! ([`crate::qa::prompts::prompt_form_spec_answers_with_existing`] —
9//! advanced gating and table rows come free), converts the answers with
10//! the deployer's `answers_to_manifest`, writes the manifest file (the
11//! durable artifact — commit it), and runs the engine: plan rows, TTY
12//! confirmation, execute. Re-running against an existing manifest
13//! pre-loads it via [`manifest_to_answers`]: satisfied questions are kept
14//! (gap-fill mode); for full edits, hand-edit the file and re-apply with
15//! `--answers <file>`.
16//!
17//! Secrets are the exception to the pure-form flow: this wizard drops the
18//! generic `secrets` table from the prompt loop and handles it *last* as a
19//! derived step. Once the bundles are known it reads each bundle's packs'
20//! `secret-requirements.json` (via the deployer's
21//! [`greentic_deployer::runtime_secrets::bundle_secret_requirements`],
22//! scoped to the bundle's route tenant) and, for each required secret, asks
23//! whether the value comes from a named environment variable (the operator
24//! enters the NAME; apply reads it from the env / dev-store) or is pasted in
25//! now (collected with a masked prompt and handed to apply, which writes it
26//! to the env's secrets store). The path is always auto-derived, and the
27//! pasted value is never written to the manifest. So the operator only
28//! enters the secrets the configured bundles actually need.
29//!
30//! Basic vs advanced: by default the wizard asks only the everyday fields.
31//! The optional row columns an operator almost always leaves empty
32//! (`customer_id`, `config_overrides`, `route_hosts` on bundles; the
33//! `welcome_*` trio and `secret_refs` on endpoints) are hidden until
34//! `--advanced` is passed — the same flag that already reveals the optional
35//! top-level questions. The route path/tenant/team and endpoint links stay
36//! in the basic flow; the common multi-bundle setup needs them.
37
38use 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
58/// Wizard entry: prompt for the manifest path, pre-load or seed the
59/// answers, run the form, persist the manifest, and hand off to the
60/// engine. `dry_run` stops after the plan preview; otherwise the engine's
61/// own TTY confirmation gates execution (no second confirm here).
62pub 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    // Drive the shared form WITHOUT the secrets section: this terminal
79    // wizard owns secrets as a final, derived step — it asks only for the
80    // secrets the configured bundles actually declare, and only for the
81    // env-var NAME of each (never the path, never the value).
82    //
83    // Pass the target env so the `webchat_gui` question defaults match the
84    // runtime resolver (`resolved_gui_enabled()`): on for the `local` env id,
85    // off elsewhere. An explicit answer in a pre-loaded manifest still wins.
86    let spec = manifest_form_spec_for_env(env);
87    // Basic flow hides the advanced-only row columns; `--advanced` reveals
88    // them (mirrors the existing top-level optional-question gating).
89    let spec = spec_for_mode(&spec, advanced);
90    let form_spec = spec_without_question(&spec, "secrets");
91    // Localize the question prompts (titles/descriptions/list nouns) for the
92    // requested locale before handing them to the prompt loop; choice VALUES
93    // stay canonical (see `localize_spec`). The deployer's English literals are
94    // the fallback, so an untranslated locale still renders.
95    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    // Pre-loaded from_env values (editing an existing manifest) become the
116    // per-secret defaults so a re-run doesn't re-ask what hasn't changed.
117    let existing_from_env = existing_from_env_by_path(&answers);
118    // Pre-loaded source choices default the env-vs-paste prompt on a re-edit.
119    let existing_source = existing_source_by_path(&answers);
120
121    // Relative bundle paths resolve against the manifest file's directory,
122    // exactly like the apply engine.
123    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    // Derive + prompt secrets from the bundles just authored. A provisional
130    // manifest gives the typed bundle list; any pre-loaded secrets in it are
131    // ignored (recomputed below).
132    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    // A pasted value lives only in memory until a confirmed apply writes it to
164    // the store. A dry run previews without executing, so it would silently
165    // discard what was just entered — and, because a paste secret already in
166    // the store is treated as satisfied on a later apply, an existing stale
167    // value would then win. Warn rather than drop it silently.
168    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
184/// The env-manifest answer-set wrapper (form id + version) around a raw
185/// answers map.
186fn 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
195/// Clone of `spec` with the question whose id is `id` removed. The terminal
196/// wizard handles `secrets` itself (derived), so it is dropped from the
197/// prompt loop while remaining in the shared spec for other front-ends.
198fn 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
204/// Return `spec` with the user-facing prompt text localized for
205/// `i18n`'s locale: the form title/description, every question's
206/// title/description (recursing into `List` row columns), and each `List`
207/// item label. Keys are derived from the stable question ids
208/// (`env_wizard.q.<id>.title`, `.desc`, `env_wizard.list.<id>.item_label`,
209/// `env_wizard.form.title`/`.desc`). A missing catalog key falls back to the
210/// English literal already on the spec — the deployer is the canonical English
211/// source, so an untranslated locale still renders.
212///
213/// Choice VALUES (`enum` options) are deliberately left untouched: they are
214/// canonical answer tokens written into the manifest, not display text.
215fn 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
226/// Localize a single question in place (see [`localize_spec`]); recurses into
227/// `List` row columns.
228fn 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
250/// Optional `List` row columns hidden from the basic (non-`--advanced`)
251/// flow, keyed by the owning list question id. They map to manifest fields
252/// an operator almost always leaves empty. Everything not listed here — and
253/// every required column — stays in the basic flow, notably the route
254/// path/tenant/team and endpoint links the common multi-bundle setup needs.
255const 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
271/// Clone of `spec` with the advanced-only row columns
272/// ([`ADVANCED_LIST_COLUMNS`]) removed from each `List` question, so the
273/// basic flow asks only the everyday fields. A no-op when `advanced` is
274/// true. Hiding at the spec level (like [`spec_without_question`]) keeps the
275/// shared prompt loop generic — it never learns which columns are advanced.
276fn 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
297/// `path -> from_env` from a pre-loaded `secrets` answer array, used as
298/// per-secret defaults when editing an existing manifest.
299fn 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
314/// `path -> source` (`env` | `paste`) from a pre-loaded `secrets` answer
315/// array, used to default the env-vs-paste choice on a re-edit. A row with no
316/// explicit `source` is inferred from `from_env` presence (back-compat with
317/// manifests authored before the source discriminator existed).
318fn 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
338/// Tenant a bundle's route binding selects (defaulting to `default`) — the
339/// scope the dev-store secret path is built under.
340fn 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
349/// Suggested env-var name for a derived secret: `<TENANT>_<KEY>` (upper), or
350/// just `<KEY>` for the default tenant. A hint only — the operator types the
351/// actual variable name.
352fn default_env_var_name(tenant: &str, key: &str) -> String {
353    /// Replace non-ASCII-alphanumeric chars with `_` and uppercase — produces
354    /// a POSIX-safe env-var name fragment from an arbitrary identifier.
355    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
374/// One secret a configured bundle declares, with the context needed to
375/// prompt for its env-var name.
376struct DerivedSecret {
377    /// Manifest secret path `<tenant>/<team>/<pack>/<name>`.
378    path: String,
379    /// Pack/provider that declared it (for display).
380    provider_id: String,
381    /// Canonical secret key (drives the default env-var name).
382    key: String,
383    /// Tenant the path is scoped to.
384    tenant: String,
385    /// Whether the declaring pack marked it required.
386    required: bool,
387    /// Bundle ids that need this same secret (display only).
388    bundle_ids: Vec<String>,
389}
390
391/// Read each bundle's packs' `secret-requirements.json` (via the deployer's
392/// [`bundle_secret_requirements`]) and collect the unique secrets they
393/// declare, in first-seen order, deduplicated by manifest path. Bundles
394/// whose artifact (or built `packs/`) is missing are skipped with a note and
395/// reported via the returned `skipped` flag — the wizard never hard-fails
396/// just because a bundle has not been built yet.
397fn 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        // A bundle declares either a single `bundle_path` or a multi-revision
409        // `revisions[]` list (mutually exclusive). For secret auto-detection,
410        // resolve the single artifact, or the first revision as a representative
411        // (revisions of one deployment share the same tenant/secrets).
412        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
486/// Derive the secrets the configured bundles need and, for each, prompt for a
487/// source: an environment variable NAME or a pasted value. Returns the
488/// `secrets[]` answer rows (`{path, source, from_env?}`) to merge into the
489/// manifest answers, plus the pasted values keyed by path to hand to the apply
490/// engine (`ApplyOptions.prefilled_secrets`) so it does not re-prompt.
491///
492/// When a bundle was skipped (unbuilt/unreadable) any pre-loaded secrets not
493/// re-derived are preserved as-is, so a partial edit never silently drops a
494/// secret the wizard couldn't recompute.
495fn 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    // When a bundle was skipped, pre-loaded secrets we couldn't recompute are
506    // preserved rather than dropped (see the tail of this fn).
507    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        // Re-edit defaults to the previously-chosen source.
572        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                // On a re-edit of an already-paste secret, the value is already
586                // in the store: empty input keeps it (apply no-ops). Otherwise a
587                // value is required now.
588                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    // Preserve pre-loaded secrets the wizard couldn't recompute (a bundle was
598    // skipped), so editing a manifest without rebuilt bundles is non-destructive.
599    // Env secrets keep their `from_env`; paste secrets keep their store value
600    // (apply no-ops on the already-stored value).
601    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
639/// Where a secret's value comes from, as chosen in the wizard.
640enum SecretSource {
641    /// A named environment variable, read at apply time.
642    Env,
643    /// A value pasted in now and stored in the env's secrets store.
644    Paste,
645}
646
647/// Prompt whether a secret's value comes from an environment variable or is
648/// pasted in now. `default_paste` seeds the default (a re-edit keeps the
649/// previous choice).
650fn prompt_secret_source(default_paste: bool, i18n: &CliI18n) -> Result<SecretSource> {
651    let default = if default_paste { "2" } else { "1" };
652    loop {
653        // The `[1]`/`[2]` reply tokens stay canonical — only the prose around
654        // them is localized.
655        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
685/// Masked prompt for a pasted secret value. When `keep_stored` is true (a
686/// re-edit of an already-stored paste secret), empty input keeps the stored
687/// value (`Ok(None)` — apply leaves the store entry untouched). Otherwise a
688/// non-empty value is required.
689///
690/// Single-line only: `rpassword` reads to the first newline, so a multiline
691/// secret (a PEM key, certificate, multiline JSON) would be truncated to its
692/// first line. Such secrets should use the environment-variable source
693/// instead — the prompt says "single line" to steer the operator there.
694fn 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
728/// Prompt for one env-var name, defaulting to `default` on empty input.
729/// Re-prompts only if both the input and the default are blank.
730fn 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
762/// Ask where the manifest lives (and will be written). Empty input takes
763/// the conventional default `./<env>.env.json`.
764fn 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
785/// Initial answers for the prompt loop.
786///
787/// Missing file → a fresh map seeded with `environment_id` (the user
788/// already said it via `--env`; don't re-ask). Existing env manifest →
789/// its answers via [`manifest_to_answers`], after the same env
790/// cross-check the apply path enforces. Any other existing file → error;
791/// the wizard never overwrites a document it doesn't own.
792fn 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
828/// Inverse of the deployer's `answers_to_manifest` — manifest → wizard
829/// answers, for pre-loading an existing manifest into the prompt loop.
830///
831/// Mirrors the deployer's conventions exactly: every `Vec<String>`
832/// manifest field (`links`, `route_hosts`, `route_path_prefixes`,
833/// `secret_refs`) renders as a comma-separated string, and
834/// `config_overrides` renders as its JSON text — `Some({})` stays the
835/// load-bearing "explicit clear", distinct from absent. The round-trip
836/// (manifest → answers → `answers_to_manifest`) is pinned by tests, so a
837/// new manifest field that misses this converter fails in CI, not in an
838/// operator's edit session.
839pub 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    // Emit `webchat_gui` only when the manifest carries an explicit choice —
856    // an unset (`None`) value is left absent so it round-trips back to `None`
857    // (the env-id default resolves at runtime) and the wizard re-asks the
858    // question with its default-true. Mirrors `public_base_url` above.
859    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                // Single-revision bundles carry `bundle_path`; multi-revision
881                // (JSON-first) bundles use the first revision's artifact as a
882                // representative for the wizard answer-set (which models one path).
883                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                // Form-backed (the `webchat_gui` question), so an explicit value
996                // survives the round-trip — unlike the form-less fields below.
997                gui_enabled: Some(true),
998                // Form-less env fields: `answers_to_manifest` always produces
999                // None, so the round-trip only holds when these start None.
1000                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            // No form questions for packs/extensions; default empty.
1034            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        // An explicit value surfaces as the boolean answer, so edit mode
1071        // pre-fills the question instead of re-asking it.
1072        let answers = manifest_to_answers(&full_manifest()).unwrap();
1073        assert_eq!(answers.answers["webchat_gui"], json!(true));
1074
1075        // An explicit `false` opt-out is preserved as the boolean answer and
1076        // survives the round-trip — it is never silently dropped or flipped.
1077        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        // An unset value omits the answer entirely: the round-trip preserves
1084        // `None` (env-id default resolves at runtime) and the wizard re-asks
1085        // with its default-true.
1086        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        // Mixed sources: an env-sourced secret keeps `from_env`; a
1096        // paste-sourced one carries `source: paste` and no `from_env` (no
1097        // value in the manifest). Both survive manifest → answers → manifest.
1098        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        // The wizard answers carry the source discriminator; the paste row
1114        // never carries a value.
1115        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                // Legacy row (no `source`): inferred from `from_env` presence.
1130                {"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        // `Some({})` is `op deploy`'s "explicit clear" — distinct from
1171        // absent ("leave untouched"). The round-trip must not collapse it.
1172        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        // Form title + top-level question title are Dutch.
1248        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        // The list "Add <noun>?" label is localized…
1254        assert_eq!(list.item_label.as_deref(), Some("bundel"));
1255        // …and nested row-column titles recurse.
1256        let bundle_id = list.fields.iter().find(|c| c.id == "bundle_id").unwrap();
1257        assert_eq!(bundle_id.title, "Bundel-id");
1258
1259        // Enum choice VALUES stay canonical (answer tokens, not display text).
1260        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        // A locale with no catalog resolves to the English fallback, i.e. the
1279        // deployer's canonical literals — the wizard still renders.
1280        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        // No secrets key → empty map.
1322        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        // Special characters in tenant/key are sanitized to underscores so
1337        // the suggested default is a valid POSIX env-var name.
1338        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    /// Lay down a built-bundle workspace next to a `.gtbundle` artifact with a
1349    /// marked provider pack declaring one secret. Returns the manifest dir.
1350    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        // Two bundles, same tenant + same provider pack → one secret path,
1395        // both bundle ids recorded.
1396        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    /// Sorted row-field ids of the `List` question `id` in `spec`.
1433    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        // Every curated column survives, and the column sets are identical to
1471        // the source spec's.
1472        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        // A two-dept-style answer set using only basic-flow columns passes the
1502        // basic form spec and converts + shape-validates through the deployer —
1503        // proving the hidden columns are never required.
1504        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}