Skip to content
8 changes: 7 additions & 1 deletion tvc/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ use clap::{Parser, Subcommand};
#[derive(Debug, Parser)]
#[command(about = "CLI for building with Turnkey Verifiable Cloud", long_about = None)]
pub struct Cli {
/// Disable all interactive prompts and suppress non-essential output.
/// Fails if required input is missing. Set TVC_NO_INPUT=true in CI/CD environments.
#[arg(long, global = true, env = "TVC_NO_INPUT")]
no_input: bool,

#[command(subcommand)]
command: Commands,
}
Expand All @@ -15,6 +20,7 @@ impl Cli {
/// Run the CLI.
pub async fn run() -> anyhow::Result<()> {
let args = Cli::parse();
let no_input = args.no_input;

match args.command {
Commands::Deploy { command } => match command {
Expand All @@ -28,7 +34,7 @@ impl Cli {
AppCommands::Create(args) => commands::app::create::run(args).await,
AppCommands::Init(args) => commands::app::init::run(args).await,
},
Commands::Login(args) => commands::login::run(args).await,
Commands::Login(args) => commands::login::run(args, no_input).await,
}
}
}
Expand Down
159 changes: 112 additions & 47 deletions tvc/src/commands/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,56 +15,79 @@ use turnkey_client::generated::GetWhoamiRequest;
#[derive(Debug, ClapArgs)]
#[command(about, long_about = None)]
pub struct Args {
/// Organization alias or ID to log in with.
/// Organization alias or ID to log in with (select existing org).
/// If not provided, will prompt interactively.
#[arg(long)]
#[arg(long, conflicts_with = "org_id")]
pub org: Option<String>,

/// Organization ID for creating a new org config.
/// Use with --alias and --api-env for fully non-interactive setup.
#[arg(long, env = "TVC_ORG_ID")]
pub org_id: Option<String>,

/// Alias for the organization config (used with --org-id).
#[arg(
long,
env = "TVC_ORG_ALIAS",
default_value = "default",
requires = "org_id"
)]
pub alias: String,

/// API environment to use (used with --org-id).
#[arg(long, env = "TVC_API_ENV", value_parser = ["prod", "preprod", "dev", "local"], requires = "org_id")]
pub api_env: Option<String>,
}

