use anyhow::{Context, Result, bail};
use std::fs;
use greentic_setup::cli_args::{BundleCommand, Cli, Command};
use greentic_setup::cli_commands;
use greentic_setup::cli_helpers::{
SetupOutputTarget, complete_loaded_answers_with_prompts, copy_dir_recursive,
ensure_deployment_targets_present, ensure_required_setup_answers_present,
maybe_start_cli_setup_tunnel, prompt_setup_params, resolve_bundle_source,
resolve_setup_scope_with_bundle, run_interactive_wizard, setup_output_target,
};
use greentic_setup::cli_i18n::CliI18n;
use greentic_setup::engine::{LoadedAnswers, SetupConfig, SetupRequest};
use greentic_setup::plan::TenantSelection;
use greentic_setup::platform_setup::StaticRoutesPolicy;
use greentic_setup::{SetupEngine, SetupMode, bundle, gtbundle};
fn get_i18n() -> &'static CliI18n {
get_i18n_with_locale(None)
}
fn get_i18n_with_locale(locale: Option<&str>) -> &'static CliI18n {
use std::sync::OnceLock;
static I18N: OnceLock<CliI18n> = OnceLock::new();
I18N.get_or_init(|| CliI18n::from_request(locale).expect("failed to initialize i18n"))
}
fn init_i18n(locale: Option<&str>) {
let _ = get_i18n_with_locale(locale);
}
fn main() -> Result<()> {
let matches = <Cli as clap::CommandFactory>::command().get_matches();
let cli =
<Cli as clap::FromArgMatches>::from_arg_matches(&matches).unwrap_or_else(|err| err.exit());
let env_explicit = matches.value_source("env") == Some(clap::parser::ValueSource::CommandLine);
init_i18n(cli.locale.as_deref());
let i18n = get_i18n();
if cli.command.is_none()
&& let Some(answers_path) = &cli.answers
&& let Some(manifest) = greentic_setup::env_mode::sniff_env_manifest(answers_path)
{
if cli.bundle.is_some() {
bail!(
"--answers {} is an environment manifest; it configures an environment, \
not a bundle — drop the bundle path",
answers_path.display()
);
}
let env = greentic_setup::resolve_env(Some(&cli.env));
return greentic_setup::env_mode::run_env_apply(
answers_path,
&manifest,
&env,
cli.dry_run,
cli.non_interactive,
Default::default(),
);
}
if cli.command.is_none()
&& cli.bundle.is_none()
&& cli.answers.is_none()
&& cli.emit_answers.is_none()
&& env_explicit
{
let env = greentic_setup::resolve_env(Some(&cli.env));
return greentic_setup::env_wizard::run_env_wizard(
&env,
cli.advanced,
cli.dry_run,
cli.non_interactive,
i18n,
);
}
#[cfg(feature = "ui")]
if cli.ui && !cli.no_ui && !cli.non_interactive && cli.command.is_none() {
return run_ui_mode(&cli, i18n);
}
match cli.command {
Some(Command::Doctor(args)) => cli_commands::doctor(args, i18n),
Some(Command::Bundle(cmd)) => match *cmd {
BundleCommand::Init(args) => cli_commands::init(args, i18n),
BundleCommand::Add(args) => cli_commands::add(args, i18n),
BundleCommand::Setup(mut args) => {
args.non_interactive = cli.non_interactive;
cli_commands::setup(args, i18n)
}
BundleCommand::Update(mut args) => {
args.non_interactive = cli.non_interactive;
cli_commands::update(args, i18n)
}
BundleCommand::SetupStatus(args) => cli_commands::setup_status(args, i18n),
BundleCommand::SetupNext(args) => cli_commands::setup_next(args, i18n),
BundleCommand::SetupRetry(args) => cli_commands::setup_retry(args, i18n),
BundleCommand::SetupReset(args) => cli_commands::setup_reset(args, i18n),
BundleCommand::SetupMigrate(args) => cli_commands::setup_migrate(args, i18n),
BundleCommand::Remove(args) => cli_commands::remove(args, i18n),
BundleCommand::Build(args) => cli_commands::build(args, i18n),
BundleCommand::List(args) => cli_commands::list(args, i18n),
BundleCommand::Status(args) => cli_commands::status(args, i18n),
},
None => run_simple_setup(&cli, i18n),
}
}
fn run_simple_setup(cli: &Cli, i18n: &CliI18n) -> Result<()> {
let (bundle_path, mut tenant, mut team, mut env, advanced) = if cli.bundle.is_none()
&& cli.answers.is_none()
&& cli.emit_answers.is_none()
&& !cli.dry_run
{
let params = prompt_setup_params(cli, i18n)?;
(
params.bundle,
params.tenant,
params.team,
params.env,
params.advanced,
)
} else {
let path = cli.bundle.clone().ok_or_else(|| {
anyhow::anyhow!(
"{}\n\n{}",
i18n.t("cli.simple.bundle_required"),
i18n.t("cli.help.for_help")
)
})?;
(
path,
cli.tenant.clone(),
cli.team.clone(),
cli.env.clone(),
cli.advanced,
)
};
env = greentic_setup::resolve_env(Some(&env));
let bundle_dir = resolve_bundle_source(&bundle_path, i18n)?;
bundle::validate_bundle_exists(&bundle_dir).context(i18n.t("cli.error.invalid_bundle"))?;
let loader_engine = SetupEngine::new(SetupConfig {
tenant: tenant.clone(),
team: team.clone(),
env: env.clone(),
offline: false,
verbose: true,
});
let loaded_answers = if let Some(answers_path) = &cli.answers {
println!(
"{}",
i18n.tf(
"setup.answers.loaded",
&[&answers_path.display().to_string()]
)
);
loader_engine
.load_answers(answers_path, cli.key.as_deref(), !cli.non_interactive)
.context(i18n.t("cli.error.failed_read_answers"))?
} else if cli.non_interactive {
anyhow::bail!("{}", i18n.t("cli.error.answers_required"));
} else if cli.emit_answers.is_some() || cli.dry_run {
LoadedAnswers::default()
} else {
println!("{}", i18n.t("cli.simple.interactive_mode"));
println!();
run_interactive_wizard(&bundle_dir, &tenant, team.as_deref(), &env, advanced)?
};
(tenant, team, env) =
resolve_setup_scope_with_bundle(tenant, team, env, &loaded_answers, &bundle_dir);
println!("{}", i18n.t("cli.simple.header"));
println!(
"{}",
i18n.tf(
"cli.bundle.add.bundle",
&[&bundle_path.display().to_string()]
)
);
println!("{}", i18n.tf("cli.bundle.add.tenant", &[&tenant]));
println!(
"{}",
i18n.tf(
"cli.bundle.add.team",
&[team.as_deref().unwrap_or("default")]
)
);
println!("{}", i18n.tf("cli.bundle.add.env", &[&env]));
println!();
let mut loaded_answers = if cli.answers.is_some() {
complete_loaded_answers_with_prompts(
&bundle_dir,
&tenant,
team.as_deref(),
&env,
advanced,
cli.non_interactive,
loaded_answers,
)?
} else {
loaded_answers
};
if cli.answers.is_some() {
ensure_deployment_targets_present(&bundle_dir, &loaded_answers)?;
}
let is_dry_run = cli.dry_run || cli.emit_answers.is_some();
let mut no_ui_oauth_server = if !is_dry_run {
Some(
greentic_setup::no_ui_oauth::start_callback_server(&bundle_dir, &env)
.context("failed to start no-UI OAuth callback server")?,
)
} else {
None
};
let _setup_tunnel = if !is_dry_run {
let local_base_url = no_ui_oauth_server
.as_ref()
.map(|server| server.local_base_url.as_str())
.unwrap_or("http://127.0.0.1:1");
let tunnel = maybe_start_cli_setup_tunnel(&mut loaded_answers, local_base_url)
.context("failed to start setup tunnel")?;
if let Some(tunnel) = tunnel.as_ref() {
println!("Setup tunnel public_base_url: {}", tunnel.public_base_url);
} else {
no_ui_oauth_server = None;
}
tunnel
} else {
None
};
if cli.non_interactive {
ensure_required_setup_answers_present(&bundle_dir, &loaded_answers)
.context("Missing required answers in --non-interactive mode")?;
}
let request = SetupRequest {
bundle: bundle_dir.clone(),
bundle_name: greentic_setup::bundle::read_bundle_name(&bundle_dir)
.ok()
.flatten(),
tenants: vec![TenantSelection {
tenant: tenant.clone(),
team: team.clone(),
allow_paths: Vec::new(),
}],
static_routes: StaticRoutesPolicy::normalize(
loaded_answers.platform_setup.static_routes.as_ref(),
&env,
)
.context(i18n.t("cli.error.failed_read_answers"))?,
deployment_targets: loaded_answers.platform_setup.deployment_targets,
tunnel: loaded_answers.platform_setup.tunnel,
telemetry: loaded_answers.platform_setup.telemetry,
setup_answers: loaded_answers.setup_answers,
..Default::default()
};
let engine = SetupEngine::new(SetupConfig {
tenant: tenant.clone(),
team: team.clone(),
env: env.clone(),
offline: false,
verbose: true,
});
let plan = engine
.plan(SetupMode::Create, &request, is_dry_run)
.context(i18n.t("cli.error.failed_build_plan"))?;
engine.print_plan(&plan);
if let Some(emit_path) = &cli.emit_answers {
engine
.emit_answers(&plan, emit_path, cli.key.as_deref(), !cli.non_interactive)
.context(i18n.t("cli.error.failed_emit_answers"))?;
println!(
"\n{}",
i18n.tf(
"cli.bundle.setup.emit_written",
&[&emit_path.display().to_string()]
)
);
println!(
"{}",
i18n.tf(
"cli.simple.emit_usage",
&[
&emit_path.display().to_string(),
&bundle_path.display().to_string()
]
)
);
return Ok(());
}
if cli.dry_run {
println!(
"\n{}",
i18n.tf("cli.simple.dry_run", &[&bundle_path.display().to_string()])
);
return Ok(());
}
let report = engine
.execute(&plan)
.context(i18n.t("cli.error.failed_execute_plan"))?;
cli_commands::print_pending_setup_actions(&report.pending_setup_actions);
cli_commands::wait_for_pending_oauth_callbacks(
no_ui_oauth_server,
&report.pending_setup_actions,
)?;
if !cli.non_interactive {
cli_commands::execute_pending_oauth_device_actions(
&bundle_dir,
&env,
&report.pending_setup_actions,
)?;
}
if let Some(output_target) = setup_output_target(&bundle_path)? {
match output_target {
SetupOutputTarget::Directory(output_bundle) => {
if output_bundle.exists() {
if output_bundle.is_dir() {
fs::remove_dir_all(&output_bundle).with_context(|| {
format!(
"failed to replace existing bundle directory {}",
output_bundle.display()
)
})?;
} else {
fs::remove_file(&output_bundle).with_context(|| {
format!(
"failed to replace existing bundle file {}",
output_bundle.display()
)
})?;
}
}
copy_dir_recursive(&bundle_dir, &output_bundle, false)
.context("failed to write configured local bundle directory")?;
println!("Configured bundle written to: {}", output_bundle.display());
}
SetupOutputTarget::Archive(output_bundle) => {
gtbundle::create_gtbundle(&bundle_dir, &output_bundle)
.context("failed to write configured .gtbundle archive")?;
println!("Configured bundle written to: {}", output_bundle.display());
}
}
}
println!(
"\n{}",
i18n.tf(
"setup.execute.success",
&[&bundle_path.display().to_string()]
)
);
Ok(())
}
#[cfg(feature = "ui")]
fn run_ui_mode(cli: &Cli, i18n: &CliI18n) -> Result<()> {
let bundle_path = cli.bundle.clone().ok_or_else(|| {
anyhow::anyhow!(
"{}\n\n{}",
i18n.t("cli.simple.bundle_required"),
i18n.t("cli.help.for_help")
)
})?;
let bundle_dir = resolve_bundle_source(&bundle_path, i18n)?;
bundle::validate_bundle_exists(&bundle_dir).context(i18n.t("cli.error.invalid_bundle"))?;
let output_target = setup_output_target(&bundle_path)?;
let (prefill_answers, answers_tenant, answers_team, answers_env) = if let Some(answers_path) =
&cli.answers
{
println!(
"{}",
i18n.tf(
"setup.answers.loaded",
&[&answers_path.display().to_string()],
),
);
let loader_engine = SetupEngine::new(SetupConfig {
tenant: cli.tenant.clone(),
team: cli.team.clone(),
env: greentic_setup::resolve_env(Some(&cli.env)),
offline: false,
verbose: false,
});
let loaded = loader_engine
.load_answers(answers_path, cli.key.as_deref(), true)
.context(i18n.t("cli.error.failed_read_answers"))?;
if let Some(tunnel) = loaded.platform_setup.tunnel.as_ref() {
let _ = greentic_setup::platform_setup::persist_tunnel_artifact(&bundle_dir, tunnel);
}
let mut prefill = loaded.setup_answers;
prefill.insert(
"platform_setup".to_string(),
serde_json::to_value(&loaded.platform_setup)
.context("failed to serialize platform setup answers for UI prefill")?,
);
(Some(prefill), loaded.tenant, loaded.team, loaded.env)
} else {
(None, None, None, None)
};
let scope_from_answers =
answers_tenant.is_some() || answers_team.is_some() || answers_env.is_some();
let tenant = answers_tenant.unwrap_or_else(|| cli.tenant.clone());
let team = answers_team.or_else(|| cli.team.clone());
let env = greentic_setup::resolve_env(Some(&answers_env.unwrap_or_else(|| cli.env.clone())));
let rt = tokio::runtime::Runtime::new().context("failed to create tokio runtime")?;
rt.block_on(greentic_setup::ui::launch(
&bundle_dir,
&tenant,
team.as_deref(),
&env,
cli.advanced,
cli.locale.as_deref(),
prefill_answers,
scope_from_answers,
output_target,
))
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn bundle_build_parses_as_subcommand_even_with_default_ui_enabled() {
let cli = Cli::parse_from([
"greentic-setup",
"bundle",
"build",
"--bundle",
"./demo-bundle",
"--out",
"/tmp/demo.gtbundle",
]);
match cli.command {
Some(Command::Bundle(cmd)) => match *cmd {
BundleCommand::Build(args) => {
assert_eq!(
args.bundle.as_deref(),
Some(std::path::Path::new("./demo-bundle"))
);
assert_eq!(args.out, std::path::PathBuf::from("/tmp/demo.gtbundle"));
}
other => panic!("expected bundle build subcommand, got {other:?}"),
},
other => panic!("expected bundle build subcommand, got {other:?}"),
}
}
#[test]
fn non_interactive_parses_as_global_simple_flag() {
let cli = Cli::parse_from([
"greentic-setup",
"--non-interactive",
"--answers",
"answers.json",
"./demo-bundle",
]);
assert!(cli.non_interactive);
assert_eq!(
cli.answers.as_deref(),
Some(std::path::Path::new("answers.json"))
);
}
#[test]
fn non_interactive_parses_after_bundle_subcommand() {
let cli = Cli::parse_from([
"greentic-setup",
"bundle",
"setup",
"--non-interactive",
"--answers",
"answers.json",
]);
assert!(cli.non_interactive);
match cli.command {
Some(Command::Bundle(cmd)) => match *cmd {
BundleCommand::Setup(args) => {
assert_eq!(
args.answers.as_deref(),
Some(std::path::Path::new("answers.json"))
);
}
other => panic!("expected bundle setup subcommand, got {other:?}"),
},
other => panic!("expected bundle setup subcommand, got {other:?}"),
}
}
#[test]
fn doctor_parses_as_top_level_subcommand() {
let cli = Cli::parse_from([
"greentic-setup",
"doctor",
"./demo",
"--json",
"--stage",
"locks",
]);
match cli.command {
Some(Command::Doctor(args)) => {
assert_eq!(args.bundle, Some(std::path::PathBuf::from("./demo")));
assert!(args.json);
assert!(!args.show_info);
assert_eq!(
args.stage,
Some(greentic_setup::cli_args::DoctorStageArg::Locks)
);
}
other => panic!("expected doctor subcommand, got {other:?}"),
}
}
#[test]
fn doctor_provider_parses_as_top_level_subcommand() {
let cli = Cli::parse_from([
"greentic-setup",
"doctor",
"--json",
"provider",
"./messaging-teams.gtpack",
]);
match cli.command {
Some(Command::Doctor(args)) => {
assert!(args.json);
match args.command {
Some(greentic_setup::cli_args::DoctorCommand::Provider(provider)) => {
assert_eq!(
provider.pack,
std::path::PathBuf::from("./messaging-teams.gtpack")
);
}
other => panic!("expected doctor provider subcommand, got {other:?}"),
}
}
other => panic!("expected doctor subcommand, got {other:?}"),
}
}
#[test]
fn bundle_setup_status_parses() {
let cli = Cli::parse_from([
"greentic-setup",
"bundle",
"setup-status",
"messaging-teams",
"--bundle",
"./demo",
"--format",
"json",
]);
match cli.command {
Some(Command::Bundle(cmd)) => match *cmd {
BundleCommand::SetupStatus(args) => {
assert_eq!(args.provider_id, "messaging-teams");
assert_eq!(args.bundle, Some(std::path::PathBuf::from("./demo")));
assert_eq!(args.format, "json");
}
other => panic!("expected setup-status subcommand, got {other:?}"),
},
other => panic!("expected bundle setup-status subcommand, got {other:?}"),
}
}
#[test]
fn bundle_setup_migrate_parses() {
let cli = Cli::parse_from([
"greentic-setup",
"bundle",
"setup-migrate",
"messaging-teams",
"--bundle",
"./demo",
"--json",
]);
match cli.command {
Some(Command::Bundle(cmd)) => match *cmd {
BundleCommand::SetupMigrate(args) => {
assert_eq!(args.provider_id, "messaging-teams");
assert_eq!(args.bundle, Some(std::path::PathBuf::from("./demo")));
assert!(args.json);
}
other => panic!("expected setup-migrate subcommand, got {other:?}"),
},
other => panic!("expected bundle setup-migrate subcommand, got {other:?}"),
}
}
#[test]
fn bundle_setup_next_parses() {
let cli = Cli::parse_from([
"greentic-setup",
"bundle",
"setup-next",
"messaging-teams",
"--bundle",
"./demo",
"--format",
"json",
"--dry-run",
]);
match cli.command {
Some(Command::Bundle(cmd)) => match *cmd {
BundleCommand::SetupNext(args) => {
assert_eq!(args.provider_id, "messaging-teams");
assert_eq!(args.bundle, Some(std::path::PathBuf::from("./demo")));
assert_eq!(args.format, "json");
assert!(args.dry_run);
}
other => panic!("expected setup-next subcommand, got {other:?}"),
},
other => panic!("expected bundle setup-next subcommand, got {other:?}"),
}
}
#[test]
fn bundle_setup_retry_parses() {
let cli = Cli::parse_from([
"greentic-setup",
"bundle",
"setup-retry",
"messaging-teams",
"--bundle",
"./demo",
"--step",
"publish",
"--json",
]);
match cli.command {
Some(Command::Bundle(cmd)) => match *cmd {
BundleCommand::SetupRetry(args) => {
assert_eq!(args.provider_id, "messaging-teams");
assert_eq!(args.bundle, Some(std::path::PathBuf::from("./demo")));
assert_eq!(args.step.as_deref(), Some("publish"));
assert!(args.json);
}
other => panic!("expected setup-retry subcommand, got {other:?}"),
},
other => panic!("expected bundle setup-retry subcommand, got {other:?}"),
}
}
#[test]
fn bundle_setup_reset_parses() {
let cli = Cli::parse_from([
"greentic-setup",
"bundle",
"setup-reset",
"messaging-teams",
"--bundle",
"./demo",
"--yes",
"--json",
]);
match cli.command {
Some(Command::Bundle(cmd)) => match *cmd {
BundleCommand::SetupReset(args) => {
assert_eq!(args.provider_id, "messaging-teams");
assert_eq!(args.bundle, Some(std::path::PathBuf::from("./demo")));
assert!(args.yes);
assert!(args.json);
}
other => panic!("expected setup-reset subcommand, got {other:?}"),
},
other => panic!("expected bundle setup-reset subcommand, got {other:?}"),
}
}
}