use crate::config::turnkey::{
Config, KeyCurve, OrgConfig, StoredApiKey, StoredQosOperatorKey, API_BASE_URL_DEV,
API_BASE_URL_LOCAL, API_BASE_URL_PREPROD, API_BASE_URL_PROD,
};
use anyhow::{anyhow, bail, Context, Result};
use clap::Args as ClapArgs;
use qos_p256::P256Pair;
use std::io::{BufRead, Write};
use turnkey_api_key_stamper::TurnkeyP256ApiKey;
use turnkey_client::generated::GetWhoamiRequest;
#[derive(Debug, ClapArgs)]
#[command(about, long_about = None)]
pub struct Args {
#[arg(long)]
pub org: Option<String>,
}
pub async fn run(args: Args) -> anyhow::Result<()> {
let mut config = Config::load().await?;
let (alias, org_config) = select_or_create_org(&mut config, args.org.as_deref()).await?;
println!("Selected org: {} ({})", alias, org_config.id);
config.set_active_org(&alias)?;
config.save().await?;
let api_key = get_or_generate_api_key(&org_config).await?;
println!();
println!("Verifying credentials...");
let whoami = verify_credentials(&api_key, &org_config.id, &org_config.api_base_url).await?;
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());
Ok(())
}
async fn select_or_create_org(
config: &mut Config,
org_arg: Option<&str>,
) -> Result<(String, OrgConfig)> {
if let Some(org) = org_arg {
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.");
}
let org_count = config.orgs.len();
if org_count == 0 {
println!("No organization configured.");
return prompt_for_new_org(config).await;
}
println!("Organization choices:");
for (alias, org) in &config.orgs {
let active = if config.active_org.as_ref() == Some(alias) {
" (active)"
} else {
""
};
println!(" - {} ({}){}", alias, org.id, active);
}
println!(" - [new] Add a new organization");
println!();
let selection = prompt("Enter organization alias or 'new'")?;
println!();
if selection == "new" {
return prompt_for_new_org(config).await;
}
if let Some(org_config) = config.orgs.get(&selection) {
return Ok((selection, org_config.clone()));
}
bail!("Organization '{}' not found", selection)
}
async fn prompt_for_new_org(config: &mut Config) -> Result<(String, OrgConfig)> {
println!("You can find your Organization ID at: https://app.turnkey.com/dashboard/welcome");
println!();
let org_id = prompt("Organization ID")?;
if org_id.is_empty() {
bail!("Organization ID is required");
}
let alias = prompt_with_default("Organization alias", "default")?;
let api_base_url = prompt_for_api_url()?;
config.add_org(&alias, org_id, api_base_url)?;
let org_config = config.orgs.get(&alias).unwrap().clone();
Ok((alias, org_config))
}
fn prompt_for_api_url() -> Result<String> {
println!();
println!("Select Turnkey API URL:");
println!(" 1. prod (default) - {API_BASE_URL_PROD}");
println!(" 2. preprod - {API_BASE_URL_PREPROD}");
println!(" 3. dev - {API_BASE_URL_DEV}");
println!(" 4. local - {API_BASE_URL_LOCAL}");
println!();
let selection = prompt_with_default("API URL [1/2/3/4]", "1")?;
let url = match selection.as_str() {
"1" | "prod" | "" => API_BASE_URL_PROD,
"2" | "preprod" => API_BASE_URL_PREPROD,
"3" | "dev" => API_BASE_URL_DEV,
"4" | "local" => API_BASE_URL_LOCAL,
_ => bail!("Invalid selection: {selection}"),
};
Ok(url.to_string())
}
async fn get_or_generate_api_key(org_config: &OrgConfig) -> Result<StoredApiKey> {
if let Some(api_key) = StoredApiKey::load(org_config).await? {
println!("Using existing API key.");
return Ok(api_key);
}
println!();
println!("Generating API key...");
let stamper = TurnkeyP256ApiKey::generate();
let public_key = hex::encode(stamper.compressed_public_key());
let private_key = hex::encode(stamper.private_key());
let api_key = StoredApiKey {
public_key: public_key.clone(),
private_key,
curve: KeyCurve::P256,
};
api_key.save(org_config).await?;
println!();
println!("API Key Generated!");
println!();
println!("Public Key: {public_key}");
println!();
println!("Add this API key to your Turnkey dashboard:");
println!(" 1. Go to https://app.turnkey.com/dashboard/users");
println!(" 2. Click your user > Create API Key > Generate API Keys via CLI > Continue");
println!(" 3. Paste the public key > Name it \"TVC CLI\" > Continue > Approve");
println!();
wait_for_enter("Press Enter when done...")?;
Ok(api_key)
}
async fn get_or_generate_operator_key(org_config: &OrgConfig) -> Result<StoredQosOperatorKey> {
if let Some(operator_key) = StoredQosOperatorKey::load(org_config).await? {
println!("Using existing operator key.");
return Ok(operator_key);
}
println!();
println!("Generating operator key...");
let pair =
P256Pair::generate().map_err(|e| anyhow!("failed to generate operator key: {e:?}"))?;
let public_key = hex::encode(pair.public_key().to_bytes());
let private_key = hex::encode(pair.to_master_seed());
let operator_key = StoredQosOperatorKey {
public_key: public_key.clone(),
private_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.");
Ok(operator_key)
}
fn find_org<'a>(config: &'a Config, org: &str) -> Option<(&'a String, &'a OrgConfig)> {
if let Some((alias, org_config)) = config.orgs.get_key_value(org) {
return Some((alias, org_config));
}
for (alias, org_config) in &config.orgs {
if org_config.id == org {
return Some((alias, org_config));
}
}
None
}
fn prompt(message: &str) -> Result<String> {
print!("{message}: ");
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().lock().read_line(&mut input)?;
Ok(input.trim().to_string())
}
fn prompt_with_default(message: &str, default: &str) -> Result<String> {
print!("{message} [{default}]: ");
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().lock().read_line(&mut input)?;
let input = input.trim();
if input.is_empty() {
Ok(default.to_string())
} else {
Ok(input.to_string())
}
}
fn wait_for_enter(message: &str) -> Result<()> {
print!("{message}");
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().lock().read_line(&mut input)?;
Ok(())
}
pub struct WhoamiResult {
pub organization_name: String,
pub organization_id: String,
pub username: String,
pub user_id: String,
}
async fn verify_credentials(
api_key: &StoredApiKey,
org_id: &str,
api_base_url: &str,
) -> Result<WhoamiResult> {
let stamper = TurnkeyP256ApiKey::from_strings(&api_key.private_key, Some(&api_key.public_key))
.context("failed to load API key")?;
let client = turnkey_client::TurnkeyClient::builder()
.api_key(stamper)
.base_url(api_base_url)
.build()
.context("failed to build Turnkey client")?;
let request = GetWhoamiRequest {
organization_id: org_id.to_string(),
};
let response = client
.get_whoami(request)
.await
.context("whoami request failed")?;
Ok(WhoamiResult {
organization_name: response.organization_name,
organization_id: response.organization_id,
username: response.username,
user_id: response.user_id,
})
}