use std::collections::{BTreeMap, BTreeSet};
use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use greentic_deployer::cli::env_manifest::{
ENV_MANIFEST_FORM_ID, ENV_MANIFEST_FORM_VERSION, EnvManifest, ManifestBundle,
TrustRootDirective, answers_to_manifest, manifest_form_spec,
};
use greentic_deployer::runtime_secrets::{
SecretValue, bundle_secret_requirements, manifest_secret_path,
};
use qa_spec::{AnswerSet, FormSpec};
use rpassword::prompt_password;
use serde_json::{Map as JsonMap, Value, json};
use crate::env_mode;
use crate::qa::prompts::prompt_form_spec_answers_with_existing;
pub fn run_env_wizard(
env: &str,
advanced: bool,
dry_run: bool,
non_interactive: bool,
) -> Result<()> {
if non_interactive || !std::io::stdin().is_terminal() {
bail!(
"the environment wizard is interactive; in headless runs pass an env manifest via \
--answers <file> (generate a skeleton with `gtc op env apply --emit-answers-template`)"
);
}
let manifest_path = prompt_manifest_path(env)?;
let initial = load_initial_answers(&manifest_path, env)?;
let spec = manifest_form_spec();
let spec = spec_for_mode(&spec, advanced);
let form_spec = spec_without_question(&spec, "secrets");
if !advanced {
println!(
"\nBasic mode — pass --advanced to also set customer id, config \
overrides, route hosts, welcome flow, and endpoint secret refs."
);
}
let prompted = prompt_form_spec_answers_with_existing(
&form_spec,
"environment",
advanced,
&Value::Object(initial),
)?;
let mut answers = prompted.as_object().cloned().unwrap_or_default();
let existing_from_env = existing_from_env_by_path(&answers);
let existing_source = existing_source_by_path(&answers);
let manifest_dir = manifest_path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let provisional = answers_to_manifest(&answer_set(answers.clone()))?;
let (secret_rows, prefilled_secrets) = derive_and_prompt_secrets(
&manifest_dir,
env,
&provisional.bundles,
&existing_from_env,
&existing_source,
)?;
if secret_rows.is_empty() {
answers.remove("secrets");
} else {
answers.insert("secrets".to_string(), Value::Array(secret_rows));
}
let manifest = answers_to_manifest(&answer_set(answers))?;
let doc = serde_json::to_value(&manifest)?;
let mut rendered = serde_json::to_string_pretty(&doc)?;
rendered.push('\n');
std::fs::write(&manifest_path, rendered)
.with_context(|| format!("failed to write `{}`", manifest_path.display()))?;
println!(
"\nWrote `{}` — the manifest is the durable artifact; keep it in version control.",
manifest_path.display()
);
if dry_run && !prefilled_secrets.is_empty() {
println!(
"\nNote: --dry-run previews only — the {} pasted secret value(s) you entered are \
NOT written to the store. Re-run without --dry-run and confirm the plan to persist \
them.",
prefilled_secrets.len()
);
}
env_mode::run_env_apply(&manifest_path, &doc, env, dry_run, false, prefilled_secrets)
}
fn answer_set(answers: JsonMap<String, Value>) -> AnswerSet {
AnswerSet {
form_id: ENV_MANIFEST_FORM_ID.to_string(),
spec_version: ENV_MANIFEST_FORM_VERSION.to_string(),
answers: Value::Object(answers),
meta: None,
}
}
fn spec_without_question(spec: &FormSpec, id: &str) -> FormSpec {
let mut reduced = spec.clone();
reduced.questions.retain(|question| question.id != id);
reduced
}
const ADVANCED_LIST_COLUMNS: &[(&str, &[&str])] = &[
(
"bundles",
&["customer_id", "config_overrides", "route_hosts"],
),
(
"messaging_endpoints",
&[
"welcome_bundle_id",
"welcome_pack_id",
"welcome_flow_id",
"secret_refs",
],
),
];
fn spec_for_mode(spec: &FormSpec, advanced: bool) -> FormSpec {
if advanced {
return spec.clone();
}
let mut reduced = spec.clone();
for question in &mut reduced.questions {
let Some(hidden) = ADVANCED_LIST_COLUMNS
.iter()
.find(|(id, _)| *id == question.id)
.map(|(_, columns)| *columns)
else {
continue;
};
if let Some(list) = question.list.as_mut() {
list.fields
.retain(|field| !hidden.contains(&field.id.as_str()));
}
}
reduced
}
fn existing_from_env_by_path(answers: &JsonMap<String, Value>) -> BTreeMap<String, String> {
let mut map = BTreeMap::new();
if let Some(Value::Array(rows)) = answers.get("secrets") {
for row in rows {
if let (Some(path), Some(from_env)) = (
row.get("path").and_then(Value::as_str),
row.get("from_env").and_then(Value::as_str),
) {
map.insert(path.to_string(), from_env.to_string());
}
}
}
map
}
fn existing_source_by_path(answers: &JsonMap<String, Value>) -> BTreeMap<String, String> {
let mut map = BTreeMap::new();
if let Some(Value::Array(rows)) = answers.get("secrets") {
for row in rows {
let Some(path) = row.get("path").and_then(Value::as_str) else {
continue;
};
let source = row.get("source").and_then(Value::as_str).unwrap_or(
if row.get("from_env").is_some() {
"env"
} else {
"paste"
},
);
map.insert(path.to_string(), source.to_string());
}
}
map
}
fn bundle_tenant(bundle: &ManifestBundle) -> String {
bundle
.route_binding
.as_ref()
.and_then(|binding| binding.tenant_selector.as_ref())
.map(|selector| selector.tenant.clone())
.unwrap_or_else(|| "default".to_string())
}
fn default_env_var_name(tenant: &str, key: &str) -> String {
fn sanitize(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_uppercase()
} else {
'_'
}
})
.collect()
}
let key = sanitize(key);
if tenant.is_empty() || tenant.eq_ignore_ascii_case("default") {
key
} else {
format!("{}_{}", sanitize(tenant), key)
}
}
struct DerivedSecret {
path: String,
provider_id: String,
key: String,
tenant: String,
required: bool,
bundle_ids: Vec<String>,
}
fn derive_required_secrets(
manifest_dir: &Path,
env: &str,
bundles: &[ManifestBundle],
) -> (Vec<DerivedSecret>, bool) {
let mut order: Vec<String> = Vec::new();
let mut by_path: BTreeMap<String, DerivedSecret> = BTreeMap::new();
let mut skipped = false;
for bundle in bundles {
let tenant = bundle_tenant(bundle);
let Some(raw) = bundle.bundle_path.as_ref().or_else(|| {
bundle
.revisions
.as_ref()
.and_then(|revs| revs.first())
.map(|rev| &rev.bundle_path)
}) else {
skipped = true;
continue;
};
let artifact = if raw.is_absolute() {
raw.clone()
} else {
manifest_dir.join(raw)
};
let Some(bundle_root) = artifact.parent() else {
skipped = true;
continue;
};
if !artifact.exists() {
eprintln!(
" note: bundle `{}` artifact `{}` not found — build it before the \
wizard to auto-detect its secrets (skipping)",
bundle.bundle_id,
artifact.display()
);
skipped = true;
continue;
}
let requirements = match bundle_secret_requirements(bundle_root, env, &tenant) {
Ok(requirements) => requirements,
Err(err) => {
eprintln!(
" note: could not read secrets for bundle `{}`: {err} (skipping)",
bundle.bundle_id
);
skipped = true;
continue;
}
};
for requirement in requirements {
let Some(path) = manifest_secret_path(&requirement.uri, env) else {
continue;
};
match by_path.get_mut(&path) {
Some(existing) => {
existing.required |= requirement.required;
existing.bundle_ids.push(bundle.bundle_id.clone());
}
None => {
order.push(path.clone());
by_path.insert(
path.clone(),
DerivedSecret {
path,
provider_id: requirement.provider_id,
key: requirement.key,
tenant: tenant.clone(),
required: requirement.required,
bundle_ids: vec![bundle.bundle_id.clone()],
},
);
}
}
}
}
let derived = order
.into_iter()
.map(|path| by_path.remove(&path).expect("path was just inserted"))
.collect();
(derived, skipped)
}
fn derive_and_prompt_secrets(
manifest_dir: &Path,
env: &str,
bundles: &[ManifestBundle],
existing_from_env: &BTreeMap<String, String>,
existing_source: &BTreeMap<String, String>,
) -> Result<(Vec<Value>, BTreeMap<String, SecretValue>)> {
let (derived, skipped) = derive_required_secrets(manifest_dir, env, bundles);
let preserving = skipped && !existing_source.is_empty();
if derived.is_empty() && !preserving {
println!("\nSecrets — the configured bundles declare no secrets; nothing to enter.");
return Ok((Vec::new(), BTreeMap::new()));
}
if !derived.is_empty() {
println!(
"\nSecrets — the configured bundles need {} secret(s).",
derived.len()
);
println!("For each, choose where the value comes from: a named environment");
println!("variable, or paste it in now. Pasted values are stored in the");
println!("environment's secrets store — never written to the manifest.");
}
let mut rows = Vec::with_capacity(derived.len());
let mut prefilled = BTreeMap::new();
let mut taken = BTreeSet::new();
for secret in &derived {
println!();
println!(
" {} — {} (bundle: {}){}",
secret.key,
secret.provider_id,
secret.bundle_ids.join(", "),
if secret.required { "" } else { " [optional]" }
);
println!(" secret path: {}", secret.path);
let was_paste = existing_source.get(&secret.path).map(String::as_str) == Some("paste");
match prompt_secret_source(was_paste)? {
SecretSource::Env => {
let default = existing_from_env
.get(&secret.path)
.cloned()
.unwrap_or_else(|| default_env_var_name(&secret.tenant, &secret.key));
let from_env = prompt_env_var_name(&default)?;
rows.push(
json!({ "path": secret.path.clone(), "source": "env", "from_env": from_env }),
);
}
SecretSource::Paste => {
if let Some(value) = prompt_paste_value(was_paste)? {
prefilled.insert(secret.path.clone(), SecretValue::from(value));
}
rows.push(json!({ "path": secret.path.clone(), "source": "paste" }));
}
}
taken.insert(secret.path.clone());
}
if skipped {
for (path, source) in existing_source {
if taken.contains(path.as_str()) {
continue;
}
match source.as_str() {
"paste" => {
eprintln!(
" note: keeping existing pasted secret `{path}` (bundle not rebuilt)"
);
rows.push(json!({ "path": path, "source": "paste" }));
}
_ => {
let Some(from_env) = existing_from_env.get(path) else {
continue;
};
eprintln!(" note: keeping existing secret `{path}` (bundle not rebuilt)");
rows.push(json!({ "path": path, "source": "env", "from_env": from_env }));
}
}
}
}
Ok((rows, prefilled))
}
enum SecretSource {
Env,
Paste,
}
fn prompt_secret_source(default_paste: bool) -> Result<SecretSource> {
let default = if default_paste { "2" } else { "1" };
loop {
print!(" > value from [1] environment variable or [2] paste it now? [{default}]: ");
std::io::stdout().flush()?;
let mut line = String::new();
let n = std::io::stdin().read_line(&mut line)?;
if n == 0 {
bail!("unexpected end of input while choosing a secret source");
}
let trimmed = line.trim();
let choice = if trimmed.is_empty() { default } else { trimmed };
match choice.to_ascii_lowercase().as_str() {
"1" | "env" | "e" => return Ok(SecretSource::Env),
"2" | "paste" | "p" => return Ok(SecretSource::Paste),
_ => println!(" Enter 1 (environment variable) or 2 (paste)."),
}
}
}
fn prompt_paste_value(keep_stored: bool) -> Result<Option<String>> {
loop {
let prompt = if keep_stored {
" > paste value (hidden, single line; empty keeps the stored value): "
} else {
" > paste value (hidden, single line): "
};
let value = prompt_password(prompt)?;
if value.is_empty() {
if keep_stored {
return Ok(None);
}
println!(" A value is required.");
continue;
}
return Ok(Some(value));
}
}
fn prompt_env_var_name(default: &str) -> Result<String> {
loop {
print!(" > env var name [{default}]: ");
std::io::stdout().flush()?;
let mut line = String::new();
let n = std::io::stdin().read_line(&mut line)?;
if n == 0 {
bail!("unexpected end of input while prompting for env var name");
}
let trimmed = line.trim();
let value = if trimmed.is_empty() { default } else { trimmed };
if value.is_empty() {
println!(" An environment variable name is required.");
continue;
}
return Ok(value.to_string());
}
}
fn prompt_manifest_path(env: &str) -> Result<PathBuf> {
let default = format!("./{env}.env.json");
print!("Manifest file [{default}]: ");
std::io::stdout().flush()?;
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
let trimmed = line.trim();
Ok(PathBuf::from(if trimmed.is_empty() {
default.as_str()
} else {
trimmed
}))
}
fn load_initial_answers(path: &Path, env: &str) -> Result<JsonMap<String, Value>> {
if !path.exists() {
let mut map = JsonMap::new();
map.insert("environment_id".to_string(), Value::String(env.to_string()));
return Ok(map);
}
let Some(doc) = env_mode::sniff_env_manifest(path) else {
bail!(
"`{}` exists and is not a greentic.env-manifest.v1 document; refusing to overwrite \
it — pick another path or remove the file",
path.display()
);
};
let manifest: EnvManifest = serde_json::from_value(doc)
.with_context(|| format!("`{}` is not a valid env manifest", path.display()))?;
if manifest.environment.id != env {
bail!(
"`{}` targets environment `{}` but --env resolves to `{env}`; pass `--env {}` to \
edit it (the manifest is never silently overridden)",
path.display(),
manifest.environment.id,
manifest.environment.id,
);
}
println!(
"Editing `{}` — existing answers are kept; only missing ones are asked.",
path.display()
);
let answers = manifest_to_answers(&manifest)?;
Ok(answers
.answers
.as_object()
.cloned()
.expect("manifest_to_answers always produces an Object"))
}
pub fn manifest_to_answers(manifest: &EnvManifest) -> Result<AnswerSet> {
let mut map = JsonMap::new();
map.insert(
"environment_id".to_string(),
Value::String(manifest.environment.id.clone()),
);
if let Some(url) = &manifest.environment.public_base_url {
map.insert("public_base_url".to_string(), Value::String(url.clone()));
}
map.insert(
"trust_root_bootstrap".to_string(),
Value::Bool(match manifest.trust_root {
Some(TrustRootDirective::Bootstrap) => true,
None => false,
}),
);
if !manifest.secrets.is_empty() {
let rows = manifest
.secrets
.iter()
.map(|s| match &s.from_env {
Some(from_env) => json!({"path": s.path, "source": "env", "from_env": from_env}),
None => json!({"path": s.path, "source": "paste"}),
})
.collect();
map.insert("secrets".to_string(), Value::Array(rows));
}
if !manifest.bundles.is_empty() {
let rows = manifest
.bundles
.iter()
.map(|b| -> Result<Value> {
let mut row = JsonMap::new();
row.insert("bundle_id".to_string(), Value::String(b.bundle_id.clone()));
if let Some(bp) = b.bundle_path.as_ref().or_else(|| {
b.revisions
.as_ref()
.and_then(|revs| revs.first())
.map(|rev| &rev.bundle_path)
}) {
row.insert(
"bundle_path".to_string(),
Value::String(bp.display().to_string()),
);
}
if let Some(customer) = &b.customer_id {
row.insert("customer_id".to_string(), Value::String(customer.clone()));
}
if let Some(overrides) = &b.config_overrides {
row.insert(
"config_overrides".to_string(),
Value::String(serde_json::to_string(overrides)?),
);
}
if let Some(binding) = &b.route_binding {
if !binding.hosts.is_empty() {
row.insert(
"route_hosts".to_string(),
Value::String(binding.hosts.join(", ")),
);
}
if !binding.path_prefixes.is_empty() {
row.insert(
"route_path_prefixes".to_string(),
Value::String(binding.path_prefixes.join(", ")),
);
}
if let Some(selector) = &binding.tenant_selector {
row.insert(
"route_tenant".to_string(),
Value::String(selector.tenant.clone()),
);
row.insert(
"route_team".to_string(),
Value::String(selector.team.clone()),
);
}
}
Ok(Value::Object(row))
})
.collect::<Result<Vec<_>>>()?;
map.insert("bundles".to_string(), Value::Array(rows));
}
if !manifest.messaging_endpoints.is_empty() {
let rows = manifest
.messaging_endpoints
.iter()
.map(|ep| {
let mut row = JsonMap::new();
row.insert("name".to_string(), Value::String(ep.name.clone()));
row.insert(
"provider_type".to_string(),
Value::String(ep.provider_type.clone()),
);
if !ep.links.is_empty() {
row.insert("links".to_string(), Value::String(ep.links.join(", ")));
}
if let Some(flow) = &ep.welcome_flow {
row.insert(
"welcome_bundle_id".to_string(),
Value::String(flow.bundle_id.clone()),
);
row.insert(
"welcome_pack_id".to_string(),
Value::String(flow.pack_id.clone()),
);
row.insert(
"welcome_flow_id".to_string(),
Value::String(flow.flow_id.clone()),
);
}
if !ep.secret_refs.is_empty() {
row.insert(
"secret_refs".to_string(),
Value::String(ep.secret_refs.join(", ")),
);
}
Value::Object(row)
})
.collect();
map.insert("messaging_endpoints".to_string(), Value::Array(rows));
}
Ok(AnswerSet {
form_id: ENV_MANIFEST_FORM_ID.to_string(),
spec_version: ENV_MANIFEST_FORM_VERSION.to_string(),
answers: Value::Object(map),
meta: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use greentic_deployer::cli::bundles::{RouteBindingPayload, TenantSelectorPayload};
use greentic_deployer::cli::env_manifest::{
ENV_MANIFEST_SCHEMA_V1, ManifestBundle, ManifestEndpoint, ManifestEnvironment,
ManifestSecret, ManifestWelcomeFlow,
};
use std::collections::BTreeMap;
fn full_manifest() -> EnvManifest {
EnvManifest {
schema: ENV_MANIFEST_SCHEMA_V1.to_string(),
environment: ManifestEnvironment {
id: "demo".to_string(),
public_base_url: Some("https://demo.example.com".to_string()),
name: None,
region: None,
tenant_org_id: None,
listen_addr: None,
},
trust_root: Some(TrustRootDirective::Bootstrap),
secrets: vec![ManifestSecret {
path: "default/_/messaging-telegram/telegram_bot_token".to_string(),
from_env: Some("DEMO_BOT_TOKEN".to_string()),
}],
bundles: vec![ManifestBundle {
bundle_id: "realbot".to_string(),
bundle_path: Some(PathBuf::from("./bundles/realbot.gtbundle")),
revisions: None,
revenue_share: None,
status: None,
customer_id: Some("acme".to_string()),
config_overrides: Some(BTreeMap::from([(
"pack-a".to_string(),
BTreeMap::from([("greeting".to_string(), json!("hi"))]),
)])),
route_binding: Some(RouteBindingPayload {
hosts: vec![
"demo.example.com".to_string(),
"alt.example.com".to_string(),
],
path_prefixes: vec!["/bot".to_string(), "/api".to_string()],
tenant_selector: Some(TenantSelectorPayload {
tenant: "acme".to_string(),
team: "support".to_string(),
}),
}),
}],
packs: Vec::new(),
extensions: Vec::new(),
messaging_endpoints: vec![ManifestEndpoint {
name: "demo-telegram".to_string(),
provider_type: "messaging.telegram.bot".to_string(),
links: vec!["realbot".to_string(), "auditbot".to_string()],
welcome_flow: Some(ManifestWelcomeFlow {
bundle_id: "realbot".to_string(),
pack_id: "pack-a".to_string(),
flow_id: "welcome".to_string(),
}),
secret_refs: vec![
"secret://local/realbot/telegram/token".to_string(),
"secret://local/realbot/telegram/webhook".to_string(),
],
}],
}
}
fn round_trip(manifest: &EnvManifest) -> EnvManifest {
let answers = manifest_to_answers(manifest).expect("manifest converts to answers");
answers_to_manifest(&answers).expect("answers convert back to a manifest")
}
#[test]
fn full_manifest_round_trips_through_the_deployer_converter() {
let original = full_manifest();
let back = round_trip(&original);
assert_eq!(
serde_json::to_value(&original).unwrap(),
serde_json::to_value(&back).unwrap(),
);
}
#[test]
fn paste_and_env_secrets_round_trip_through_the_converter() {
let mut manifest = full_manifest();
manifest.secrets = vec![
ManifestSecret {
path: "default/_/messaging-telegram/telegram_bot_token".to_string(),
from_env: Some("DEMO_BOT_TOKEN".to_string()),
},
ManifestSecret {
path: "default/_/messaging-slack/slack_bot_token".to_string(),
from_env: None,
},
];
let back = round_trip(&manifest);
assert_eq!(back.secrets[0].from_env.as_deref(), Some("DEMO_BOT_TOKEN"));
assert_eq!(back.secrets[1].from_env, None);
let answers = manifest_to_answers(&manifest).unwrap();
let rows = answers.answers["secrets"].as_array().unwrap();
assert_eq!(rows[0]["source"], "env");
assert_eq!(rows[0]["from_env"], "DEMO_BOT_TOKEN");
assert_eq!(rows[1]["source"], "paste");
assert!(rows[1].get("from_env").is_none());
}
#[test]
fn existing_source_by_path_reads_and_infers() {
let answers = json!({
"secrets": [
{"path": "a/_/p/tok", "source": "paste"},
{"path": "b/_/p/tok", "source": "env", "from_env": "B"},
{"path": "c/_/p/tok", "from_env": "C"},
{"path": "d/_/p/tok"}
]
});
let map = existing_source_by_path(answers.as_object().unwrap());
assert_eq!(map.get("a/_/p/tok").map(String::as_str), Some("paste"));
assert_eq!(map.get("b/_/p/tok").map(String::as_str), Some("env"));
assert_eq!(map.get("c/_/p/tok").map(String::as_str), Some("env"));
assert_eq!(map.get("d/_/p/tok").map(String::as_str), Some("paste"));
}
#[test]
fn minimal_manifest_round_trips() {
let original = EnvManifest {
schema: ENV_MANIFEST_SCHEMA_V1.to_string(),
environment: ManifestEnvironment {
id: "local".to_string(),
public_base_url: None,
name: None,
region: None,
tenant_org_id: None,
listen_addr: None,
},
trust_root: None,
secrets: Vec::new(),
packs: Vec::new(),
bundles: Vec::new(),
extensions: Vec::new(),
messaging_endpoints: Vec::new(),
};
let back = round_trip(&original);
assert_eq!(
serde_json::to_value(&original).unwrap(),
serde_json::to_value(&back).unwrap(),
);
}
#[test]
fn empty_config_overrides_stays_an_explicit_clear() {
let mut manifest = full_manifest();
manifest.bundles[0].config_overrides = Some(BTreeMap::new());
let back = round_trip(&manifest);
assert_eq!(back.bundles[0].config_overrides, Some(BTreeMap::new()));
}
#[test]
fn generated_answers_pass_the_form_validation() {
let answers = manifest_to_answers(&full_manifest()).unwrap();
let result = qa_spec::validate(&manifest_form_spec(), &answers.answers);
assert!(
result.valid,
"errors: {:?}, missing: {:?}, unknown: {:?}",
result.errors, result.missing_required, result.unknown_fields
);
}
#[test]
fn load_initial_answers_seeds_env_for_new_files() {
let dir = tempfile::tempdir().unwrap();
let map = load_initial_answers(&dir.path().join("demo.env.json"), "demo").unwrap();
assert_eq!(map.get("environment_id"), Some(&json!("demo")));
assert_eq!(map.len(), 1, "only the env id is pre-seeded: {map:?}");
}
#[test]
fn load_initial_answers_refuses_files_it_does_not_own() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("notes.json");
std::fs::write(&path, r#"{"some": "other document"}"#).unwrap();
let err = load_initial_answers(&path, "demo").unwrap_err();
assert!(
format!("{err:#}").contains("refusing to overwrite"),
"got: {err:#}"
);
}
#[test]
fn load_initial_answers_rejects_env_mismatch() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("demo.env.json");
let manifest = serde_json::to_string(&full_manifest()).unwrap();
std::fs::write(&path, manifest).unwrap();
let err = load_initial_answers(&path, "local").unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("`demo`"), "names the manifest env: {msg}");
assert!(msg.contains("`local`"), "names the --env value: {msg}");
}
#[test]
fn load_initial_answers_preloads_a_matching_manifest() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("demo.env.json");
std::fs::write(&path, serde_json::to_string(&full_manifest()).unwrap()).unwrap();
let map = load_initial_answers(&path, "demo").unwrap();
assert_eq!(map.get("environment_id"), Some(&json!("demo")));
assert!(map.get("secrets").is_some_and(Value::is_array));
assert!(map.get("bundles").is_some_and(Value::is_array));
}
#[test]
fn wizard_is_interactive_only() {
let err = run_env_wizard("demo", false, false, true).unwrap_err();
assert!(
format!("{err:#}").contains("--answers"),
"points at the headless alternative: {err:#}"
);
}
#[test]
fn spec_without_question_drops_only_the_named_question() {
let spec = manifest_form_spec();
let reduced = spec_without_question(&spec, "secrets");
assert!(
spec.questions.iter().any(|q| q.id == "secrets"),
"fixture has the secrets question"
);
assert!(
reduced.questions.iter().all(|q| q.id != "secrets"),
"secrets question is dropped"
);
assert_eq!(
reduced.questions.len(),
spec.questions.len() - 1,
"exactly one question removed"
);
}
#[test]
fn existing_from_env_by_path_indexes_preloaded_secrets() {
let answers = json!({
"secrets": [
{"path": "legal/_/messaging-telegram/telegram_bot_token", "from_env": "LEGAL_TOK"},
{"path": "acct/_/messaging-telegram/telegram_bot_token", "from_env": "ACCT_TOK"}
]
});
let map = existing_from_env_by_path(answers.as_object().unwrap());
assert_eq!(
map.get("legal/_/messaging-telegram/telegram_bot_token")
.map(String::as_str),
Some("LEGAL_TOK")
);
assert_eq!(map.len(), 2);
assert!(existing_from_env_by_path(&JsonMap::new()).is_empty());
}
#[test]
fn default_env_var_name_prefixes_non_default_tenant() {
assert_eq!(
default_env_var_name("legal", "telegram_bot_token"),
"LEGAL_TELEGRAM_BOT_TOKEN"
);
assert_eq!(
default_env_var_name("default", "telegram_bot_token"),
"TELEGRAM_BOT_TOKEN"
);
assert_eq!(default_env_var_name("", "api_key"), "API_KEY");
assert_eq!(
default_env_var_name("my-tenant", "bot.token"),
"MY_TENANT_BOT_TOKEN"
);
}
fn bundle_from(value: Value) -> ManifestBundle {
serde_json::from_value(value).expect("valid manifest bundle")
}
fn built_bundle_with_telegram_secret(root: &Path, workspace: &str) {
let pack_dir = root.join(workspace).join("packs/messaging-telegram");
std::fs::create_dir_all(pack_dir.join("assets")).unwrap();
std::fs::write(pack_dir.join("pack.yaml"), "id: messaging-telegram\n").unwrap();
std::fs::write(
pack_dir.join("assets/secret-requirements.json"),
r#"[{"key":"TELEGRAM_BOT_TOKEN","required":true}]"#,
)
.unwrap();
std::fs::write(
root.join(workspace).join("realbot.gtbundle"),
b"squashfs-placeholder",
)
.unwrap();
}
#[test]
fn derive_required_secrets_reads_built_bundle_packs() {
let dir = tempfile::tempdir().unwrap();
built_bundle_with_telegram_secret(dir.path(), "ws-legal");
let bundle = bundle_from(json!({
"bundle_id": "realbot-legal",
"bundle_path": "ws-legal/realbot.gtbundle",
"route_binding": {
"hosts": [],
"path_prefixes": ["/legal"],
"tenant_selector": {"tenant": "legal", "team": "default"}
}
}));
let (derived, skipped) =
derive_required_secrets(dir.path(), "local", std::slice::from_ref(&bundle));
assert!(!skipped);
assert_eq!(derived.len(), 1);
assert_eq!(
derived[0].path,
"legal/_/messaging-telegram/telegram_bot_token"
);
assert_eq!(derived[0].tenant, "legal");
assert_eq!(derived[0].bundle_ids, vec!["realbot-legal".to_string()]);
}
#[test]
fn derive_required_secrets_dedups_same_path_across_bundles() {
let dir = tempfile::tempdir().unwrap();
built_bundle_with_telegram_secret(dir.path(), "ws-a");
built_bundle_with_telegram_secret(dir.path(), "ws-b");
let bundles = [
bundle_from(json!({
"bundle_id": "a", "bundle_path": "ws-a/realbot.gtbundle",
"route_binding": {"hosts": [], "path_prefixes": ["/a"],
"tenant_selector": {"tenant": "shared", "team": "default"}}
})),
bundle_from(json!({
"bundle_id": "b", "bundle_path": "ws-b/realbot.gtbundle",
"route_binding": {"hosts": [], "path_prefixes": ["/b"],
"tenant_selector": {"tenant": "shared", "team": "default"}}
})),
];
let (derived, _) = derive_required_secrets(dir.path(), "local", &bundles);
assert_eq!(derived.len(), 1, "deduped by path");
assert_eq!(
derived[0].bundle_ids,
vec!["a".to_string(), "b".to_string()]
);
}
#[test]
fn derive_required_secrets_skips_unbuilt_bundle() {
let dir = tempfile::tempdir().unwrap();
let bundle = bundle_from(json!({
"bundle_id": "missing",
"bundle_path": "ws-missing/realbot.gtbundle"
}));
let (derived, skipped) =
derive_required_secrets(dir.path(), "local", std::slice::from_ref(&bundle));
assert!(derived.is_empty());
assert!(skipped, "missing artifact flags skipped");
}
fn list_field_ids(spec: &FormSpec, id: &str) -> Vec<String> {
let mut ids: Vec<String> = spec
.questions
.iter()
.find(|q| q.id == id)
.and_then(|q| q.list.as_ref())
.map(|list| list.fields.iter().map(|f| f.id.clone()).collect())
.unwrap_or_default();
ids.sort();
ids
}
#[test]
fn spec_for_mode_basic_hides_only_the_curated_columns() {
let basic = spec_for_mode(&manifest_form_spec(), false);
assert_eq!(
list_field_ids(&basic, "bundles"),
[
"bundle_id",
"bundle_path",
"route_path_prefixes",
"route_team",
"route_tenant",
],
"basic bundles keep id/path + route path/tenant/team only"
);
assert_eq!(
list_field_ids(&basic, "messaging_endpoints"),
["links", "name", "provider_type"],
"basic endpoints keep name/provider_type/links only"
);
}
#[test]
fn spec_for_mode_advanced_is_a_noop() {
let spec = manifest_form_spec();
let advanced = spec_for_mode(&spec, true);
assert_eq!(
list_field_ids(&advanced, "bundles"),
list_field_ids(&spec, "bundles"),
);
assert_eq!(
list_field_ids(&advanced, "messaging_endpoints"),
list_field_ids(&spec, "messaging_endpoints"),
);
for col in ["customer_id", "config_overrides", "route_hosts"] {
assert!(
list_field_ids(&advanced, "bundles").contains(&col.to_string()),
"advanced keeps bundles.{col}"
);
}
for col in [
"welcome_bundle_id",
"welcome_pack_id",
"welcome_flow_id",
"secret_refs",
] {
assert!(
list_field_ids(&advanced, "messaging_endpoints").contains(&col.to_string()),
"advanced keeps messaging_endpoints.{col}"
);
}
}
#[test]
fn basic_spec_answers_convert_to_a_valid_manifest() {
let basic = spec_for_mode(&manifest_form_spec(), false);
let raw = json!({
"environment_id": "local",
"trust_root_bootstrap": true,
"bundles": [{
"bundle_id": "legal",
"bundle_path": "ws-legal/realbot.gtbundle",
"route_path_prefixes": "/legal",
"route_tenant": "legal",
"route_team": "default"
}],
"messaging_endpoints": [{
"name": "legal",
"provider_type": "messaging.telegram.bot",
"links": "legal"
}]
});
let set = answer_set(raw.as_object().unwrap().clone());
let report = qa_spec::validate(&basic, &set.answers);
assert!(
report.valid,
"basic answers must pass the basic spec: {report:?}"
);
let manifest = answers_to_manifest(&set).expect("converts");
manifest.validate_shape().expect("valid shape");
let bundle = &manifest.bundles[0];
assert!(bundle.customer_id.is_none());
assert!(bundle.config_overrides.is_none());
let rb = bundle.route_binding.as_ref().expect("route binding built");
assert_eq!(rb.path_prefixes, ["/legal"]);
assert!(rb.hosts.is_empty(), "route_hosts stays empty in basic mode");
assert!(manifest.messaging_endpoints[0].welcome_flow.is_none());
assert!(manifest.messaging_endpoints[0].secret_refs.is_empty());
}
}