diff --git a/auth/Cargo.toml b/auth/Cargo.toml index da74049..a5ac9b9 100644 --- a/auth/Cargo.toml +++ b/auth/Cargo.toml @@ -14,11 +14,11 @@ hex = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha2 = { workspace = true } +tempfile = { workspace = true } tokio = { workspace = true, features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "time"] } toml = { workspace = true } turnkey_api_key_stamper = { workspace = true } turnkey_client = { workspace = true } [dev-dependencies] -tempfile = { workspace = true } wiremock = { workspace = true } diff --git a/auth/src/config.rs b/auth/src/config.rs index 6e22f5e..ccc3727 100644 --- a/auth/src/config.rs +++ b/auth/src/config.rs @@ -1,8 +1,10 @@ use std::collections::BTreeMap; +use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow}; use serde::{Deserialize, Serialize}; +use tempfile::NamedTempFile; const DEFAULT_API_BASE_URL: &str = "https://api.turnkey.com"; const CONFIG_PATH_ENV: &str = "TURNKEY_TK_CONFIG_PATH"; @@ -297,10 +299,44 @@ async fn save_persisted_config(path: &Path, config: &PersistedConfigFile) -> Res tokio::fs::create_dir_all(parent).await?; } let serialized = toml::to_string_pretty(config).context("failed to serialize config file")?; - tokio::fs::write(path, serialized).await?; + let parent = path + .parent() + .ok_or_else(|| anyhow!("config path has no parent directory"))?; + let temp = NamedTempFile::new_in(parent).context("failed to create temporary config file")?; + tokio::fs::write(temp.path(), &serialized) + .await + .context("failed to write temporary config file")?; + tokio::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o600)) + .await + .context("failed to set config file permissions")?; + temp.persist(path) + .context("failed to persist config file")?; Ok(()) } +/// Atomically writes all init config values to the global config file. +pub async fn save_init_config( + organization_id: &str, + api_public_key: &str, + api_private_key: &str, + signing_address: &str, + signing_public_key: &str, + api_base_url: Option<&str>, +) -> Result<()> { + let path = global_config_path()?; + let config = PersistedConfigFile { + turnkey: PersistedTurnkeyConfig { + organization_id: Some(organization_id.to_string()), + api_public_key: Some(api_public_key.to_string()), + api_private_key: Some(api_private_key.to_string()), + signing_address: Some(signing_address.to_string()), + signing_public_key: Some(signing_public_key.to_string()), + api_base_url: api_base_url.map(|s| s.to_string()), + }, + }; + save_persisted_config(&path, &config).await +} + fn read_value(env: &BTreeMap, key: &str) -> Option { env.get(key).and_then(|value| normalize_value(value)) } diff --git a/auth/src/init.rs b/auth/src/init.rs new file mode 100644 index 0000000..402468f --- /dev/null +++ b/auth/src/init.rs @@ -0,0 +1,153 @@ +use anyhow::{Context, Result, anyhow}; +use turnkey_api_key_stamper::TurnkeyP256ApiKey; +use turnkey_client::TurnkeyClient; +use turnkey_client::generated::immutable::common::v1::{AddressFormat, Curve, PathFormat}; +use turnkey_client::generated::{ + CreateWalletIntent, GetWalletAccountsRequest, GetWalletsRequest, WalletAccountParams, +}; + +use crate::config; + +const DEFAULT_API_BASE_URL: &str = "https://api.turnkey.com"; +const WALLET_NAME: &str = "tk-default"; + +/// Result of a successful `tk init` operation. +#[derive(Debug, Clone)] +pub struct InitResult { + /// The signing address for the resolved Ed25519 account. + pub signing_address: String, + /// The hex-encoded Ed25519 public key for the resolved account. + pub signing_public_key: String, + /// The organization ID used for the operation. + pub organization_id: String, + /// Whether a new wallet was created during init. + pub created: bool, +} + +/// Initializes a Turnkey configuration by finding or creating an Ed25519 wallet account. +pub async fn initialize( + org_id: &str, + api_public_key: &str, + api_private_key: &str, + api_base_url: Option<&str>, +) -> Result { + let base_url = api_base_url.unwrap_or(DEFAULT_API_BASE_URL); + + let api_key = TurnkeyP256ApiKey::from_strings(api_private_key, Some(api_public_key)) + .context("failed to load Turnkey API key")?; + + let client = TurnkeyClient::builder() + .api_key(api_key) + .base_url(base_url) + .build() + .context("failed to build Turnkey client")?; + + // List existing wallets. + let wallets_response = client + .get_wallets(GetWalletsRequest { + organization_id: org_id.to_string(), + }) + .await + .context("failed to list wallets")?; + + // Search each wallet for an Ed25519 account. + for wallet in &wallets_response.wallets { + let accounts_response = client + .get_wallet_accounts(GetWalletAccountsRequest { + organization_id: org_id.to_string(), + wallet_id: Some(wallet.wallet_id.clone()), + include_wallet_details: None, + pagination_options: None, + }) + .await + .context("failed to list wallet accounts")?; + + if let Some(account) = accounts_response + .accounts + .iter() + .find(|a| a.curve == Curve::Ed25519) + { + let public_key = account + .public_key + .as_ref() + .ok_or_else(|| anyhow!("Ed25519 account missing public key"))?; + + config::save_init_config( + org_id, + api_public_key, + api_private_key, + &account.address, + public_key, + api_base_url, + ) + .await?; + + return Ok(InitResult { + signing_address: account.address.clone(), + signing_public_key: public_key.clone(), + organization_id: org_id.to_string(), + created: false, + }); + } + } + + // No Ed25519 account found, create a new wallet. + let create_result = client + .create_wallet( + org_id.to_string(), + client.current_timestamp(), + CreateWalletIntent { + wallet_name: WALLET_NAME.to_string(), + accounts: vec![WalletAccountParams { + curve: Curve::Ed25519, + path_format: PathFormat::Bip32, + path: "m/44'/501'/0'/0'".to_string(), + address_format: AddressFormat::Compressed, + }], + mnemonic_length: None, + }, + ) + .await + .context("failed to create wallet")?; + + let wallet_id = create_result.result.wallet_id; + + // Fetch accounts for the newly created wallet. + let accounts_response = client + .get_wallet_accounts(GetWalletAccountsRequest { + organization_id: org_id.to_string(), + wallet_id: Some(wallet_id), + include_wallet_details: None, + pagination_options: None, + }) + .await + .context("failed to list accounts for new wallet")?; + + let account = accounts_response + .accounts + .iter() + .find(|a| a.curve == Curve::Ed25519) + .ok_or_else(|| anyhow!("newly created wallet has no Ed25519 account"))?; + + let public_key = account + .public_key + .as_ref() + .ok_or_else(|| anyhow!("Ed25519 account missing public key"))?; + + config::save_init_config( + org_id, + api_public_key, + api_private_key, + &account.address, + public_key, + api_base_url, + ) + .await?; + + Ok(InitResult { + signing_address: account.address.clone(), + signing_public_key: public_key.clone(), + organization_id: org_id.to_string(), + created: true, + }) +} diff --git a/auth/src/lib.rs b/auth/src/lib.rs index ee5873a..99622fc 100644 --- a/auth/src/lib.rs +++ b/auth/src/lib.rs @@ -5,9 +5,13 @@ pub mod config; /// Git SSH signing helpers backed by Turnkey. pub mod git_sign; +/// Organization initialization and wallet setup helpers. +pub mod init; /// Public-key helpers backed by Turnkey. pub mod public_key; /// SSH wire-format helpers for public keys and signatures. pub mod ssh; /// Turnkey-backed signing client helpers. pub mod turnkey; +/// Authenticated identity lookup helpers. +pub mod whoami; diff --git a/auth/src/whoami.rs b/auth/src/whoami.rs new file mode 100644 index 0000000..b625741 --- /dev/null +++ b/auth/src/whoami.rs @@ -0,0 +1,46 @@ +use anyhow::{Context, Result}; +use turnkey_api_key_stamper::TurnkeyP256ApiKey; +use turnkey_client::TurnkeyClient; +use turnkey_client::generated::GetWhoamiRequest; + +use crate::config::Config; + +/// Authenticated Turnkey identity information. +#[derive(Debug, Clone)] +pub struct Identity { + /// The organization ID. + pub organization_id: String, + /// The organization name. + pub organization_name: String, + /// The authenticated user ID. + pub user_id: String, + /// The authenticated username. + pub username: String, +} + +/// Fetches the authenticated identity from Turnkey. +pub async fn get_identity(config: &Config) -> Result { + let api_key = + TurnkeyP256ApiKey::from_strings(&config.api_private_key, Some(&config.api_public_key)) + .context("failed to load Turnkey API key")?; + + let client = TurnkeyClient::builder() + .api_key(api_key) + .base_url(&config.api_base_url) + .build() + .context("failed to build Turnkey client")?; + + let response = client + .get_whoami(GetWhoamiRequest { + organization_id: config.organization_id.clone(), + }) + .await + .context("failed to fetch identity from Turnkey")?; + + Ok(Identity { + organization_id: response.organization_id, + organization_name: response.organization_name, + user_id: response.user_id, + username: response.username, + }) +} diff --git a/tk/src/cli.rs b/tk/src/cli.rs index 40d409a..3675460 100644 --- a/tk/src/cli.rs +++ b/tk/src/cli.rs @@ -5,6 +5,7 @@ use turnkey_auth::config::DEFAULT_CONFIG_DIR_DISPLAY; /// Top-level CLI arguments for the `tk` binary. #[derive(Debug, Parser)] #[command( + version, about = "CLI for Turnkey backed auth workflows", long_about = None, after_help = after_help() @@ -20,6 +21,8 @@ impl Cli { let args = Self::parse(); match args.command { + Commands::Init(args) => commands::init::run(args).await, + Commands::Whoami(args) => commands::whoami::run(args).await, Commands::Config(args) => commands::config::run(args).await, Commands::SshAgent(args) => commands::agent::run(args).await, Commands::GitSign(args) => commands::git_sign::run(args).await, @@ -30,6 +33,10 @@ impl Cli { #[derive(Debug, Subcommand)] enum Commands { + /// Initialize Turnkey credentials and wallet configuration. + Init(commands::init::Args), + /// Display the authenticated Turnkey identity. + Whoami(commands::whoami::Args), /// Inspect and update persistent auth configuration. Config(commands::config::Args), /// Manage a background SSH agent over a Unix socket. @@ -43,6 +50,11 @@ enum Commands { fn after_help() -> String { format!( "\ +Quick start: + export TURNKEY_API_PRIVATE_KEY=\"\" + tk init --org-id --api-public-key + tk whoami + Environment: TURNKEY_ORGANIZATION_ID TURNKEY_API_PUBLIC_KEY diff --git a/tk/src/commands/config.rs b/tk/src/commands/config.rs index 81b0bbb..67dfbe8 100644 --- a/tk/src/commands/config.rs +++ b/tk/src/commands/config.rs @@ -1,3 +1,4 @@ +use anyhow::bail; use clap::{Args as ClapArgs, Subcommand}; use turnkey_auth::config::{self, ConfigKey}; @@ -40,6 +41,12 @@ pub async fn run(args: Args) -> anyhow::Result<()> { } Command::Set(args) => { let key = ConfigKey::parse(&args.key)?; + if key == ConfigKey::ApiPrivateKey { + bail!( + "turnkey.apiPrivateKey cannot be set via the CLI to avoid shell history exposure.\n\ + Use the TURNKEY_API_PRIVATE_KEY environment variable or run `tk init` instead." + ); + } config::set_config_value(key, &args.value).await?; } Command::List => { diff --git a/tk/src/commands/init.rs b/tk/src/commands/init.rs new file mode 100644 index 0000000..d17cb7d --- /dev/null +++ b/tk/src/commands/init.rs @@ -0,0 +1,72 @@ +use anyhow::{Result, bail}; +use clap::Args as ClapArgs; + +use turnkey_auth::config; + +/// Arguments for the `tk init` subcommand. +#[derive(Debug, ClapArgs)] +#[command( + about = "Initialize Turnkey credentials and wallet configuration", + long_about = None, + after_help = "\ +The API private key must be provided via the TURNKEY_API_PRIVATE_KEY environment \ +variable. It is never accepted as a CLI flag to prevent exposure in shell history. + +Example: + export TURNKEY_API_PRIVATE_KEY=\"\" + tk init --org-id --api-public-key +" +)] +pub struct Args { + /// Turnkey organization ID. + #[arg(long)] + pub org_id: String, + + /// Turnkey API public key. + #[arg(long)] + pub api_public_key: String, + + /// Optional API base URL override. + #[arg(long)] + pub api_base_url: Option, + + #[arg(skip)] + pub api_private_key: Option, +} + +/// Runs the `tk init` subcommand. +pub async fn run(mut args: Args) -> Result<()> { + args.api_private_key = std::env::var("TURNKEY_API_PRIVATE_KEY").ok(); + + let api_private_key = match &args.api_private_key { + Some(key) if !key.trim().is_empty() => key.as_str(), + _ => bail!( + "TURNKEY_API_PRIVATE_KEY environment variable is required.\n\ + Set it before running tk init:\n\n \ + export TURNKEY_API_PRIVATE_KEY=\"\"" + ), + }; + + let result = turnkey_auth::init::initialize( + &args.org_id, + &args.api_public_key, + api_private_key, + args.api_base_url.as_deref(), + ) + .await?; + + let config_path = config::global_config_path()?; + + if result.created { + println!("Created new Ed25519 wallet account."); + } else { + println!("Found existing Ed25519 wallet account."); + } + println!(" Signing address: {}", result.signing_address); + println!(" Signing public key: {}", result.signing_public_key); + println!(" Organization: {}", result.organization_id); + println!(); + println!("Config saved to {}", config_path.display()); + + Ok(()) +} diff --git a/tk/src/commands/mod.rs b/tk/src/commands/mod.rs index 8e8dfbb..ff890f2 100644 --- a/tk/src/commands/mod.rs +++ b/tk/src/commands/mod.rs @@ -4,5 +4,9 @@ pub mod agent; pub mod config; /// Git SSH signing command implementation. pub mod git_sign; +/// Organization initialization and wallet setup command. +pub mod init; /// Public key printing command implementation. pub mod public_key; +/// Authenticated identity display command. +pub mod whoami; diff --git a/tk/src/commands/whoami.rs b/tk/src/commands/whoami.rs new file mode 100644 index 0000000..6c1a102 --- /dev/null +++ b/tk/src/commands/whoami.rs @@ -0,0 +1,26 @@ +use anyhow::{Context, Result}; +use clap::Args as ClapArgs; + +use turnkey_auth::config::Config; + +/// Arguments for the `tk whoami` subcommand. +#[derive(Debug, ClapArgs)] +#[command(about = "Display the authenticated Turnkey identity")] +pub struct Args; + +/// Runs the `tk whoami` subcommand. +pub async fn run(_args: Args) -> Result<()> { + let config = Config::resolve() + .await + .context("Run `tk init` to set up your credentials.")?; + + let identity = turnkey_auth::whoami::get_identity(&config).await?; + + println!( + "Organization: {} ({})", + identity.organization_name, identity.organization_id + ); + println!("User: {} ({})", identity.username, identity.user_id); + + Ok(()) +} diff --git a/tk/tests/config_command.rs b/tk/tests/config_command.rs index eae9da0..b653c34 100644 --- a/tk/tests/config_command.rs +++ b/tk/tests/config_command.rs @@ -66,20 +66,12 @@ fn config_list_and_get_redact_private_key() { let temp = tempdir().expect("temp dir should exist"); let config_path = temp.path().join("tk.toml"); - let mut set_cmd = Command::new(env!("CARGO_BIN_EXE_tk")); - set_cmd - .args([ - "config", - "set", - "turnkey.apiPrivateKey", - "persisted-private-key", - ]) - .env("TURNKEY_TK_CONFIG_PATH", &config_path) - .env_remove("TURNKEY_ORGANIZATION_ID") - .env_remove("TURNKEY_API_PUBLIC_KEY") - .env_remove("TURNKEY_API_PRIVATE_KEY") - .env_remove("TURNKEY_API_BASE_URL"); - set_cmd.assert().success(); + // Write the config file directly since `config set` blocks apiPrivateKey. + fs::write( + &config_path, + "[turnkey]\napiPrivateKey = \"persisted-private-key\"\n", + ) + .expect("config file should be writable"); let mut list_cmd = Command::new(env!("CARGO_BIN_EXE_tk")); list_cmd @@ -108,3 +100,22 @@ fn config_list_and_get_redact_private_key() { .stdout(predicate::str::contains("")) .stdout(predicate::str::contains("persisted-private-key").not()); } + +#[test] +fn config_set_blocks_api_private_key() { + let temp = tempdir().expect("temp dir should exist"); + let config_path = temp.path().join("tk.toml"); + + let mut cmd = Command::new(env!("CARGO_BIN_EXE_tk")); + cmd.args(["config", "set", "turnkey.apiPrivateKey", "secret-value"]) + .env("TURNKEY_TK_CONFIG_PATH", &config_path) + .env_remove("TURNKEY_ORGANIZATION_ID") + .env_remove("TURNKEY_API_PUBLIC_KEY") + .env_remove("TURNKEY_API_PRIVATE_KEY") + .env_remove("TURNKEY_API_BASE_URL"); + + cmd.assert() + .failure() + .stderr(predicate::str::contains("cannot be set via the CLI")) + .stderr(predicate::str::contains("tk init")); +} diff --git a/tk/tests/init_command.rs b/tk/tests/init_command.rs new file mode 100644 index 0000000..95d3472 --- /dev/null +++ b/tk/tests/init_command.rs @@ -0,0 +1,174 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::tempdir; +use turnkey_api_key_stamper::TurnkeyP256ApiKey; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[tokio::test] +async fn init_uses_existing_wallet_with_ed25519_account() { + let server = MockServer::start().await; + let api_key = TurnkeyP256ApiKey::generate(); + let temp = tempdir().unwrap(); + let config_path = temp.path().join("tk.toml"); + + Mock::given(method("POST")) + .and(path("/public/v1/query/list_wallets")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ + "wallets": [{ + "walletId": "wallet-1", + "walletName": "existing-wallet", + "exported": false, + "imported": false + }] + })) + .insert_header("Content-Type", "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/public/v1/query/list_wallet_accounts")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ + "accounts": [{ + "walletAccountId": "account-1", + "organizationId": "org-id", + "walletId": "wallet-1", + "curve": "CURVE_ED25519", + "pathFormat": "PATH_FORMAT_BIP32", + "path": "m/44'/501'/0'/0'", + "addressFormat": "ADDRESS_FORMAT_COMPRESSED", + "address": "ed25519-address", + "publicKey": "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + }] + })) + .insert_header("Content-Type", "application/json"), + ) + .mount(&server) + .await; + + let mut cmd = Command::new(env!("CARGO_BIN_EXE_tk")); + cmd.args([ + "init", + "--org-id", + "org-id", + "--api-public-key", + &hex::encode(api_key.compressed_public_key()), + "--api-base-url", + &server.uri(), + ]) + .env( + "TURNKEY_API_PRIVATE_KEY", + hex::encode(api_key.private_key()), + ) + .env("TURNKEY_TK_CONFIG_PATH", &config_path); + + cmd.assert() + .success() + .stdout(predicate::str::contains( + "Found existing Ed25519 wallet account", + )) + .stdout(predicate::str::contains("ed25519-address")); + + let stored = std::fs::read_to_string(&config_path).unwrap(); + assert!(stored.contains("signingAddress = \"ed25519-address\"")); + assert!(stored.contains( + "signingPublicKey = \"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890\"" + )); +} + +#[tokio::test] +async fn init_creates_wallet_when_none_exist() { + let server = MockServer::start().await; + let api_key = TurnkeyP256ApiKey::generate(); + let temp = tempdir().unwrap(); + let config_path = temp.path().join("tk.toml"); + + Mock::given(method("POST")) + .and(path("/public/v1/query/list_wallets")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ + "wallets": [] + })) + .insert_header("Content-Type", "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/public/v1/submit/create_wallet")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ + "activity": { + "id": "activity-id", + "organizationId": "org-id", + "fingerprint": "fingerprint", + "status": "ACTIVITY_STATUS_COMPLETED", + "type": "ACTIVITY_TYPE_CREATE_WALLET", + "result": { + "createWalletResult": { + "walletId": "new-wallet-id", + "addresses": ["new-ed25519-address"] + } + } + } + })) + .insert_header("Content-Type", "application/json"), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/public/v1/query/list_wallet_accounts")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ + "accounts": [{ + "walletAccountId": "new-account-1", + "organizationId": "org-id", + "walletId": "new-wallet-id", + "curve": "CURVE_ED25519", + "pathFormat": "PATH_FORMAT_BIP32", + "path": "m/44'/501'/0'/0'", + "addressFormat": "ADDRESS_FORMAT_COMPRESSED", + "address": "new-ed25519-address", + "publicKey": "1111111111111111111111111111111111111111111111111111111111111111" + }] + })) + .insert_header("Content-Type", "application/json"), + ) + .mount(&server) + .await; + + let mut cmd = Command::new(env!("CARGO_BIN_EXE_tk")); + cmd.args([ + "init", + "--org-id", + "org-id", + "--api-public-key", + &hex::encode(api_key.compressed_public_key()), + "--api-base-url", + &server.uri(), + ]) + .env( + "TURNKEY_API_PRIVATE_KEY", + hex::encode(api_key.private_key()), + ) + .env("TURNKEY_TK_CONFIG_PATH", &config_path); + + cmd.assert() + .success() + .stdout(predicate::str::contains( + "Created new Ed25519 wallet account", + )) + .stdout(predicate::str::contains("new-ed25519-address")); + + let stored = std::fs::read_to_string(&config_path).unwrap(); + assert!(stored.contains("signingAddress = \"new-ed25519-address\"")); +} diff --git a/tk/tests/whoami_command.rs b/tk/tests/whoami_command.rs new file mode 100644 index 0000000..375708a --- /dev/null +++ b/tk/tests/whoami_command.rs @@ -0,0 +1,82 @@ +use std::fs; + +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::tempdir; +use turnkey_api_key_stamper::TurnkeyP256ApiKey; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[tokio::test] +async fn whoami_displays_identity() { + let server = MockServer::start().await; + let api_key = TurnkeyP256ApiKey::generate(); + let temp = tempdir().unwrap(); + let config_path = temp.path().join("tk.toml"); + + fs::write( + &config_path, + format!( + r#"[turnkey] +organizationId = "org-id" +apiPublicKey = "{}" +apiPrivateKey = "{}" +signingAddress = "signing-addr" +signingPublicKey = "6666666666666666666666666666666666666666666666666666666666666666" +apiBaseUrl = "{}" +"#, + hex::encode(api_key.compressed_public_key()), + hex::encode(api_key.private_key()), + server.uri(), + ), + ) + .unwrap(); + + Mock::given(method("POST")) + .and(path("/public/v1/query/whoami")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ + "organizationId": "org-id", + "organizationName": "My Org", + "userId": "user-123", + "username": "testuser" + })) + .insert_header("Content-Type", "application/json"), + ) + .mount(&server) + .await; + + let mut cmd = Command::new(env!("CARGO_BIN_EXE_tk")); + cmd.arg("whoami") + .env("TURNKEY_TK_CONFIG_PATH", &config_path) + .env_remove("TURNKEY_ORGANIZATION_ID") + .env_remove("TURNKEY_API_PUBLIC_KEY") + .env_remove("TURNKEY_API_PRIVATE_KEY") + .env_remove("TURNKEY_API_BASE_URL"); + + cmd.assert() + .success() + .stdout(predicate::str::contains("My Org")) + .stdout(predicate::str::contains("org-id")) + .stdout(predicate::str::contains("testuser")) + .stdout(predicate::str::contains("user-123")); +} + +#[test] +fn whoami_suggests_init_when_config_missing() { + let temp = tempdir().unwrap(); + let config_path = temp.path().join("nonexistent.toml"); + + let mut cmd = Command::new(env!("CARGO_BIN_EXE_tk")); + cmd.arg("whoami") + .env("TURNKEY_TK_CONFIG_PATH", &config_path) + .env_remove("TURNKEY_ORGANIZATION_ID") + .env_remove("TURNKEY_API_PUBLIC_KEY") + .env_remove("TURNKEY_API_PRIVATE_KEY") + .env_remove("TURNKEY_API_BASE_URL"); + + cmd.assert() + .failure() + .stderr(predicate::str::contains("tk init")); +}