/// Run the login command.
pub async fn run(args: Args) -> anyhow::Result<()> {
pub async fn run(args: Args, no_input: bool) -> anyhow::Result<()> {
// Load existing config
let mut config = Config::load().await?;

// Select or create org
let (alias, org_config) = select_or_create_org(&mut config, args.org.as_deref()).await?;
let (alias, org_config) = select_or_create_org(&mut config, &args, no_input).await?;

println!("Selected org: {} ({})", alias, org_config.id);
info(
no_input,
&format!("Selected org: {} ({})", alias, org_config.id),
);

// Save config with the new/updated org
config.set_active_org(&alias)?;
config.save().await?;

// Get or generate API key
let api_key = get_or_generate_api_key(&org_config).await?;
let api_key = get_or_generate_api_key(&org_config, no_input).await?;

// Verify credentials with whoami
println!();
println!("Verifying credentials...");
info(no_input, "");
info(no_input, "Verifying credentials...");

let whoami = verify_credentials(&api_key, &org_config.id, &org_config.api_base_url).await?;

// Get or generate operator key
let operator_key = get_or_generate_operator_key(&org_config).await?;

println!();
println!("Successfully logged in!");
println!();
println!(
"Organization: {} ({})",
whoami.organization_name, whoami.organization_id
);
println!("User: {} ({})", whoami.username, whoami.user_id);
println!("Active Org: {alias}");
println!("API Key: {}", api_key.public_key);
println!("Operator Key: {}", operator_key.public_key);
println!();
println!(
"Config: {}",
crate::config::turnkey::config_file_path()?.display()
);
println!("API Key: {}", org_config.api_key_path.display());
println!("Operator Key: {}", org_config.operator_key_path.display());
let operator_key = get_or_generate_operator_key(&org_config, no_input).await?;

if !no_input {
println!();
println!("Successfully logged in!");
println!();
println!(
"Organization: {} ({})",
whoami.organization_name, whoami.organization_id
);
println!("User: {} ({})", whoami.username, whoami.user_id);
println!("Active Org: {alias}");
println!("API Key: {}", api_key.public_key);
println!("Operator Key: {}", operator_key.public_key);
println!();
println!(
"Config: {}",
crate::config::turnkey::config_file_path()?.display()
);
println!("API Key: {}", org_config.api_key_path.display());
println!("Operator Key: {}", org_config.operator_key_path.display());
}

Ok(())
}
Expand All @@ -73,16 +96,40 @@ pub async fn run(args: Args) -> anyhow::Result<()> {
/// Returns the alias and a clone of the org config.
async fn select_or_create_org(
config: &mut Config,
org_arg: Option<&str>,
args: &Args,
no_input: bool,
) -> Result<(String, OrgConfig)> {
// If --org-id provided, create/update org non-interactively
if let Some(ref org_id) = args.org_id {
let api_base_url = match args.api_env.as_deref().unwrap_or("prod") {
"prod" => API_BASE_URL_PROD,
"preprod" => API_BASE_URL_PREPROD,
"dev" => API_BASE_URL_DEV,
"local" => API_BASE_URL_LOCAL,
_ => unreachable!("clap validates api_env"),
};
config.add_org(&args.alias, org_id.clone(), api_base_url.to_string())?;
let org_config = config.orgs.get(&args.alias).unwrap().clone();
return Ok((args.alias.clone(), org_config));
}

// If --org provided, try to find it by alias or ID
if let Some(org) = org_arg {
if let Some(ref org) = args.org {
if let Some((alias, org_config)) = find_org(config, org) {
return Ok((alias.clone(), org_config.clone()));
}
bail!("Organization '{org}' not found. Run `tvc login` without --org to set up a new organization.");
}

// Non-interactive mode requires --org or --org-id
if no_input {
bail!(
"No organization specified in non-interactive mode. \
Use --org <ALIAS> to select an existing org, or \
--org-id <ID> to create a new org config."
);
}

// No --org provided, check existing orgs
let org_count = config.orgs.len();

Expand Down Expand Up @@ -165,16 +212,16 @@ fn prompt_for_api_url() -> Result<String> {
/// Get an existing API key or generate a new one.
/// If an API key exists, it's returned directly.
/// If not, a new key is generated, saved, and the user is prompted to add it to the dashboard.
async fn get_or_generate_api_key(org_config: &OrgConfig) -> Result<StoredApiKey> {
async fn get_or_generate_api_key(org_config: &OrgConfig, no_input: bool) -> Result<StoredApiKey> {
// Check if API key already exists
if let Some(api_key) = StoredApiKey::load(org_config).await? {
println!("Using existing API key.");
info(no_input, "Using existing API key.");
return Ok(api_key);
}

// Generate new API key
println!();
println!("Generating API key...");
info(no_input, "");
info(no_input, "Generating API key...");

let stamper = TurnkeyP256ApiKey::generate();
let public_key = hex::encode(stamper.compressed_public_key());
Expand All @@ -189,7 +236,7 @@ async fn get_or_generate_api_key(org_config: &OrgConfig) -> Result<StoredApiKey>
// Save the key
api_key.save(org_config).await?;

// Display instructions
// Always show manual setup instructions, even in non-interactive mode.
println!();
println!("API Key Generated!");
println!();
Expand All @@ -201,22 +248,28 @@ async fn get_or_generate_api_key(org_config: &OrgConfig) -> Result<StoredApiKey>
println!(" 3. Paste the public key > Name it \"TVC CLI\" > Continue > Approve");
println!();

wait_for_enter("Press Enter when done...")?;
// Skip wait in non-interactive mode.
if !no_input {
wait_for_enter("Press Enter when done...")?;
}

Ok(api_key)
}

/// Get an existing operator key or generate a new one.
async fn get_or_generate_operator_key(org_config: &OrgConfig) -> Result<StoredQosOperatorKey> {
async fn get_or_generate_operator_key(
org_config: &OrgConfig,
no_input: bool,
) -> Result<StoredQosOperatorKey> {
// Check if operator key already exists
if let Some(operator_key) = StoredQosOperatorKey::load(org_config).await? {
println!("Using existing operator key.");
info(no_input, "Using existing operator key.");
return Ok(operator_key);
}

// Generate new operator key
println!();
println!("Generating operator key...");
info(no_input, "");
info(no_input, "Generating operator key...");

let pair =
P256Pair::generate().map_err(|e| anyhow!("failed to generate operator key: {e:?}"))?;
Expand All @@ -231,13 +284,19 @@ async fn get_or_generate_operator_key(org_config: &OrgConfig) -> Result<StoredQo
// Save the key
operator_key.save(org_config).await?;

println!();
println!("Operator Key Generated!");
println!();
println!("Public Key: {public_key}");
println!();
println!("This key will be used for approving deployment manifests.");
println!("Make sure to register this as an operator in your organization.");
info(no_input, "");
info(no_input, "Operator Key Generated!");
info(no_input, "");
info(no_input, &format!("Public Key: {public_key}"));
info(no_input, "");
info(
no_input,
"This key will be used for approving deployment manifests.",
);
info(
no_input,
"Make sure to register this as an operator in your organization.",
);

Ok(operator_key)
}
Expand Down Expand Up @@ -296,6 +355,12 @@ fn wait_for_enter(message: &str) -> Result<()> {
Ok(())
}

fn info(no_input: bool, message: &str) {
if !no_input {
println!("{message}");
}
}

/// Result of a successful whoami verification.
pub struct WhoamiResult {
pub organization_name: String,
Expand Down
24 changes: 24 additions & 0 deletions tvc/tests/global_flags.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
use assert_cmd::cargo::cargo_bin_cmd;
use predicates::prelude::*;

#[test]
fn help_shows_global_flags() {
cargo_bin_cmd!("tvc")
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("--no-input"));
}

#[test]
fn no_input_flag_recognized() {
cargo_bin_cmd!("tvc")
.arg("--no-input")
.arg("deploy")
.arg("approve")
.arg("--dry-run")
.arg("--dangerous-skip-interactive")
.assert()
.failure()
.stderr(predicate::str::contains("manifest source is required"));
}
Loading
Loading