From b6bb1e150cfd6dc090a7510bcd55c7268d903442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Thu, 16 Jul 2026 12:03:53 -0700 Subject: [PATCH 1/8] feat: remove profiles from the authentication system first draft BREAKING-CHANGE: --profile and --prefer-profile removed Breaks any script using them --- README.md | 36 +- src/args.rs | 74 +- src/auth.rs | 1673 +++++++++++++++++++++++++++---------- src/config/mod.rs | 48 +- src/datasets/pipeline.rs | 3 +- src/datasets/snapshots.rs | 17 +- src/eval.rs | 30 +- src/functions/push.rs | 3 +- src/init.rs | 26 +- src/main.rs | 9 +- src/setup/mod.rs | 186 ++--- src/status.rs | 99 ++- src/switch.rs | 114 +-- src/traces.rs | 82 +- src/utils/mod.rs | 2 +- src/utils/profile.rs | 37 +- tests/cli.rs | 21 +- tests/functions.rs | 59 +- 18 files changed, 1545 insertions(+), 974 deletions(-) diff --git a/README.md b/README.md index cfa19e70..237e27d8 100644 --- a/README.md +++ b/README.md @@ -312,34 +312,34 @@ Local version and pagination-key conversion helpers: ## `bt auth` -- Authenticate interactively (prompts for auth method, profile name defaults to org name): +- Authenticate interactively (prompts for auth method and organization): - `bt auth login` - First prompt chooses: `OAuth (browser)` (default) or `API key`. - - If your API key can access multiple orgs, `bt` uses a searchable picker (alphabetized) and lets you choose a specific org or no default org (cross-org mode). - - After login, `bt` updates the active profile/org context immediately. If `--project` is set, it also switches that project; otherwise it clears any stale default project for the new login. + - OAuth can be saved for an org or in cross-org mode. API-key logins are saved per org; if a key can access multiple orgs, `bt` uses a searchable org picker. + - After login, `bt` updates the active org context immediately. If `--project` is set, it also switches that project; otherwise it clears any stale default project for the new login. - `bt` confirms the resolved API URL before saving. - Login with OAuth (browser-based, stores refresh token in secure credential store): - - `bt auth login --oauth --profile work` + - `bt auth login --oauth --org myorg` - You can pass `--no-browser` to print the URL without auto-opening. - On remote/SSH hosts, paste the final callback URL from your local browser if localhost callback cannot be delivered. -- List profiles: +- List saved auth logins: - `bt auth profiles` -- Log out (remove a saved profile): - - `bt auth logout` +- Log out: + - `bt auth logout --org myorg` + - `bt auth logout --org myorg --api-key-hint sk-****abcde` - `bt auth logout --force` (skip confirmation) -- Show current auth source/profile: - - `bt auth status` +- Show current auth context: + - `bt status` - Force-refresh OAuth access token for debugging: - - `bt auth refresh --profile work` + - `bt auth refresh --org myorg` Auth resolution order for commands is: -1. Explicit `--profile` -2. `--api-key` or `BRAINTRUST_API_KEY` (unless `--prefer-profile` is set) -3. `BRAINTRUST_PROFILE` -4. Org-based profile match (profile whose org matches `--org`/config org) -5. Single-profile auto-select (if only one profile exists) -6. Interactive profile picker (if multiple profiles exist and a TTY is available) +1. Explicit `--api-key sk-...` +2. `--prefer-api-key` / `BRAINTRUST_PREFER_API_KEY` (uses `BRAINTRUST_API_KEY` first, then a stored API key for the selected org, then falls back to OAuth) +3. Stored OAuth login for the selected org (or cross-org OAuth when selected) +4. Stored API key login for the selected org when no OAuth login is available +5. `BRAINTRUST_API_KEY` On Linux, secure storage uses `secret-tool` (libsecret) with a running Secret Service daemon. On macOS, it uses the `security` keychain utility. If a secure store is unavailable, `bt` falls back to a plaintext secrets file with `0600` permissions. @@ -357,8 +357,8 @@ Interactively switch org and project context: Show current org and project context: -- `bt status` — display current org, project, and config source -- `bt status --verbose` — show detailed config resolution +- `bt status` — display current org, project, selected auth method, and config source +- `bt status --verbose` — show detailed config and auth resolution - `bt status -j` — JSON output ## `bt setup` and `bt docs` diff --git a/src/args.rs b/src/args.rs index c15c6478..f0f20d64 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1,4 +1,3 @@ -use std::ffi::OsString; use std::path::{Path, PathBuf}; use clap::Args; @@ -39,13 +38,9 @@ pub struct BaseArgs { #[arg(long, env = "BRAINTRUST_NO_INPUT", global = true, value_parser = clap::builder::BoolishValueParser::new(), default_value_t = false)] pub no_input: bool, - /// Use a saved login profile (or via BRAINTRUST_PROFILE) - #[arg(long, env = "BRAINTRUST_PROFILE", global = true)] + #[arg(skip)] pub profile: Option, - #[arg(skip = false)] - pub profile_explicit: bool, - /// Override active org (or via BRAINTRUST_ORG_NAME) #[arg(short = 'o', long = "org", env = "BRAINTRUST_ORG_NAME", global = true)] pub org_name: Option, @@ -67,9 +62,9 @@ pub struct BaseArgs { #[arg(skip)] pub api_key_source: Option, - /// Prefer profile credentials even if BRAINTRUST_API_KEY/--api-key is set. - #[arg(long, global = true)] - pub prefer_profile: bool, + /// Prefer API key credentials for the selected org when available. + #[arg(long = "prefer-api-key", env = "BRAINTRUST_PREFER_API_KEY", global = true, value_parser = clap::builder::BoolishValueParser::new(), default_value_t = false)] + pub prefer_api_key: bool, /// Override API URL (or via BRAINTRUST_API_URL) #[arg( @@ -126,64 +121,3 @@ impl BaseArgs { self.verbose && self.verbose_source.is_some() } } - -pub fn has_explicit_profile_arg(args: &[OsString]) -> bool { - let mut idx = 1usize; - while idx < args.len() { - let Some(arg) = args[idx].to_str() else { - idx += 1; - continue; - }; - - if arg == "--" { - break; - } - - if arg == "--profile" || arg.starts_with("--profile=") { - return true; - } - - idx += 1; - } - - false -} - -#[cfg(test)] -mod tests { - use super::has_explicit_profile_arg; - use std::ffi::OsString; - - #[test] - fn has_explicit_profile_arg_detects_split_flag() { - let args = vec![ - OsString::from("bt"), - OsString::from("status"), - OsString::from("--profile"), - OsString::from("work"), - ]; - assert!(has_explicit_profile_arg(&args)); - } - - #[test] - fn has_explicit_profile_arg_detects_equals_flag() { - let args = vec![ - OsString::from("bt"), - OsString::from("status"), - OsString::from("--profile=work"), - ]; - assert!(has_explicit_profile_arg(&args)); - } - - #[test] - fn has_explicit_profile_arg_ignores_passthrough_args() { - let args = vec![ - OsString::from("bt"), - OsString::from("eval"), - OsString::from("--"), - OsString::from("--profile"), - OsString::from("work"), - ]; - assert!(!has_explicit_profile_arg(&args)); - } -} diff --git a/src/auth.rs b/src/auth.rs index 44db3d77..32f1ae1d 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,7 +1,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::error::Error as StdError; use std::fs; -use std::io::{IsTerminal, Write}; +use std::io::Write; use std::net::TcpListener; use std::path::{Path, PathBuf}; use std::process::Command; @@ -25,6 +25,7 @@ use oauth2::{ }; use serde::{Deserialize, Serialize}; use serde_json::json; +use sha2::{Digest, Sha256}; use tokio::sync::oneshot; use crate::{ @@ -62,6 +63,7 @@ pub struct ResolvedAuth { #[derive(Debug, Clone)] pub struct ProfileInfo { pub name: String, + pub auth_method: String, pub org_name: Option, pub user_name: Option, pub email: Option, @@ -71,7 +73,6 @@ pub struct ProfileInfo { #[derive(Debug, Clone)] pub(crate) struct StoredProfileInfo { pub name: String, - pub is_oauth: bool, pub org_name: Option, } @@ -84,7 +85,6 @@ pub struct AvailableOrg { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RecoverableAuthErrorKind { - OauthProfileSelection, OauthClientId, OauthRefreshToken, StoredCredential, @@ -115,8 +115,7 @@ pub fn is_missing_credential_error(err: &anyhow::Error) -> bool { .is_some_and(|err| { matches!( err.kind, - RecoverableAuthErrorKind::OauthProfileSelection - | RecoverableAuthErrorKind::OauthClientId + RecoverableAuthErrorKind::OauthClientId | RecoverableAuthErrorKind::OauthRefreshToken | RecoverableAuthErrorKind::StoredCredential ) @@ -129,13 +128,7 @@ pub fn list_profiles() -> Result> { Ok(store .profiles .iter() - .map(|(name, p)| ProfileInfo { - name: name.clone(), - org_name: p.org_name.clone(), - user_name: p.user_name.clone(), - email: p.email.clone(), - api_key_hint: p.api_key_hint.clone(), - }) + .map(|(name, p)| profile_info_from_store_entry(name, p)) .collect()) } @@ -146,12 +139,12 @@ pub(crate) fn list_stored_profiles() -> Result> { .iter() .map(|(name, profile)| StoredProfileInfo { name: name.clone(), - is_oauth: profile.auth_kind == AuthKind::Oauth, org_name: profile.org_name.clone(), }) .collect()) } +#[cfg(test)] pub fn resolve_org_to_profile(identifier: &str, profiles: &[ProfileInfo]) -> Result { if profiles.is_empty() { bail!("no auth profiles found. Run `bt auth login` to create one."); @@ -185,7 +178,7 @@ pub fn resolve_org_to_profile(identifier: &str, profiles: &[ProfileInfo]) -> Res _ => { if !ui::can_prompt() { bail!( - "multiple profiles for org '{identifier}': {}. Use --profile to disambiguate.", + "multiple auth logins for org '{identifier}': {}. Use --org to disambiguate.", matches .iter() .map(|p| p.name.as_str()) @@ -204,22 +197,38 @@ pub fn resolve_org_to_profile(identifier: &str, profiles: &[ProfileInfo]) -> Res } } +fn profile_info_identity_label(profile: &ProfileInfo) -> Option { + if let Some(email) = profile.email.as_deref() { + return match profile.user_name.as_deref() { + Some(name) => Some(format!("{name} ({email})")), + None => Some(email.to_string()), + }; + } + profile.api_key_hint.clone() +} + +pub(crate) fn profile_info_label(profile: &ProfileInfo) -> String { + let mut parts = vec![profile + .org_name + .clone() + .unwrap_or_else(|| "cross-org".to_string())]; + parts.push(profile.auth_method.clone()); + if let Some(identity) = profile_info_identity_label(profile) { + parts.push(identity); + } + parts.join(" — ") +} + pub fn select_profile_interactive(current: Option<&str>) -> Result> { let profiles = list_profiles()?; if profiles.is_empty() { - bail!("no auth profiles found. Run `bt auth login` to create one."); + bail!("no auth logins found. Run `bt auth login` to create one."); } if profiles.len() == 1 { return Ok(Some(profiles[0].name.clone())); } - let labels: Vec = profiles - .iter() - .map(|p| match &p.org_name { - Some(org) if org != &p.name => format!("{} (profile: {})", org, p.name), - _ => p.name.clone(), - }) - .collect(); + let labels: Vec = profiles.iter().map(profile_info_label).collect(); let default = current .and_then(|c| { @@ -228,7 +237,7 @@ pub fn select_profile_interactive(current: Option<&str>) -> Result, #[serde(default)] + org_id: Option, + #[serde(default)] org_name: Option, #[serde(default)] oauth_client_id: Option, @@ -317,10 +328,14 @@ struct AuthProfile { #[serde(default)] email: Option, #[serde(default)] + api_key_hash: Option, + #[serde(default)] api_key_hint: Option, + #[serde(default)] + legacy_secret_key: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] enum AuthKind { #[default] @@ -328,6 +343,13 @@ enum AuthKind { Oauth, } +fn auth_kind_label(kind: AuthKind) -> &'static str { + match kind { + AuthKind::ApiKey => "api_key", + AuthKind::Oauth => "oauth", + } +} + #[derive(Debug, Clone, Deserialize)] struct ApiKeyLoginResponse { org_info: Vec, @@ -377,8 +399,8 @@ struct OAuthErrorResponse { Examples: bt auth login bt auth profiles - bt auth refresh - bt auth logout --profile work + bt auth refresh --org acme + bt auth logout --org acme ")] pub struct AuthArgs { #[command(subcommand)] @@ -389,20 +411,16 @@ pub struct AuthArgs { enum AuthCommand { /// Authenticate with Braintrust (OAuth or API key) Login(AuthLoginArgs), - /// Force-refresh OAuth access token for a profile + /// Force-refresh OAuth access token for the selected org Refresh, - /// List auth profiles and check connection status + /// List saved auth logins and check connection status Profiles(AuthProfilesArgs), - /// Log out by removing a saved profile + /// Log out by removing a saved auth login Logout(AuthLogoutArgs), } #[derive(Debug, Clone, Args)] -struct AuthProfilesArgs { - /// Only show the profile with this name - #[arg(long, value_name = "NAME")] - profile: Option, -} +struct AuthProfilesArgs {} #[derive(Debug, Clone, Args)] struct AuthLoginArgs { @@ -410,7 +428,7 @@ struct AuthLoginArgs { #[arg(long)] oauth: bool, - /// OAuth client id (defaults to bt_cli_) + /// OAuth client id (defaults to bt_cli) #[arg(long, value_name = "CLIENT_ID")] client_id: Option, @@ -421,9 +439,9 @@ struct AuthLoginArgs { #[derive(Debug, Clone, Args)] struct AuthLogoutArgs { - /// Profile name to log out of (interactive picker if omitted) - #[arg(long)] - profile: Option, + /// API key hint to log out of when multiple API keys exist for an org + #[arg(long = "api-key-hint", value_name = "HINT")] + api_key_hint: Option, /// Skip confirmation prompt #[arg(long, short = 'f')] @@ -524,11 +542,8 @@ pub async fn login(base: &BaseArgs) -> Result { } let login = match builder.build().await { Ok(client) => client.wait_for_login().await?, - Err(err) if auth.is_oauth => { - let org_name = auth - .org_name - .clone() - .ok_or_else(|| anyhow::anyhow!("oauth profile is missing org_name: {err}"))?; + Err(_err) if auth.is_oauth => { + let org_name = auth.org_name.clone().unwrap_or_default(); let login = LoginState::new(); login.set( api_key.clone(), @@ -797,42 +812,27 @@ fn has_cached_project_id(base: &BaseArgs) -> bool { .is_some_and(|project_id| !project_id.trim().is_empty()) } -fn maybe_warn_api_key_override(base: &BaseArgs) { - if base.json || !std::io::stderr().is_terminal() { - return; - } - if resolve_api_key_override(base).is_none() { - return; - } +fn maybe_warn_api_key_override(_base: &BaseArgs) {} - let ignored_profile = base - .profile - .as_ref() - .map(|value| value.trim()) - .filter(|value| !value.is_empty()); - - if let Some(profile_name) = ignored_profile { - eprintln!( - "Info: using --api-key/BRAINTRUST_API_KEY credentials; selected profile '{profile_name}' is ignored for this command. Use --prefer-profile or unset BRAINTRUST_API_KEY to use a profile with OAuth login.", - ); +fn resolve_cli_api_key_override(base: &BaseArgs) -> Option { + if matches!( + base.api_key_source, + Some(crate::args::ArgValueSource::EnvVariable) + ) { + return None; } + let value = base.api_key.as_deref()?.trim(); + if value.is_empty() { + return None; + } + Some(value.to_string()) } -fn has_explicit_profile_selection(base: &BaseArgs) -> bool { - base.profile_explicit - && base - .profile - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) -} - -fn resolve_api_key_override(base: &BaseArgs) -> Option { - if (base.prefer_profile || has_explicit_profile_selection(base)) - && !matches!( - base.api_key_source, - Some(crate::args::ArgValueSource::CommandLine) - ) - { +fn resolve_env_api_key(base: &BaseArgs) -> Option { + if !matches!( + base.api_key_source, + Some(crate::args::ArgValueSource::EnvVariable) + ) { return None; } let value = base.api_key.as_deref()?.trim(); @@ -842,115 +842,278 @@ fn resolve_api_key_override(base: &BaseArgs) -> Option { Some(value.to_string()) } -fn config_auth_context(base: &BaseArgs) -> (Option, Option) { +#[cfg(test)] +fn resolve_api_key_override(base: &BaseArgs) -> Option { + resolve_cli_api_key_override(base) +} + +fn config_auth_context(base: &BaseArgs) -> Option { let cfg = crate::config::load().unwrap_or_default(); config_auth_context_from_config(base, &cfg) } -fn config_auth_context_from_config( - base: &BaseArgs, - cfg: &crate::config::Config, -) -> (Option, Option) { - let profile = if crate::config::trimmed_option(base.profile.as_deref()).is_none() { - crate::config::trimmed_option(cfg.profile.as_deref()).map(str::to_string) - } else { - None - }; - - let org = if crate::config::trimmed_option(base.org_name.as_deref()).is_none() { +fn config_auth_context_from_config(base: &BaseArgs, cfg: &crate::config::Config) -> Option { + if crate::config::trimmed_option(base.org_name.as_deref()).is_none() { crate::config::trimmed_option(cfg.org.as_deref()).map(str::to_string) } else { None - }; + } +} - (profile, org) +fn effective_org_name<'a>(base: &'a BaseArgs, cfg_org: &'a Option) -> Option<&'a str> { + crate::config::trimmed_option(base.org_name.as_deref()) + .or_else(|| crate::config::trimmed_option(cfg_org.as_deref())) } pub async fn resolve_auth(base: &BaseArgs) -> Result { let mut store = load_auth_store()?; - let mut auth_base = base.clone(); - let (cfg_profile, cfg_org) = config_auth_context(base); - if let Some(profile) = cfg_profile { - auth_base.profile = Some(profile); + let cfg_org = config_auth_context(base); + + if let Some(api_key) = resolve_cli_api_key_override(base) { + return Ok(ResolvedAuth { + api_key: Some(api_key), + api_url: base.api_url.clone(), + app_url: base.app_url.clone(), + org_name: effective_org_name(base, &cfg_org).map(str::to_string), + is_oauth: false, + }); + } + + if base.prefer_api_key { + if let Some(auth) = resolve_preferred_api_key_auth(base, &mut store, &cfg_org)? { + return Ok(auth); + } + if let Some(profile_name) = + select_oauth_profile_for_auth(base, &store, &cfg_org, ui::can_prompt())? + { + return resolve_oauth_profile_auth(base, &mut store, &cfg_org, &profile_name).await; + } + bail!("--prefer-api-key requires an API key or OAuth login for the selected org"); } if let Some(profile_name) = - maybe_select_profile_for_auth(&auth_base, &store, &cfg_org, ui::can_prompt())? + select_oauth_profile_for_auth(base, &store, &cfg_org, ui::can_prompt())? { - auth_base.profile = Some(profile_name); + return resolve_oauth_profile_auth(base, &mut store, &cfg_org, &profile_name).await; } - let mut auth = resolve_auth_from_store_with_secret_lookup( - &auth_base, - &store, - load_profile_secret, - &cfg_org, - )?; - if !auth.is_oauth { - return Ok(auth); + if let Some(profile_name) = + select_api_key_profile_for_auth(base, &store, &cfg_org, ui::can_prompt())? + { + return resolve_api_key_profile_auth(base, &mut store, &cfg_org, &profile_name); } - let effective_org = auth_base.org_name.as_deref().or(cfg_org.as_deref()); - let profile_name = auth_base - .profile + if let Some(api_key) = resolve_env_api_key(base) { + return Ok(ResolvedAuth { + api_key: Some(api_key), + api_url: base.api_url.clone(), + app_url: base.app_url.clone(), + org_name: effective_org_name(base, &cfg_org).map(str::to_string), + is_oauth: false, + }); + } + + if effective_org_name(base, &cfg_org).is_none() { + if let Some(err) = missing_org_for_stored_logins_error(&store) { + return Err(err); + } + } + + Ok(ResolvedAuth { + api_key: None, + api_url: base.api_url.clone(), + app_url: base.app_url.clone(), + org_name: effective_org_name(base, &cfg_org).map(str::to_string), + is_oauth: false, + }) +} + +fn resolve_preferred_api_key_auth( + base: &BaseArgs, + store: &mut AuthStore, + cfg_org: &Option, +) -> Result> { + let Some(org) = effective_org_name(base, cfg_org) else { + bail!( + "--prefer-api-key requires an org; pass --org or run `bt switch /`" + ); + }; + + if let Some(api_key) = resolve_env_api_key(base) { + return Ok(Some(ResolvedAuth { + api_key: Some(api_key), + api_url: base.api_url.clone(), + app_url: base.app_url.clone(), + org_name: Some(org.to_string()), + is_oauth: false, + })); + } + + if let Some(profile_name) = + select_api_key_profile_for_auth(base, store, cfg_org, ui::can_prompt())? + { + return resolve_api_key_profile_auth(base, store, cfg_org, &profile_name).map(Some); + } + + Ok(None) +} + +fn resolve_api_key_profile_auth( + base: &BaseArgs, + store: &mut AuthStore, + cfg_org: &Option, + profile_name: &str, +) -> Result { + let profile = store.profiles.get(profile_name).cloned().ok_or_else(|| { + anyhow::anyhow!("auth login '{profile_name}' not found; run `bt auth profiles`") + })?; + let api_key = load_profile_secret_with_legacy( + profile_name, + profile.legacy_secret_key.as_deref(), + )? + .ok_or_else(|| { + recoverable_auth_error( + RecoverableAuthErrorKind::StoredCredential, + format!( + "no keychain credential found for auth login '{}'; re-run `bt auth login --org --api-key `", + auth_slot_label(profile_name, &profile) + ), + ) + })?; + + let resolved = ResolvedAuth { + api_key: Some(api_key.clone()), + api_url: base.api_url.clone().or_else(|| profile.api_url.clone()), + app_url: base.app_url.clone().or_else(|| profile.app_url.clone()), + org_name: effective_org_name(base, cfg_org) + .map(str::to_string) + .or_else(|| profile.org_name.clone()), + is_oauth: false, + }; + + maybe_rekey_api_key_profile_after_secret_load(store, profile_name, &api_key)?; + Ok(resolved) +} + +fn maybe_rekey_api_key_profile_after_secret_load( + store: &mut AuthStore, + profile_name: &str, + api_key: &str, +) -> Result<()> { + let Some(profile) = store.profiles.get(profile_name).cloned() else { + return Ok(()); + }; + if profile.auth_kind != AuthKind::ApiKey { + return Ok(()); + } + let Some(org_id) = profile + .org_id .as_deref() - .filter(|value| !value.trim().is_empty()) - .or_else(|| effective_org.and_then(|org| resolve_profile_for_org(org, &store))) - .or_else(|| { - (store.profiles.len() == 1).then(|| store.profiles.keys().next().unwrap().as_str()) - }) - .ok_or_else(|| { - recoverable_auth_error( - RecoverableAuthErrorKind::OauthProfileSelection, - "oauth profile requested but none selected".to_string(), - ) - })? - .to_string(); + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Ok(()); + }; + + let hash = api_key_hash(api_key); + let new_key = api_key_slot_key(&hash, org_id); + let already_canonical = + profile_name == new_key && profile.api_key_hash.as_deref() == Some(&hash); + if already_canonical { + return Ok(()); + } + + let mut updated = profile; + updated.api_key_hash = Some(hash); + if profile_name != new_key && updated.legacy_secret_key.is_none() { + updated.legacy_secret_key = Some(profile_name.to_string()); + } + + if profile_name != new_key { + store.profiles.remove(profile_name); + } + store.profiles.insert(new_key, updated); + save_auth_store(store) +} + +async fn resolve_oauth_profile_auth( + base: &BaseArgs, + store: &mut AuthStore, + cfg_org: &Option, + profile_name: &str, +) -> Result { let profile = store .profiles - .get(profile_name.as_str()) - .ok_or_else(|| anyhow::anyhow!("profile '{profile_name}' not found"))?; + .get(profile_name) + .cloned() + .ok_or_else(|| anyhow::anyhow!("oauth login '{profile_name}' not found"))?; let client_id = profile.oauth_client_id.as_deref().ok_or_else(|| { recoverable_auth_error( RecoverableAuthErrorKind::OauthClientId, format!( - "oauth profile '{profile_name}' is missing client_id; re-run `bt auth login --oauth --profile {profile_name}`" + "oauth login for '{}' is missing client_id; re-run `bt auth login --oauth --org `", + auth_slot_label(profile_name, &profile) ), ) })?; - let cached_expires_at = profile.oauth_access_expires_at; - let api_url = auth + let api_url = base .api_url .clone() + .or_else(|| profile.api_url.clone()) .unwrap_or_else(|| DEFAULT_API_URL.to_string()); + let app_url = base.app_url.clone().or_else(|| profile.app_url.clone()); + let org_name = effective_org_name(base, cfg_org) + .map(str::to_string) + .or_else(|| profile.org_name.clone()); - if let Some(cached_access_token) = - load_valid_cached_oauth_access_token(&profile_name, cached_expires_at)? - { + let mut auth = ResolvedAuth { + api_key: None, + api_url: Some(api_url.clone()), + app_url, + org_name, + is_oauth: true, + }; + + if let Some(cached_access_token) = load_valid_cached_oauth_access_token( + profile_name, + &profile, + profile.oauth_access_expires_at, + )? { auth.api_key = Some(cached_access_token); return Ok(auth); } - let refresh_token = load_profile_oauth_refresh_token(&profile_name)?.ok_or_else(|| { - recoverable_auth_error( - RecoverableAuthErrorKind::OauthRefreshToken, - format!( - "oauth refresh token missing for profile '{profile_name}'; re-run `bt auth login --oauth --profile {profile_name}`" - ), - ) - })?; + let refresh_token = load_profile_oauth_refresh_token_for_profile(profile_name, &profile)? + .ok_or_else(|| { + recoverable_auth_error( + RecoverableAuthErrorKind::OauthRefreshToken, + format!( + "oauth refresh token missing for '{}'; re-run `bt auth login --oauth --org `", + auth_slot_label(profile_name, &profile) + ), + ) + })?; let refreshed = - refresh_oauth_access_token(&api_url, &refresh_token, client_id, &profile_name).await?; - save_profile_oauth_access_token(&profile_name, &refreshed.access_token)?; + refresh_oauth_access_token(&api_url, &refresh_token, client_id, profile_name).await?; + save_profile_oauth_access_token(profile_name, &refreshed.access_token)?; + let mut refresh_rotated = false; if let Some(next_refresh_token) = refreshed.refresh_token.as_ref() { if next_refresh_token != &refresh_token { - save_profile_oauth_refresh_token(&profile_name, next_refresh_token)?; + save_profile_oauth_refresh_token(profile_name, next_refresh_token)?; + refresh_rotated = true; } } - if let Some(profile) = store.profiles.get_mut(&profile_name) { + if !refresh_rotated && profile.legacy_secret_key.is_some() { + save_profile_oauth_refresh_token(profile_name, &refresh_token)?; + } + if let Some(profile) = store.profiles.get_mut(profile_name) { profile.oauth_access_expires_at = determine_oauth_access_expiry_epoch(&refreshed); + if refresh_rotated || profile.legacy_secret_key.is_some() { + delete_legacy_profile_secrets(profile); + profile.legacy_secret_key = None; + } } - save_auth_store(&store)?; + save_auth_store(store)?; auth.api_key = Some(refreshed.access_token); Ok(auth) } @@ -986,22 +1149,48 @@ pub async fn resolved_runner_env(base: &BaseArgs) -> Result(org: &str, store: &'a AuthStore) -> Option<&'a str> { - if store.profiles.contains_key(org) { - return Some( - store - .profiles - .keys() - .find(|k| k.as_str() == org) - .map(|k| k.as_str()) - .unwrap(), - ); +fn profile_matches_org_identifier(profile: &AuthProfile, org: &str) -> bool { + profile.org_id.as_deref() == Some(org) || profile.org_name.as_deref() == Some(org) +} + +fn profile_org_label(profile: &AuthProfile) -> String { + profile + .org_name + .as_deref() + .filter(|org| !org.trim().is_empty()) + .map(str::to_string) + .unwrap_or_else(|| "cross-org".to_string()) +} + +fn profile_identity_label(profile: &AuthProfile) -> Option { + match profile.auth_kind { + AuthKind::Oauth => match (profile.user_name.as_deref(), profile.email.as_deref()) { + (Some(name), Some(email)) => Some(format!("{name} ({email})")), + (None, Some(email)) => Some(email.to_string()), + (Some(name), None) => Some(name.to_string()), + (None, None) => None, + }, + AuthKind::ApiKey => profile.api_key_hint.clone(), } +} +fn auth_slot_label(name: &str, profile: &AuthProfile) -> String { + let mut parts = vec![profile_org_label(profile)]; + parts.push(auth_kind_label(profile.auth_kind).to_string()); + if let Some(identity) = profile_identity_label(profile) { + parts.push(identity); + } else if !name.contains("::") { + parts.push(name.to_string()); + } + parts.join(" — ") +} + +#[cfg(test)] +fn resolve_profile_for_org<'a>(org: &str, store: &'a AuthStore) -> Option<&'a str> { let matches: Vec<&str> = store .profiles .iter() - .filter(|(_, p)| p.org_name.as_deref() == Some(org)) + .filter(|(name, p)| name.as_str() == org || profile_matches_org_identifier(p, org)) .map(|(name, _)| name.as_str()) .collect(); @@ -1012,26 +1201,172 @@ fn resolve_profile_for_org<'a>(org: &str, store: &'a AuthStore) -> Option<&'a st } } +#[cfg(test)] fn profile_names_for_org<'a>(org: &str, store: &'a AuthStore) -> Vec<&'a str> { store .profiles .iter() - .filter(|(_, profile)| profile.org_name.as_deref() == Some(org)) + .filter(|(_, profile)| profile_matches_org_identifier(profile, org)) .map(|(name, _)| name.as_str()) .collect() } -fn profile_label_from_store(name: &str, store: &AuthStore) -> String { - match store +fn is_cross_org_oauth_profile(profile: &AuthProfile) -> bool { + profile.auth_kind == AuthKind::Oauth + && profile + .org_id + .as_deref() + .is_none_or(|org_id| org_id.trim().is_empty()) + && profile + .org_name + .as_deref() + .is_none_or(|org_name| org_name.trim().is_empty()) +} + +fn auth_profile_names_by_kind<'a>( + store: &'a AuthStore, + org: Option<&str>, + kind: AuthKind, +) -> Vec<&'a str> { + store + .profiles + .iter() + .filter(|(_, profile)| profile.auth_kind == kind) + .filter(|(_, profile)| match org { + Some(org) => profile_matches_org_identifier(profile, org), + None if kind == AuthKind::Oauth => is_cross_org_oauth_profile(profile), + None => false, + }) + .map(|(name, _)| name.as_str()) + .collect() +} + +fn profile_info_from_store_entry(name: &str, profile: &AuthProfile) -> ProfileInfo { + ProfileInfo { + name: name.to_string(), + auth_method: auth_kind_label(profile.auth_kind).to_string(), + org_name: profile.org_name.clone(), + user_name: profile.user_name.clone(), + email: profile.email.clone(), + api_key_hint: profile.api_key_hint.clone(), + } +} + +fn profile_info_for_candidate(store: &AuthStore, name: &str) -> Option { + store .profiles .get(name) - .and_then(|profile| profile.org_name.as_deref()) - { - Some(org) if org != name => format!("{} (profile: {})", org, name), - _ => name.to_string(), + .map(|profile| profile_info_from_store_entry(name, profile)) +} + +fn ad_hoc_api_key_profile(org: Option<&str>, api_key: &str) -> ProfileInfo { + ProfileInfo { + name: String::new(), + auth_method: auth_kind_label(AuthKind::ApiKey).to_string(), + org_name: org.map(str::to_string), + user_name: None, + email: None, + api_key_hint: Some(obscure_api_key(api_key)), } } +pub(crate) fn active_auth_info(base: &BaseArgs, org: Option<&str>) -> Option { + if let Some(api_key) = resolve_cli_api_key_override(base) { + return Some(ad_hoc_api_key_profile(org, &api_key)); + } + + let store = load_auth_store().unwrap_or_default(); + + if base.prefer_api_key { + let org = org?; + if let Some(api_key) = resolve_env_api_key(base) { + return Some(ad_hoc_api_key_profile(Some(org), &api_key)); + } + + let api_key_candidates = auth_profile_names_by_kind(&store, Some(org), AuthKind::ApiKey); + match api_key_candidates.as_slice() { + [name] => return profile_info_for_candidate(&store, name), + [] => {} + _ => return None, + } + + let oauth_candidates = auth_profile_names_by_kind(&store, Some(org), AuthKind::Oauth); + return match oauth_candidates.as_slice() { + [name] => profile_info_for_candidate(&store, name), + [] => None, + _ => None, + }; + } + + let oauth_candidates = auth_profile_names_by_kind(&store, org, AuthKind::Oauth); + match oauth_candidates.as_slice() { + [name] => return profile_info_for_candidate(&store, name), + [] => {} + _ => return None, + } + + let api_key_candidates = auth_profile_names_by_kind(&store, org, AuthKind::ApiKey); + match api_key_candidates.as_slice() { + [name] => return profile_info_for_candidate(&store, name), + [] => {} + _ => return None, + } + + resolve_env_api_key(base).map(|api_key| ad_hoc_api_key_profile(org, &api_key)) +} + +fn missing_org_for_stored_logins_error(store: &AuthStore) -> Option { + let candidates = store + .profiles + .iter() + .filter(|(_, profile)| { + !is_cross_org_oauth_profile(profile) + && (profile + .org_id + .as_deref() + .is_some_and(|org_id| !org_id.trim().is_empty()) + || profile + .org_name + .as_deref() + .is_some_and(|org_name| !org_name.trim().is_empty())) + }) + .collect::>(); + if candidates.is_empty() { + return None; + } + + let labels = candidates + .iter() + .map(|(name, profile)| auth_slot_label(name, profile)) + .collect::>() + .join(", "); + let all_api_key = candidates + .iter() + .all(|(_, profile)| profile.auth_kind == AuthKind::ApiKey); + + Some(if candidates.len() == 1 { + anyhow::anyhow!( + "auth org selection required; pass --org to use saved auth login: {labels}" + ) + } else if all_api_key { + anyhow::anyhow!( + "multiple API key logins available: {labels}. Pass --org to disambiguate." + ) + } else { + anyhow::anyhow!( + "multiple auth logins available: {labels}. Pass --org to disambiguate." + ) + }) +} + +fn profile_label_from_store(name: &str, store: &AuthStore) -> String { + store + .profiles + .get(name) + .map(|profile| auth_slot_label(name, profile)) + .unwrap_or_else(|| name.to_string()) +} + fn select_profile_from_store( prompt: &str, names: &[&str], @@ -1049,15 +1384,81 @@ fn select_profile_from_store( || store .profiles .get(*name) - .and_then(|profile| profile.org_name.as_deref()) - == Some(current) + .is_some_and(|profile| profile_matches_org_identifier(profile, current)) }) }) .unwrap_or(0); - let idx = ui::fuzzy_select(prompt, &labels, default)?; + let label_refs: Vec<&str> = labels.iter().map(String::as_str).collect(); + let idx = ui::fuzzy_select(prompt, &label_refs, default)?; Ok(names[idx].to_string()) } +fn candidate_identities<'a>(names: &[&'a str], store: &'a AuthStore) -> Vec { + names + .iter() + .map(|name| { + store + .profiles + .get(*name) + .and_then(profile_identity_label) + .unwrap_or_else(|| (*name).to_string()) + }) + .collect() +} + +fn select_oauth_profile_for_auth( + base: &BaseArgs, + store: &AuthStore, + cfg_org: &Option, + can_prompt: bool, +) -> Result> { + let org = effective_org_name(base, cfg_org); + let candidates = auth_profile_names_by_kind(store, org, AuthKind::Oauth); + select_auth_profile_candidate("OAuth login", org, &candidates, store, can_prompt) +} + +fn select_api_key_profile_for_auth( + base: &BaseArgs, + store: &AuthStore, + cfg_org: &Option, + can_prompt: bool, +) -> Result> { + let org = effective_org_name(base, cfg_org); + let candidates = auth_profile_names_by_kind(store, org, AuthKind::ApiKey); + select_auth_profile_candidate("API key", org, &candidates, store, can_prompt) +} + +fn select_auth_profile_candidate( + kind_label: &str, + org: Option<&str>, + candidates: &[&str], + store: &AuthStore, + can_prompt: bool, +) -> Result> { + match candidates.len() { + 0 => Ok(None), + 1 => Ok(Some(candidates[0].to_string())), + _ if can_prompt => { + let prompt = org + .map(|org| format!("Multiple {kind_label} logins for '{org}'. Select one")) + .unwrap_or_else(|| format!("Select {kind_label} login")); + select_profile_from_store(&prompt, candidates, org, store).map(Some) + } + _ => { + let identities = candidate_identities(candidates, store).join(", "); + if let Some(org) = org { + bail!( + "multiple {kind_label} logins for org '{org}': {identities}. Rerun interactively or remove one with `bt auth logout`." + ); + } + bail!( + "multiple cross-org {kind_label} logins available: {identities}. Rerun interactively or remove one with `bt auth logout`." + ); + } + } +} + +#[cfg(test)] fn maybe_select_profile_for_auth( base: &BaseArgs, store: &AuthStore, @@ -1090,7 +1491,7 @@ fn maybe_select_profile_for_auth( if !can_prompt { bail!( - "multiple profiles for org '{org}': {}. Use --profile to disambiguate.", + "multiple auth logins for org '{org}': {}. Use --org to disambiguate.", matching_profiles.join(", ") ); } @@ -1111,7 +1512,7 @@ fn maybe_select_profile_for_auth( let names: Vec<&str> = store.profiles.keys().map(|name| name.as_str()).collect(); if !can_prompt { bail!( - "multiple auth profiles available: {}. Pass --profile , set BRAINTRUST_PROFILE, or configure an org.", + "multiple auth logins available: {}. Pass --org to disambiguate.", names.join(", ") ); } @@ -1119,6 +1520,7 @@ fn maybe_select_profile_for_auth( select_profile_from_store("Select org", &names, None, store).map(Some) } +#[cfg(test)] fn resolve_auth_from_store_with_secret_lookup( base: &BaseArgs, store: &AuthStore, @@ -1159,7 +1561,7 @@ where if let Some(profile_name) = selected_profile_name { let profile = store.profiles.get(profile_name).ok_or_else(|| { anyhow::anyhow!( - "profile '{profile_name}' not found; run `bt auth profiles` or `bt auth login --profile {profile_name}`" + "auth login '{profile_name}' not found; run `bt auth profiles` or `bt auth login --org `" ) })?; let is_oauth = profile.auth_kind == AuthKind::Oauth; @@ -1170,7 +1572,7 @@ where recoverable_auth_error( RecoverableAuthErrorKind::StoredCredential, format!( - "no keychain credential found for profile '{profile_name}'; re-run `bt auth login --profile {profile_name}`" + "no keychain credential found for auth login '{profile_name}'; re-run `bt auth login --org `" ), ) })?) @@ -1225,7 +1627,6 @@ async fn run_login_set(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> { .clone() .unwrap_or_else(|| DEFAULT_APP_URL.to_string()); let login_orgs = fetch_login_orgs(&api_key, &login_app_url).await?; - let store = load_auth_store()?; let requested_org_resolution = resolve_requested_org_for_api_key_login( &login_orgs, base.org_name.as_deref(), @@ -1235,8 +1636,6 @@ async fn run_login_set(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> { if requested_org_resolution == RequestedOrgResolution::SwitchToOauth { return run_login_oauth(base, args).await; } - let default_org_name = - default_login_org_name(&store, base.profile.as_deref(), base.org_name.as_deref()); let selected_org = select_login_org( login_orgs.clone(), match requested_org_resolution { @@ -1246,51 +1645,45 @@ async fn run_login_set(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> { } RequestedOrgResolution::SwitchToOauth => unreachable!("handled above"), }, - default_org_name.as_deref(), + None, interactive, base.verbose, - true, + false, explicitly_quiet(base), )?; + let selected_org = selected_org.ok_or_else(|| { + anyhow::anyhow!("API-key login requires an org; pass --org or rerun interactively") + })?; let selected_api_url = - resolve_profile_api_url(base.api_url.clone(), selected_org.as_ref(), &login_orgs)?; - let (profile_name, should_confirm_overwrite) = resolve_api_key_login_profile_name( - base.profile.as_deref(), - selected_org.as_ref().map(|org| org.name.as_str()), - &selected_api_url, - &store, - )?; - if should_confirm_overwrite { - confirm_profile_overwrite(&profile_name)?; - } + resolve_profile_api_url(base.api_url.clone(), Some(&selected_org), &login_orgs)?; - commit_api_key_profile( - &profile_name, + let _slot_key = commit_api_key_profile( &api_key, selected_api_url.clone(), base.app_url.clone(), - selected_org.as_ref().map(|org| org.name.clone()), + selected_org.id.clone(), + selected_org.name.clone(), )?; let context_update = persist_post_login_context( base, - &profile_name, &api_key, &selected_api_url, &login_app_url, - selected_org.as_ref(), + Some(&selected_org), ) .await .context("login succeeded, but failed to update active context")?; - let human = format_login_success(&selected_org, &profile_name, &selected_api_url); + let human = format_login_success(Some(&selected_org), &selected_api_url); emit_result( base.json, serde_json::json!({ - "name": profile_name, "auth": "api_key", - "org": selected_org.as_ref().map(|org| org.name.clone()), + "org": selected_org.name, + "org_id": selected_org.id, "api_url": selected_api_url, - "app_url": base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL), + "app_url": login_app_url, + "api_key_hint": obscure_api_key(&api_key), "status": "ok", }), || { @@ -1315,16 +1708,10 @@ async fn run_login_oauth(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> { .app_url .clone() .unwrap_or_else(|| DEFAULT_APP_URL.to_string()); - let provisional_profile = base - .profile - .as_deref() - .map(str::trim) - .filter(|name| !name.is_empty()) - .unwrap_or("default"); let client_id = args .client_id .clone() - .unwrap_or_else(|| default_oauth_client_id(provisional_profile)); + .unwrap_or_else(default_oauth_client_id); let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); let state = generate_random_token(32)?; @@ -1375,13 +1762,10 @@ async fn run_login_oauth(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> { ) .await?; let login_orgs = fetch_login_orgs(&oauth_tokens.access_token, &app_url).await?; - let store = load_auth_store()?; - let default_org_name = - default_login_org_name(&store, base.profile.as_deref(), base.org_name.as_deref()); let selected_org = select_login_org( login_orgs.clone(), base.org_name.as_deref(), - default_org_name.as_deref(), + None, ui::can_prompt(), base.verbose, true, @@ -1389,30 +1773,16 @@ async fn run_login_oauth(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> { )?; let selected_api_url = resolve_profile_api_url(base.api_url.clone(), selected_org.as_ref(), &login_orgs)?; - let jwt_id = decode_jwt_identity(&oauth_tokens.access_token); - let (profile_name, should_confirm_overwrite) = resolve_oauth_login_profile_name( - base.profile.as_deref(), - selected_org.as_ref().map(|org| org.name.as_str()), - &selected_api_url, - &app_url, - &jwt_id, - &store, - )?; - if should_confirm_overwrite { - confirm_profile_overwrite(&profile_name)?; - } - commit_oauth_profile( - &profile_name, + let _slot_key = commit_oauth_profile( &oauth_tokens, selected_api_url.clone(), app_url.clone(), client_id.clone(), - selected_org.as_ref().map(|org| org.name.clone()), + selected_org.as_ref(), )?; let context_update = persist_post_login_context( base, - &profile_name, &oauth_tokens.access_token, &selected_api_url, &app_url, @@ -1421,13 +1791,14 @@ async fn run_login_oauth(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> { .await .context("login succeeded, but failed to update active context")?; - let human = format_login_success(&selected_org, &profile_name, &selected_api_url); + let human = format_login_success(selected_org.as_ref(), &selected_api_url); emit_result( base.json, serde_json::json!({ - "name": profile_name, "auth": "oauth", "org": selected_org.as_ref().map(|org| org.name.clone()), + "org_id": selected_org.as_ref().map(|org| org.id.clone()), + "cross_org": selected_org.is_none(), "api_url": selected_api_url, "app_url": app_url, "status": "ok", @@ -1446,84 +1817,116 @@ async fn run_login_oauth(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> { } pub(crate) fn commit_api_key_profile( - profile_name: &str, api_key: &str, api_url: String, app_url: Option, - org_name: Option, -) -> Result<()> { - save_profile_secret(profile_name, api_key)?; - let _ = delete_profile_oauth_refresh_token(profile_name); - let _ = delete_profile_oauth_access_token(profile_name); + org_id: String, + org_name: String, +) -> Result { + let hash = api_key_hash(api_key); + let slot_key = api_key_slot_key(&hash, &org_id); + save_profile_secret(&slot_key, api_key)?; let mut store = load_auth_store()?; + if let Some(old_profile) = store.profiles.get(&slot_key) { + delete_legacy_profile_secrets(old_profile); + } store.profiles.insert( - profile_name.to_string(), + slot_key.clone(), AuthProfile { auth_kind: AuthKind::ApiKey, api_url: Some(api_url), app_url, - org_name, + org_id: Some(org_id), + org_name: Some(org_name), oauth_client_id: None, oauth_access_expires_at: None, user_name: None, email: None, + api_key_hash: Some(hash), api_key_hint: Some(obscure_api_key(api_key)), + legacy_secret_key: None, }, ); - save_auth_store(&store) + save_auth_store(&store)?; + Ok(slot_key) } fn commit_oauth_profile( - profile_name: &str, tokens: &OAuthTokenResponse, api_url: String, app_url: String, client_id: String, - org_name: Option, -) -> Result<()> { + selected_org: Option<&LoginOrgInfo>, +) -> Result { let refresh_token = tokens.refresh_token.as_ref().ok_or_else(|| { anyhow::anyhow!( - "oauth token response did not include a refresh_token; cannot create persistent oauth profile" + "oauth token response did not include a refresh_token; cannot create persistent oauth login" ) })?; - save_profile_oauth_refresh_token(profile_name, refresh_token)?; - save_profile_oauth_access_token(profile_name, &tokens.access_token)?; - let _ = delete_profile_secret(profile_name); let oauth_access_expires_at = determine_oauth_access_expiry_epoch(tokens); let jwt_id = decode_jwt_identity(&tokens.access_token); + let email = jwt_id + .email + .clone() + .filter(|email| !email.trim().is_empty()) + .ok_or_else(|| { + anyhow::anyhow!( + "oauth token did not include an email; cannot create persistent oauth login" + ) + })?; + let org_id = selected_org.map(|org| org.id.clone()).unwrap_or_default(); + let slot_key = oauth_slot_key(&org_id, &email); + + save_profile_oauth_refresh_token(&slot_key, refresh_token)?; + save_profile_oauth_access_token(&slot_key, &tokens.access_token)?; + let _ = delete_profile_secret(&slot_key); let mut store = load_auth_store()?; + if let Some(old_profile) = store.profiles.get(&slot_key) { + delete_legacy_profile_secrets(old_profile); + } store.profiles.insert( - profile_name.to_string(), + slot_key.clone(), AuthProfile { auth_kind: AuthKind::Oauth, api_url: Some(api_url), app_url: Some(app_url), - org_name, + org_id: Some(org_id), + org_name: selected_org.map(|org| org.name.clone()), oauth_client_id: Some(client_id), oauth_access_expires_at, user_name: jwt_id.name, email: jwt_id.email, + api_key_hash: None, api_key_hint: None, + legacy_secret_key: None, }, ); - save_auth_store(&store) + save_auth_store(&store)?; + Ok(slot_key) } async fn run_login_refresh(base: &BaseArgs) -> Result<()> { let mut store = load_auth_store()?; - let (profile_name, source) = resolve_selected_profile_name_for_debug(base, &store)?; + let cfg_org = config_auth_context(base); + let profile_name = select_oauth_profile_for_auth(base, &store, &cfg_org, ui::can_prompt())? + .ok_or_else(|| { + anyhow::anyhow!( + "no OAuth login selected; pass --org or run `bt auth profiles` to see available logins" + ) + })?; let profile = store .profiles .get(profile_name.as_str()) - .ok_or_else(|| profile_not_found_err(&profile_name, &store))?; - if profile.auth_kind != AuthKind::Oauth { - bail!( - "profile '{profile_name}' uses api key auth; `bt auth refresh` only applies to oauth profiles" - ); - } + .cloned() + .ok_or_else(|| { + anyhow::anyhow!( + "OAuth login '{}' not found; run `bt auth profiles` to see available logins", + profile_name + ) + })?; let api_url = profile .api_url @@ -1531,18 +1934,24 @@ async fn run_login_refresh(base: &BaseArgs) -> Result<()> { .unwrap_or_else(|| DEFAULT_API_URL.to_string()); let client_id = profile.oauth_client_id.clone().ok_or_else(|| { anyhow::anyhow!( - "oauth profile '{profile_name}' is missing client_id; re-run `bt auth login --oauth --profile {profile_name}`" + "OAuth login for '{}' is missing client_id; re-run `bt auth login --oauth --org `", + auth_slot_label(&profile_name, &profile) ) })?; let previous_expires_at = profile.oauth_access_expires_at; - let refresh_token = load_profile_oauth_refresh_token(profile_name.as_str())?.ok_or_else(|| { - anyhow::anyhow!( - "oauth refresh token missing for profile '{profile_name}'; re-run `bt auth login --oauth --profile {profile_name}`" + let refresh_token = + load_profile_oauth_refresh_token_for_profile(profile_name.as_str(), &profile)?.ok_or_else( + || { + anyhow::anyhow!( + "OAuth refresh token missing for '{}'; re-run `bt auth login --oauth --org `", + auth_slot_label(&profile_name, &profile) ) - })?; + }, + )?; eprintln!( - "Refreshing OAuth token for profile '{profile_name}' (source: {source}, api_url: {api_url})" + "Refreshing OAuth token for {} (api_url: {api_url})", + auth_slot_label(&profile_name, &profile) ); if let Some(expires_at) = previous_expires_at { let now = current_unix_timestamp(); @@ -1565,10 +1974,17 @@ async fn run_login_refresh(base: &BaseArgs) -> Result<()> { refresh_rotated = true; } } + if !refresh_rotated && profile.legacy_secret_key.is_some() { + save_profile_oauth_refresh_token(profile_name.as_str(), &refresh_token)?; + } let new_expires_at = determine_oauth_access_expiry_epoch(&refreshed); if let Some(profile) = store.profiles.get_mut(profile_name.as_str()) { profile.oauth_access_expires_at = new_expires_at; + if refresh_rotated || profile.legacy_secret_key.is_some() { + delete_legacy_profile_secrets(profile); + profile.legacy_secret_key = None; + } } save_auth_store(&store)?; @@ -1588,8 +2004,10 @@ async fn run_login_refresh(base: &BaseArgs) -> Result<()> { emit_result( base.json, serde_json::json!({ - "name": profile_name, "auth": "oauth", + "org": profile.org_name, + "org_id": profile.org_id.filter(|org_id| !org_id.trim().is_empty()), + "user_email": profile.email, "access_expires_at": new_expires_at, "refresh_token_rotated": refresh_rotated, "status": "ok", @@ -1598,6 +2016,7 @@ async fn run_login_refresh(base: &BaseArgs) -> Result<()> { ) } +#[cfg(test)] fn resolve_selected_profile_name_for_debug( base: &BaseArgs, store: &AuthStore, @@ -1605,7 +2024,7 @@ fn resolve_selected_profile_name_for_debug( if let Some(profile_name) = base.profile.as_deref() { let profile_name = profile_name.trim(); if !profile_name.is_empty() { - return Ok((profile_name.to_string(), "--profile/BRAINTRUST_PROFILE")); + return Ok((profile_name.to_string(), "legacy profile")); } } @@ -1626,9 +2045,10 @@ fn resolve_selected_profile_name_for_debug( } } - bail!("no profile selected; pass --profile , set BRAINTRUST_PROFILE, or configure an org") + bail!("no auth login selected; pass --org or rerun interactively") } +#[cfg(test)] fn resolve_profile_name( explicit_profile: Option<&str>, suggested_org_name: Option<&str>, @@ -1648,6 +2068,7 @@ fn resolve_profile_name( .to_string()) } +#[cfg(test)] fn default_login_org_name( store: &AuthStore, profile_name: Option<&str>, @@ -1673,6 +2094,7 @@ fn default_login_org_name( Some(stored_org_name.unwrap_or(profile_name).to_string()) } +#[cfg(test)] fn default_profile_name(suggested_org_name: Option<&str>) -> String { suggested_org_name .map(str::trim) @@ -1681,6 +2103,7 @@ fn default_profile_name(suggested_org_name: Option<&str>) -> String { .to_string() } +#[cfg(test)] fn next_available_profile_name(base_name: &str, store: &AuthStore) -> String { if !store.profiles.contains_key(base_name) { return base_name.to_string(); @@ -1692,6 +2115,7 @@ fn next_available_profile_name(base_name: &str, store: &AuthStore) -> String { .expect("profile name sequence is infinite") } +#[cfg(test)] fn resolve_api_key_login_profile_name( explicit_profile: Option<&str>, suggested_org_name: Option<&str>, @@ -1723,6 +2147,7 @@ fn resolve_api_key_login_profile_name( )) } +#[cfg(test)] fn resolve_oauth_login_profile_name( explicit_profile: Option<&str>, suggested_org_name: Option<&str>, @@ -1776,6 +2201,7 @@ fn resolve_oauth_login_profile_name( )) } +#[cfg(test)] fn profile_matches_api_key_login_target( profile: &AuthProfile, selected_api_url: &str, @@ -1786,6 +2212,7 @@ fn profile_matches_api_key_login_target( && profile.org_name.as_deref() == suggested_org_name } +#[cfg(test)] fn profile_matches_oauth_login_target( profile: &AuthProfile, selected_api_url: &str, @@ -1801,6 +2228,8 @@ fn profile_matches_oauth_login_target( && profile.email == jwt_id.email } +#[cfg(test)] +#[allow(dead_code)] fn confirm_profile_overwrite(profile_name: &str) -> Result<()> { let store = load_auth_store()?; if !store.profiles.contains_key(profile_name) { @@ -1821,17 +2250,10 @@ fn confirm_profile_overwrite(profile_name: &str) -> Result<()> { Ok(()) } -fn format_login_success( - selected_org: &Option, - profile_name: &str, - api_url: &str, -) -> String { - match selected_org.as_ref() { - Some(org) => format!( - "Logged in as {} (profile: {profile_name}, api: {api_url})", - org.name - ), - None => format!("Logged in (cross-org, profile: {profile_name}, api: {api_url})"), +fn format_login_success(selected_org: Option<&LoginOrgInfo>, api_url: &str) -> String { + match selected_org { + Some(org) => format!("Logged in as {} (api: {api_url})", org.name), + None => format!("Logged in (cross-org, api: {api_url})"), } } @@ -1893,7 +2315,6 @@ async fn resolve_post_login_project( async fn persist_post_login_context( base: &BaseArgs, - profile_name: &str, credential: &str, api_url: &str, app_url: &str, @@ -1910,7 +2331,7 @@ async fn persist_post_login_context( let mut cfg = config::load_file(&path); switch::apply_switch_config( &mut cfg, - Some(profile_name), + None, selected_org.map(|org| org.name.as_str()), project.as_ref(), ); @@ -1923,20 +2344,6 @@ async fn persist_post_login_context( }) } -/// Build an actionable "profile not found" error that lists the available -/// profiles, so non-interactive callers can see what they can pick from. -fn profile_not_found_err(name: &str, store: &AuthStore) -> anyhow::Error { - let available: Vec = store.profiles.keys().cloned().collect(); - let suffix = if available.is_empty() { - String::new() - } else { - format!(": {}", available.join(", ")) - }; - anyhow::anyhow!( - "profile '{name}' not found; run `bt auth profiles` to see available profiles{suffix}" - ) -} - /// Emit a machine-readable JSON payload on stdout when `--json` is set, /// otherwise run the human-readable printer. Keeps stdout pure JSON. fn emit_result(json: bool, payload: serde_json::Value, human: impl FnOnce()) -> Result<()> { @@ -1948,36 +2355,21 @@ fn emit_result(json: bool, payload: serde_json::Value, human: impl FnOnce()) -> Ok(()) } -async fn run_profiles(base: &BaseArgs, args: AuthProfilesArgs) -> Result<()> { +async fn run_profiles(base: &BaseArgs, _args: AuthProfilesArgs) -> Result<()> { let store = load_auth_store()?; - - // Filter to a single profile when --profile is given; error out if it doesn't match. - let filtered_store = match &args.profile { - Some(name) => { - let profile = store - .profiles - .get(name) - .ok_or_else(|| profile_not_found_err(name, &store))?; - let mut s = AuthStore::default(); - s.profiles.insert(name.clone(), profile.clone()); - s - } - None => store, - }; - - if filtered_store.profiles.is_empty() { + if store.profiles.is_empty() { return emit_result(base.json, serde_json::json!([]), || { - println!("No saved profiles. Run `bt auth login` to create one.") + println!("No saved auth logins. Run `bt auth login` to create one.") }); } - let verifications = verify_all_profiles_from_store(&filtered_store).await; + let verifications = verify_all_profiles_from_store(&store).await; let all_network_errors = verifications .iter() .all(|v| v.status == "error" && !v.error.as_deref().unwrap_or("").contains("invalid")); if all_network_errors { - eprintln!("Could not reach Braintrust API. Showing saved profiles:"); - print_saved_profiles(&filtered_store, base.json)?; + eprintln!("Could not reach Braintrust API. Showing saved auth logins:"); + print_saved_profiles(&store, base.json)?; return Ok(()); } @@ -2004,29 +2396,40 @@ async fn run_profiles(base: &BaseArgs, args: AuthProfilesArgs) -> Result<()> { Ok(()) } +fn auth_profile_json(profile: &AuthProfile, status: &str) -> serde_json::Value { + serde_json::json!({ + "auth": auth_kind_label(profile.auth_kind), + "org": profile.org_name, + "org_id": profile.org_id.as_deref().filter(|org_id| !org_id.trim().is_empty()), + "user_name": profile.user_name, + "user_email": profile.email, + "api_key_hint": profile.api_key_hint, + "status": status, + }) +} + fn run_login_delete(profile_name: &str, force: bool, base_json: bool) -> Result<()> { let profile_name = profile_name.trim(); if profile_name.is_empty() { - bail!("profile name cannot be empty"); + bail!("auth login key cannot be empty"); } let mut store = load_auth_store()?; - if !store.profiles.contains_key(profile_name) { - return Err(profile_not_found_err(profile_name, &store)); - } + let profile = store.profiles.get(profile_name).cloned().ok_or_else(|| { + anyhow::anyhow!("auth login not found; run `bt auth profiles` to see available logins") + })?; + let label = auth_slot_label(profile_name, &profile); if !force { if let Some(term) = ui::prompt_term() { let confirmed = Confirm::new() - .with_prompt(format!("Delete profile '{profile_name}'?")) + .with_prompt(format!("Delete {label}?")) .default(false) .interact_on(&term)?; if !confirmed { - return emit_result( - base_json, - serde_json::json!({ "name": profile_name, "status": "cancelled" }), - || eprintln!("Cancelled"), - ); + return emit_result(base_json, auth_profile_json(&profile, "cancelled"), || { + eprintln!("Cancelled") + }); } } } @@ -2034,49 +2437,72 @@ fn run_login_delete(profile_name: &str, force: bool, base_json: bool) -> Result< store.profiles.remove(profile_name); save_auth_store(&store)?; if let Err(err) = delete_profile_secret(profile_name) { - eprintln!("warning: failed to delete keychain credential for '{profile_name}': {err}"); + eprintln!("warning: failed to delete keychain credential for '{label}': {err}"); } if let Err(err) = delete_profile_oauth_refresh_token(profile_name) { - eprintln!("warning: failed to delete oauth refresh token for '{profile_name}': {err}"); + eprintln!("warning: failed to delete oauth refresh token for '{label}': {err}"); } if let Err(err) = delete_profile_oauth_access_token(profile_name) { - eprintln!("warning: failed to delete oauth access token for '{profile_name}': {err}"); + eprintln!("warning: failed to delete oauth access token for '{label}': {err}"); } + delete_legacy_profile_secrets(&profile); - emit_result( - base_json, - serde_json::json!({ "name": profile_name, "status": "deleted" }), - || { - ui::print_command_status( - ui::CommandStatus::Success, - &format!("Deleted profile '{profile_name}'"), - ) - }, - ) + emit_result(base_json, auth_profile_json(&profile, "deleted"), || { + ui::print_command_status(ui::CommandStatus::Success, &format!("Deleted {label}")); + }) } fn run_login_logout(base: BaseArgs, args: AuthLogoutArgs) -> Result<()> { let store = load_auth_store()?; if store.profiles.is_empty() { return emit_result(base.json, serde_json::json!({ "status": "empty" }), || { - println!("No saved profiles.") + println!("No saved auth logins.") }); } - let profile_name = if let Some(p) = args.profile.or(base.profile) { - let p = p.trim().to_string(); - if !store.profiles.contains_key(&p) { - return Err(profile_not_found_err(&p, &store)); + let cfg_org = config_auth_context(&base); + let org = effective_org_name(&base, &cfg_org); + let mut candidates: Vec<&str> = store + .profiles + .iter() + .filter(|(_, profile)| org.is_none_or(|org| profile_matches_org_identifier(profile, org))) + .filter(|(_, profile)| { + args.api_key_hint.as_deref().is_none_or(|hint| { + profile.auth_kind == AuthKind::ApiKey + && profile.api_key_hint.as_deref() == Some(hint.trim()) + }) + }) + .map(|(name, _)| name.as_str()) + .collect(); + + if args.api_key_hint.is_none() && org.is_some() { + let oauth_candidates: Vec<&str> = candidates + .iter() + .copied() + .filter(|name| { + store + .profiles + .get(*name) + .is_some_and(|profile| profile.auth_kind == AuthKind::Oauth) + }) + .collect(); + if !oauth_candidates.is_empty() { + candidates = oauth_candidates; + } + } + + let profile_name = match candidates.len() { + 0 => bail!("no matching auth login found; run `bt auth profiles` to see available logins"), + 1 => candidates[0].to_string(), + _ if ui::can_prompt() => { + select_profile_from_store("Select auth login to log out", &candidates, org, &store)? + } + _ => { + let labels = candidate_identities(&candidates, &store).join(", "); + bail!( + "multiple auth logins match: {labels}. Pass --org or --api-key-hint to disambiguate." + ); } - p - } else if store.profiles.len() == 1 { - store.profiles.keys().next().unwrap().clone() - } else if ui::can_prompt() { - let names: Vec<&str> = store.profiles.keys().map(|k| k.as_str()).collect(); - let idx = crate::ui::fuzzy_select("Select profile to log out", &names, 0)?; - names[idx].to_string() - } else { - bail!("multiple profiles exist. Use --profile to specify which one."); }; run_login_delete(&profile_name, args.force, base.json) @@ -2098,18 +2524,20 @@ enum CredentialLoad { fn load_credential_for_profile(name: &str, profile: &AuthProfile) -> CredentialLoad { match profile.auth_kind { - AuthKind::ApiKey => match load_profile_secret(name) { - Ok(Some(k)) => CredentialLoad::Found(k), - Ok(None) => CredentialLoad::Missing, - Err(e) => CredentialLoad::Error(e.to_string()), - }, + AuthKind::ApiKey => { + match load_profile_secret_with_legacy(name, profile.legacy_secret_key.as_deref()) { + Ok(Some(k)) => CredentialLoad::Found(k), + Ok(None) => CredentialLoad::Missing, + Err(e) => CredentialLoad::Error(e.to_string()), + } + } AuthKind::Oauth => { if let Some(ts) = profile.oauth_access_expires_at { if !oauth_access_token_is_fresh(ts) { return CredentialLoad::Expired; } } - match load_profile_oauth_access_token(name) { + match load_profile_oauth_access_token_for_profile(name, profile) { Ok(Some(k)) => CredentialLoad::Found(k), Ok(None) => CredentialLoad::Missing, Err(e) => CredentialLoad::Error(e.to_string()), @@ -2120,11 +2548,14 @@ fn load_credential_for_profile(name: &str, profile: &AuthProfile) -> CredentialL #[derive(Debug, Clone, Serialize)] pub struct ProfileVerification { + #[serde(skip_serializing)] pub name: String, pub auth: String, #[serde(skip_serializing_if = "Option::is_none")] pub org: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub org_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub user_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub user_email: Option, @@ -2139,6 +2570,7 @@ fn build_verification( name: &str, auth_kind: &str, org: Option, + org_id: Option, jwt_id: Option, api_key_hint: Option, status: ProfileStatus, @@ -2153,6 +2585,7 @@ fn build_verification( name: name.to_string(), auth: auth_kind.to_string(), org, + org_id, user_name: jwt_id.as_ref().and_then(|j| j.name.clone()), user_email: jwt_id.as_ref().and_then(|j| j.email.clone()), api_key_hint, @@ -2163,15 +2596,16 @@ fn build_verification( async fn verify_profile_full(name: &str, profile: &AuthProfile) -> ProfileVerification { let app_url = profile.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); - let auth_kind = match profile.auth_kind { - AuthKind::ApiKey => "api_key", - AuthKind::Oauth => "oauth", - }; + let auth_kind = auth_kind_label(profile.auth_kind); let mk = |status, jwt_id: Option, hint: Option| { build_verification( name, auth_kind, profile.org_name.clone(), + profile + .org_id + .clone() + .filter(|org_id| !org_id.trim().is_empty()), jwt_id, hint, status, @@ -2180,9 +2614,15 @@ async fn verify_profile_full(name: &str, profile: &AuthProfile) -> ProfileVerifi let credential = match load_credential_for_profile(name, profile) { CredentialLoad::Found(k) => k, - CredentialLoad::Missing => return mk(ProfileStatus::Missing, None, None), - CredentialLoad::Expired => return mk(ProfileStatus::Expired, None, None), - CredentialLoad::Error(e) => return mk(ProfileStatus::Error(e), None, None), + CredentialLoad::Missing => { + return mk(ProfileStatus::Missing, None, profile.api_key_hint.clone()) + } + CredentialLoad::Expired => { + return mk(ProfileStatus::Expired, None, profile.api_key_hint.clone()) + } + CredentialLoad::Error(e) => { + return mk(ProfileStatus::Error(e), None, profile.api_key_hint.clone()) + } }; let (jwt_id, hint) = match profile.auth_kind { @@ -2203,7 +2643,7 @@ async fn verify_profile_full(name: &str, profile: &AuthProfile) -> ProfileVerifi } else { ProfileStatus::Error(msg) }; - mk(status, None, None) + mk(status, None, hint) } } } @@ -2227,10 +2667,10 @@ async fn verify_all_profiles_from_store(store: &AuthStore) -> Vec String { - let mut parts = vec![v.name.clone(), v.auth.clone()]; - if let Some(ref org) = v.org { - parts.push(format!("org: {org}")); - } + let mut parts = vec![ + v.org.clone().unwrap_or_else(|| "cross-org".to_string()), + v.auth.clone(), + ]; match v.status.as_str() { "ok" => { let id = match (&v.user_name, &v.user_email) { @@ -2243,7 +2683,10 @@ fn format_verification_line(v: &ProfileVerification) -> String { } } "expired" => parts.push("token expired".into()), - "missing" => parts.push("credential missing".into()), + "missing" => match v.api_key_hint.as_deref() { + Some(hint) => parts.push(format!("{hint} credential missing")), + None => parts.push("credential missing".into()), + }, _ => { if let Some(ref e) = v.error { parts.push(e.clone()); @@ -2258,11 +2701,11 @@ fn print_saved_profiles(store: &AuthStore, json: bool) -> Result<()> { let output: Vec = store .profiles .iter() - .map(|(name, p)| { + .map(|(_name, p)| { serde_json::json!({ - "name": name, - "auth": match p.auth_kind { AuthKind::ApiKey => "api_key", AuthKind::Oauth => "oauth" }, + "auth": auth_kind_label(p.auth_kind), "org": p.org_name, + "org_id": p.org_id, "user_name": p.user_name, "user_email": p.email, "api_key_hint": p.api_key_hint, @@ -2273,25 +2716,7 @@ fn print_saved_profiles(store: &AuthStore, json: bool) -> Result<()> { println!("{}", serde_json::to_string(&output)?); } else { for (name, profile) in &store.profiles { - let kind = match profile.auth_kind { - AuthKind::ApiKey => "api_key", - AuthKind::Oauth => "oauth", - }; - let org = profile - .org_name - .as_deref() - .map(|o| format!(" org={o}")) - .unwrap_or_default(); - let id = match (profile.user_name.as_deref(), profile.email.as_deref()) { - (Some(n), Some(e)) => format!(" {n} ({e})"), - (None, Some(e)) => format!(" {e}"), - _ => profile - .api_key_hint - .as_deref() - .map(|h| format!(" {h}")) - .unwrap_or_default(), - }; - println!(" {name} {kind}{org}{id}"); + println!(" {}", auth_slot_label(name, profile)); } } Ok(()) @@ -2357,6 +2782,11 @@ fn select_login_org( } if !interactive { + if allow_cross_org { + bail!( + "organization selection required in non-interactive mode; pass --org or rerun interactively to choose cross-org mode" + ); + } return Ok(None); } @@ -2417,7 +2847,7 @@ fn find_login_org<'a>( fn find_login_org_index(orgs: &[LoginOrgInfo], requested_org_name: &str) -> Option { orgs.iter() - .position(|org| org.name == requested_org_name) + .position(|org| org.id == requested_org_name || org.name == requested_org_name) .or_else(|| { let lowered = requested_org_name.to_ascii_lowercase(); orgs.iter() @@ -2515,23 +2945,8 @@ fn resolve_profile_api_url( ) } -fn default_oauth_client_id(profile_name: &str) -> String { - let sanitized = profile_name - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { - ch - } else { - '-' - } - }) - .collect::(); - let trimmed = sanitized.trim_matches('-'); - if trimmed.is_empty() { - "bt_cli_default".to_string() - } else { - format!("bt_cli_{trimmed}") - } +fn default_oauth_client_id() -> String { + "bt_cli".to_string() } fn generate_random_token(num_bytes: usize) -> Result { @@ -2917,20 +3332,19 @@ async fn exchange_oauth_authorization_code( fn map_refresh_oauth_error( api_url: &str, - profile_name: &str, + auth_login: &str, status: reqwest::StatusCode, body: &str, ) -> anyhow::Error { if let Ok(server_err) = serde_json::from_str::(body) { if matches!(server_err.error.as_deref(), Some("invalid_grant")) { - let mut message = - format!("oauth refresh token expired or was rejected for profile '{profile_name}'"); + let mut message = format!( + "oauth refresh token expired or was rejected for auth login '{auth_login}'" + ); if let Some(description) = server_err.error_description.as_deref() { message.push_str(&format!(" ({description})")); } - message.push_str(&format!( - "; re-run `bt auth login --oauth --profile {profile_name}`" - )); + message.push_str("; re-run `bt auth login --oauth --org `"); return recoverable_auth_error(RecoverableAuthErrorKind::OauthRefreshToken, message); } } @@ -2945,7 +3359,7 @@ async fn refresh_oauth_access_token( api_url: &str, refresh_token: &str, client_id: &str, - profile_name: &str, + auth_login: &str, ) -> Result { let http_client = build_http_client_from_builder( reqwest::Client::builder() @@ -2967,12 +3381,7 @@ async fn refresh_oauth_access_token( if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - return Err(map_refresh_oauth_error( - api_url, - profile_name, - status, - &body, - )); + return Err(map_refresh_oauth_error(api_url, auth_login, status, &body)); } response @@ -3093,6 +3502,47 @@ fn load_profile_secret(profile_name: &str) -> Result> { } } +fn load_profile_secret_with_legacy( + primary_key: &str, + legacy_key: Option<&str>, +) -> Result> { + if let Some(secret) = load_profile_secret(primary_key)? { + return Ok(Some(secret)); + } + + let Some(legacy_key) = legacy_key + .map(str::trim) + .filter(|key| !key.is_empty() && *key != primary_key) + else { + return Ok(None); + }; + + let Some(secret) = load_profile_secret(legacy_key)? else { + return Ok(None); + }; + let _ = relocate_plaintext_secret_if_present(primary_key, legacy_key, &secret); + Ok(Some(secret)) +} + +fn relocate_plaintext_secret_if_present( + primary_key: &str, + legacy_key: &str, + secret: &str, +) -> Result<()> { + let path = secret_store_path()?; + if !path.exists() { + return Ok(()); + } + let mut store = load_secret_store()?; + if store.secrets.remove(legacy_key).is_some() { + store + .secrets + .insert(primary_key.to_string(), secret.to_string()); + save_secret_store(&store)?; + } + Ok(()) +} + fn delete_profile_secret(profile_name: &str) -> Result<()> { let keychain_err = delete_profile_secret_keychain(profile_name).err(); let plaintext_err = delete_profile_secret_plaintext(profile_name).err(); @@ -3445,11 +3895,25 @@ fn save_profile_oauth_refresh_token(profile_name: &str, refresh_token: &str) -> save_profile_secret(&key, refresh_token) } +#[cfg(test)] +#[allow(dead_code)] fn load_profile_oauth_refresh_token(profile_name: &str) -> Result> { let key = oauth_refresh_secret_key(profile_name); load_profile_secret(&key) } +fn load_profile_oauth_refresh_token_for_profile( + profile_name: &str, + profile: &AuthProfile, +) -> Result> { + let primary = oauth_refresh_secret_key(profile_name); + let legacy = profile + .legacy_secret_key + .as_deref() + .map(oauth_refresh_secret_key); + load_profile_secret_with_legacy(&primary, legacy.as_deref()) +} + fn delete_profile_oauth_refresh_token(profile_name: &str) -> Result<()> { let key = oauth_refresh_secret_key(profile_name); delete_profile_secret(&key) @@ -3460,18 +3924,48 @@ fn save_profile_oauth_access_token(profile_name: &str, access_token: &str) -> Re save_profile_secret(&key, access_token) } +#[cfg(test)] +#[allow(dead_code)] fn load_profile_oauth_access_token(profile_name: &str) -> Result> { let key = oauth_access_secret_key(profile_name); load_profile_secret(&key) } +fn load_profile_oauth_access_token_for_profile( + profile_name: &str, + profile: &AuthProfile, +) -> Result> { + let primary = oauth_access_secret_key(profile_name); + let legacy = profile + .legacy_secret_key + .as_deref() + .map(oauth_access_secret_key); + load_profile_secret_with_legacy(&primary, legacy.as_deref()) +} + fn delete_profile_oauth_access_token(profile_name: &str) -> Result<()> { let key = oauth_access_secret_key(profile_name); delete_profile_secret(&key) } +fn delete_legacy_profile_secrets(profile: &AuthProfile) { + let Some(legacy_key) = profile.legacy_secret_key.as_deref() else { + return; + }; + match profile.auth_kind { + AuthKind::ApiKey => { + let _ = delete_profile_secret(legacy_key); + } + AuthKind::Oauth => { + let _ = delete_profile_oauth_refresh_token(legacy_key); + let _ = delete_profile_oauth_access_token(legacy_key); + } + } +} + fn load_valid_cached_oauth_access_token( profile_name: &str, + profile: &AuthProfile, expires_at: Option, ) -> Result> { let Some(expires_at) = expires_at else { @@ -3480,7 +3974,7 @@ fn load_valid_cached_oauth_access_token( if !oauth_access_token_is_fresh(expires_at) { return Ok(None); } - load_profile_oauth_access_token(profile_name) + load_profile_oauth_access_token_for_profile(profile_name, profile) } fn oauth_access_token_is_fresh(expires_at: u64) -> bool { @@ -3543,6 +4037,36 @@ pub fn obscure_api_key(key: &str) -> String { format!("{}****{}", &key[..prefix_end], &key[suffix_start..]) } +fn sha256_hex(value: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(value.as_bytes()); + let digest = hasher.finalize(); + let mut out = String::with_capacity(digest.len() * 2); + for byte in digest { + use std::fmt::Write as _; + let _ = write!(&mut out, "{byte:02x}"); + } + out +} + +fn api_key_hash(api_key: &str) -> String { + sha256_hex(api_key) +} + +fn oauth_slot_key(org_id: &str, email: &str) -> String { + format!("{org_id}::{email}") +} + +fn api_key_slot_key(api_key_hash: &str, org_id: &str) -> String { + format!("{api_key_hash}::{org_id}") +} + +#[cfg(test)] +#[allow(dead_code)] +fn split_slot_key(slot_key: &str) -> Option<(&str, &str)> { + slot_key.split_once("::") +} + fn current_unix_timestamp() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -3562,8 +4086,99 @@ fn load_auth_store_from_path(path: &Path) -> Result { let data = fs::read_to_string(path) .with_context(|| format!("failed to read auth config {}", path.display()))?; - serde_json::from_str(&data) - .with_context(|| format!("failed to parse auth config {}", path.display())) + let store: AuthStore = serde_json::from_str(&data) + .with_context(|| format!("failed to parse auth config {}", path.display()))?; + Ok(migrate_auth_store(store)) +} + +fn migrate_auth_store(store: AuthStore) -> AuthStore { + let mut migrated = AuthStore::default(); + for (old_key, mut profile) in store.profiles { + normalize_profile_cached_fields_from_key(&old_key, &mut profile); + let new_key = canonical_profile_key(&old_key, &profile); + if new_key != old_key && profile.legacy_secret_key.is_none() { + profile.legacy_secret_key = Some(old_key.clone()); + } + if migrated + .profiles + .get(&new_key) + .is_some_and(|existing| !should_replace_migrated_profile(existing, &profile)) + { + continue; + } + migrated.profiles.insert(new_key, profile); + } + migrated +} + +fn should_replace_migrated_profile(existing: &AuthProfile, candidate: &AuthProfile) -> bool { + match (existing.auth_kind, candidate.auth_kind) { + (AuthKind::Oauth, AuthKind::Oauth) => { + candidate.oauth_access_expires_at.unwrap_or_default() + > existing.oauth_access_expires_at.unwrap_or_default() + } + _ => false, + } +} + +fn looks_like_sha256_hex(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn normalize_profile_cached_fields_from_key(current_key: &str, profile: &mut AuthProfile) { + let Some((left, right)) = current_key.split_once("::") else { + return; + }; + + match profile.auth_kind { + AuthKind::Oauth => { + let key_matches_email = profile.email.as_deref() == Some(right); + if key_matches_email || (profile.email.is_none() && right.contains('@')) { + if profile.org_id.is_none() { + profile.org_id = Some(left.to_string()); + } + if profile.email.is_none() { + profile.email = Some(right.to_string()); + } + } + } + AuthKind::ApiKey => { + if looks_like_sha256_hex(left) && !right.trim().is_empty() { + if profile.api_key_hash.is_none() { + profile.api_key_hash = Some(left.to_string()); + } + if profile.org_id.is_none() { + profile.org_id = Some(right.to_string()); + } + } + } + } +} + +fn canonical_profile_key(current_key: &str, profile: &AuthProfile) -> String { + match profile.auth_kind { + AuthKind::Oauth => { + let Some(email) = profile.email.as_deref().filter(|value| !value.is_empty()) else { + return current_key.to_string(); + }; + let org_id = profile.org_id.as_deref().unwrap_or_default(); + if profile.org_id.is_some() || profile.org_name.is_none() { + oauth_slot_key(org_id, email) + } else { + current_key.to_string() + } + } + AuthKind::ApiKey => match ( + profile + .api_key_hash + .as_deref() + .filter(|value| !value.is_empty()), + profile.org_id.as_deref().filter(|value| !value.is_empty()), + ) { + (Some(hash), Some(org_id)) => api_key_slot_key(hash, org_id), + _ => current_key.to_string(), + }, + } } fn save_auth_store(store: &AuthStore) -> Result<()> { @@ -3669,12 +4284,11 @@ mod tests { no_color: false, no_input: false, profile: None, - profile_explicit: false, project: None, org_name: None, api_key: None, api_key_source: None, - prefer_profile: false, + prefer_api_key: false, api_url: None, app_url: None, ca_cert: None, @@ -3957,12 +4571,15 @@ mod tests { auth_kind: AuthKind::ApiKey, api_url: Some((*api_url).to_string()), app_url: Some((*app_url).to_string()), + org_id: None, org_name: Some((*org_name).to_string()), oauth_client_id: None, oauth_access_expires_at: None, user_name: None, email: None, + api_key_hash: None, api_key_hint: None, + legacy_secret_key: None, }, ); } @@ -4116,6 +4733,103 @@ mod tests { assert_eq!(DEFAULT_APP_URL, "https://www.braintrust.dev"); } + #[tokio::test] + async fn active_auth_info_without_org_uses_cross_org_oauth_only() { + let _env = TestEnv::new(None, None).await; + let mut store = AuthStore::default(); + store.profiles.insert( + api_key_slot_key(&api_key_hash("test-api-key"), "org_fake"), + AuthProfile { + auth_kind: AuthKind::ApiKey, + org_id: Some("org_fake".to_string()), + org_name: Some("test-org".to_string()), + api_key_hint: Some("sk-****abcde".to_string()), + ..Default::default() + }, + ); + store.profiles.insert( + oauth_slot_key("", "user@example.test"), + AuthProfile { + auth_kind: AuthKind::Oauth, + org_id: Some(String::new()), + org_name: None, + user_name: Some("Test User".to_string()), + email: Some("user@example.test".to_string()), + ..Default::default() + }, + ); + save_auth_store(&store).expect("save auth store"); + + let info = active_auth_info(&make_base(), None).expect("active auth info"); + + assert_eq!(info.auth_method, "oauth"); + assert_eq!(info.email.as_deref(), Some("user@example.test")); + assert_eq!(info.org_name, None); + } + + #[tokio::test] + async fn active_auth_info_prefer_api_key_selects_stored_key_for_org() { + let _env = TestEnv::new(None, None).await; + let mut store = AuthStore::default(); + store.profiles.insert( + oauth_slot_key("org_fake", "user@example.test"), + AuthProfile { + auth_kind: AuthKind::Oauth, + org_id: Some("org_fake".to_string()), + org_name: Some("test-org".to_string()), + user_name: Some("Test User".to_string()), + email: Some("user@example.test".to_string()), + ..Default::default() + }, + ); + store.profiles.insert( + api_key_slot_key(&api_key_hash("test-api-key"), "org_fake"), + AuthProfile { + auth_kind: AuthKind::ApiKey, + org_id: Some("org_fake".to_string()), + org_name: Some("test-org".to_string()), + api_key_hint: Some("sk-****abcde".to_string()), + ..Default::default() + }, + ); + save_auth_store(&store).expect("save auth store"); + let mut base = make_base(); + base.prefer_api_key = true; + + let info = active_auth_info(&base, Some("test-org")).expect("active auth info"); + + assert_eq!(info.auth_method, "api_key"); + assert_eq!(info.api_key_hint.as_deref(), Some("sk-****abcde")); + } + + #[tokio::test] + async fn api_key_profile_rekeys_after_legacy_secret_load() { + let _env = TestEnv::new(None, None).await; + let mut store = AuthStore::default(); + store.profiles.insert( + "work".to_string(), + AuthProfile { + auth_kind: AuthKind::ApiKey, + org_id: Some("org_fake".to_string()), + org_name: Some("test-org".to_string()), + api_key_hash: None, + ..Default::default() + }, + ); + + maybe_rekey_api_key_profile_after_secret_load(&mut store, "work", "test-api-key") + .expect("rekey api key profile"); + + let key = api_key_slot_key(&api_key_hash("test-api-key"), "org_fake"); + let profile = store.profiles.get(&key).expect("rekeyed profile"); + assert!(!store.profiles.contains_key("work")); + assert_eq!( + profile.api_key_hash.as_deref(), + Some(api_key_hash("test-api-key").as_str()) + ); + assert_eq!(profile.legacy_secret_key.as_deref(), Some("work")); + } + #[test] fn save_and_load_auth_store_round_trip() { let unique = SystemTime::now() @@ -4148,6 +4862,80 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + #[test] + fn migrate_auth_store_rekeys_oauth_slots_and_preserves_legacy_secret_key() { + let mut store = AuthStore::default(); + store.profiles.insert( + "work".to_string(), + AuthProfile { + auth_kind: AuthKind::Oauth, + org_id: Some("org_fake".to_string()), + org_name: Some("test-org".to_string()), + email: Some("user@example.test".to_string()), + oauth_client_id: Some("bt_cli_work".to_string()), + ..Default::default() + }, + ); + + let migrated = migrate_auth_store(store); + let key = oauth_slot_key("org_fake", "user@example.test"); + let profile = migrated.profiles.get(&key).expect("migrated profile"); + + assert_eq!(profile.legacy_secret_key.as_deref(), Some("work")); + assert_eq!(profile.oauth_client_id.as_deref(), Some("bt_cli_work")); + } + + #[test] + fn migrate_auth_store_rekeys_api_key_slots_by_hash_and_org() { + let hash = api_key_hash("test-api-key"); + let mut store = AuthStore::default(); + store.profiles.insert( + "work".to_string(), + AuthProfile { + auth_kind: AuthKind::ApiKey, + org_id: Some("org_fake".to_string()), + org_name: Some("test-org".to_string()), + api_key_hash: Some(hash.clone()), + api_key_hint: Some("test-****i-key".to_string()), + ..Default::default() + }, + ); + + let migrated = migrate_auth_store(store); + let key = api_key_slot_key(&hash, "org_fake"); + let profile = migrated.profiles.get(&key).expect("migrated profile"); + + assert_eq!(profile.legacy_secret_key.as_deref(), Some("work")); + assert_eq!(profile.api_key_hint.as_deref(), Some("test-****i-key")); + } + + #[test] + fn migrate_auth_store_dedupes_oauth_slots_by_latest_expiry() { + let mut store = AuthStore::default(); + for (name, expires_at, client_id) in [("old", 10, "bt_cli_old"), ("new", 20, "bt_cli_new")] + { + store.profiles.insert( + name.to_string(), + AuthProfile { + auth_kind: AuthKind::Oauth, + org_id: Some("org_fake".to_string()), + org_name: Some("test-org".to_string()), + email: Some("user@example.test".to_string()), + oauth_client_id: Some(client_id.to_string()), + oauth_access_expires_at: Some(expires_at), + ..Default::default() + }, + ); + } + + let migrated = migrate_auth_store(store); + let key = oauth_slot_key("org_fake", "user@example.test"); + assert_eq!(migrated.profiles.len(), 1); + let profile = migrated.profiles.get(&key).expect("migrated profile"); + assert_eq!(profile.oauth_client_id.as_deref(), Some("bt_cli_new")); + assert_eq!(profile.legacy_secret_key.as_deref(), Some("new")); + } + #[test] fn resolve_auth_uses_profile_when_no_api_key_override() { let mut base = make_base(); @@ -4181,25 +4969,23 @@ mod tests { } #[test] - fn config_auth_context_returns_profile_and_org_independently() { + fn config_auth_context_returns_config_org() { let base = make_base(); let cfg = auth_config(Some("default-profile"), Some("local-org")); - let (profile, org) = config_auth_context_from_config(&base, &cfg); + let org = config_auth_context_from_config(&base, &cfg); - assert_eq!(profile.as_deref(), Some("default-profile")); assert_eq!(org.as_deref(), Some("local-org")); } #[test] - fn config_auth_context_preserves_explicit_profile_and_config_org() { + fn config_auth_context_ignores_legacy_profile_and_preserves_config_org() { let mut base = make_base(); base.profile = Some("explicit-profile".to_string()); let cfg = auth_config(Some("config-profile"), Some("local-org")); - let (profile, org) = config_auth_context_from_config(&base, &cfg); + let org = config_auth_context_from_config(&base, &cfg); - assert_eq!(profile, None); assert_eq!(org.as_deref(), Some("local-org")); } @@ -4239,10 +5025,10 @@ mod tests { } #[test] - fn resolve_auth_prefer_profile_ignores_api_key_override() { + fn resolve_auth_prefer_api_key_uses_api_key_override() { let mut base = make_base(); base.api_key = Some("explicit-key".to_string()); - base.prefer_profile = true; + base.prefer_api_key = true; base.profile = Some("work".to_string()); let mut store = AuthStore::default(); @@ -4266,16 +5052,16 @@ mod tests { &None, ) .expect("resolve"); - assert_eq!(resolved.api_key.as_deref(), Some("profile-key")); - assert_eq!(resolved.org_name.as_deref(), Some("Example Org")); + assert_eq!(resolved.api_key.as_deref(), Some("explicit-key")); + assert_eq!(resolved.org_name, None); } #[test] - fn resolve_auth_prefers_cli_api_key_even_with_prefer_profile() { + fn resolve_auth_prefers_cli_api_key_even_with_prefer_api_key() { let mut base = make_base(); base.api_key = Some("explicit-key".to_string()); base.api_key_source = Some(crate::args::ArgValueSource::CommandLine); - base.prefer_profile = true; + base.prefer_api_key = true; base.profile = Some("work".to_string()); let mut store = AuthStore::default(); @@ -4304,11 +5090,10 @@ mod tests { } #[test] - fn resolve_auth_explicit_profile_ignores_env_api_key_override() { + fn resolve_auth_legacy_explicit_profile_does_not_override_api_key() { let mut base = make_base(); base.api_key = Some("explicit-key".to_string()); base.profile = Some("work".to_string()); - base.profile_explicit = true; let mut store = AuthStore::default(); store.profiles.insert( @@ -4331,8 +5116,8 @@ mod tests { &None, ) .expect("resolve"); - assert_eq!(resolved.api_key.as_deref(), Some("profile-key")); - assert_eq!(resolved.org_name.as_deref(), Some("Example Org")); + assert_eq!(resolved.api_key.as_deref(), Some("explicit-key")); + assert_eq!(resolved.org_name, None); } #[test] @@ -4375,7 +5160,7 @@ mod tests { let (profile_name, source) = resolve_selected_profile_name_for_debug(&base, &store).expect("resolve"); assert_eq!(profile_name, "work"); - assert_eq!(source, "--profile/BRAINTRUST_PROFILE"); + assert_eq!(source, "legacy profile"); } #[test] @@ -4487,10 +5272,10 @@ mod tests { let err = maybe_select_profile_for_auth(&base, &store, &None, false) .expect_err("selection should be required"); - assert!(err.to_string().contains("multiple auth profiles available")); + assert!(err.to_string().contains("multiple auth logins available")); assert!(err.to_string().contains("alpha")); assert!(err.to_string().contains("beta")); - assert!(err.to_string().contains("--profile ")); + assert!(err.to_string().contains("--org ")); } #[test] @@ -4517,7 +5302,9 @@ mod tests { let err = maybe_select_profile_for_auth(&base, &store, &None, false) .expect_err("org selection should be required"); - assert!(err.to_string().contains("multiple profiles for org 'acme'")); + assert!(err + .to_string() + .contains("multiple auth logins for org 'acme'")); assert!(err.to_string().contains("work-1")); assert!(err.to_string().contains("work-2")); } @@ -4946,7 +5733,6 @@ mod tests { let update = persist_post_login_context( &make_base(), - "work", "test-api-key", "https://api.example.test", "https://www.example.test", @@ -4957,7 +5743,7 @@ mod tests { let cfg = crate::config::load_global().expect("load global config"); assert_eq!(update.display, "acme"); - assert_eq!(cfg.profile.as_deref(), Some("work")); + assert_eq!(cfg.profile, None); assert_eq!(cfg.org.as_deref(), Some("acme")); assert_eq!(cfg.project, None); assert_eq!(cfg.project_id, None); @@ -5103,6 +5889,7 @@ mod tests { name: "work".into(), auth: "oauth".into(), org: Some("acme".into()), + org_id: None, user_name: Some("Alice".into()), user_email: Some("alice@example.com".into()), api_key_hint: None, @@ -5111,7 +5898,7 @@ mod tests { }; assert_eq!( format_verification_line(&v), - "work — oauth — org: acme — Alice (alice@example.com)" + "acme — oauth — Alice (alice@example.com)" ); } @@ -5121,6 +5908,7 @@ mod tests { name: "work".into(), auth: "api_key".into(), org: Some("acme".into()), + org_id: None, user_name: None, user_email: None, api_key_hint: Some("sk-****zhJwO".into()), @@ -5129,7 +5917,7 @@ mod tests { }; assert_eq!( format_verification_line(&v), - "work — api_key — org: acme — sk-****zhJwO" + "acme — api_key — sk-****zhJwO" ); } @@ -5139,13 +5927,17 @@ mod tests { name: "old".into(), auth: "oauth".into(), org: None, + org_id: None, user_name: None, user_email: None, api_key_hint: None, status: "expired".into(), error: None, }; - assert_eq!(format_verification_line(&v), "old — oauth — token expired"); + assert_eq!( + format_verification_line(&v), + "cross-org — oauth — token expired" + ); } #[test] @@ -5154,6 +5946,7 @@ mod tests { name: "bad".into(), auth: "api_key".into(), org: Some("corp".into()), + org_id: None, user_name: None, user_email: None, api_key_hint: None, @@ -5162,7 +5955,7 @@ mod tests { }; assert_eq!( format_verification_line(&v), - "bad — api_key — org: corp — invalid API key" + "corp — api_key — invalid API key" ); } diff --git a/src/config/mod.rs b/src/config/mod.rs index 779499d5..c6b51604 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -18,6 +18,7 @@ mod set; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(default)] pub struct Config { + #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option, pub org: Option, pub project: Option, @@ -26,12 +27,11 @@ pub struct Config { pub extra: serde_json::Map, } -pub const KNOWN_KEYS: &[&str] = &["profile", "org", "project", "project_id"]; +pub const KNOWN_KEYS: &[&str] = &["org", "project", "project_id"]; impl Config { pub fn get_field(&self, key: &str) -> Option<&str> { match key { - "profile" => self.profile.as_deref(), "org" => self.org.as_deref(), "project" => self.project.as_deref(), "project_id" => self.project_id.as_deref(), @@ -41,7 +41,6 @@ impl Config { pub fn set_field(&mut self, key: &str, value: String) -> bool { match key { - "profile" => self.profile = Some(value), "org" => self.org = Some(value), "project" => { self.project = Some(value); @@ -55,7 +54,6 @@ impl Config { pub fn unset_field(&mut self, key: &str) -> bool { match key { - "profile" => self.profile = None, "org" => self.org = None, "project" => { self.project = None; @@ -84,7 +82,7 @@ impl Config { self.project_id.clone() }; Config { - profile: other.profile.clone().or_else(|| self.profile.clone()), + profile: None, org: other.org.clone().or_else(|| self.org.clone()), project, project_id, @@ -119,7 +117,7 @@ pub fn load_file(path: &Path) -> Config { } }; - let config: Config = match serde_json::from_str(&file_contents) { + let mut config: Config = match serde_json::from_str(&file_contents) { Ok(c) => c, Err(e) => { print_command_status( @@ -130,6 +128,10 @@ pub fn load_file(path: &Path) -> Config { } }; + // Legacy config files may contain `profile`; profiles are no longer a + // user-facing selector, so ignore it rather than warning or preserving it. + config.profile = None; + for key in config.extra.keys() { print_command_status( CommandStatus::Error, @@ -180,21 +182,13 @@ pub(crate) fn project_from_config_for_context( .flatten() } -fn config_matches_context(base: &BaseArgs, cfg: &Config, resolved_org: Option<&str>) -> bool { - let selected_profile = trimmed_option(base.profile.as_deref()); - let cfg_profile = trimmed_option(cfg.profile.as_deref()); +fn config_matches_context(_base: &BaseArgs, cfg: &Config, resolved_org: Option<&str>) -> bool { let cfg_org = trimmed_option(cfg.org.as_deref()); let resolved_org = trimmed_option(resolved_org); - match selected_profile { - Some(profile) => { - cfg_profile == Some(profile) - || (cfg_profile.is_none() && cfg_org.is_some() && cfg_org == resolved_org) - } - None => cfg_org - .zip(resolved_org) - .is_none_or(|(cfg, resolved)| cfg == resolved), - } + cfg_org + .zip(resolved_org) + .is_none_or(|(cfg, resolved)| cfg == resolved) } pub(crate) fn trimmed_option(value: Option<&str>) -> Option<&str> { @@ -318,14 +312,14 @@ enum ConfigCommands { }, /// Get a config value Get { - /// Config key (profile, org, project, project_id) + /// Config key (org, project, project_id) key: String, #[command(flatten)] scope: ScopeArgs, }, /// Set a config value Set { - /// Config key (profile, org, project, project_id) + /// Config key (org, project, project_id) key: String, /// Value to set value: String, @@ -334,7 +328,7 @@ enum ConfigCommands { }, /// Remove a config value Unset { - /// Config key (profile, org, project, project_id) + /// Config key (org, project, project_id) key: String, #[command(flatten)] scope: ScopeArgs, @@ -443,12 +437,11 @@ mod tests { no_color: false, no_input: false, profile: profile.map(str::to_string), - profile_explicit: profile.is_some(), org_name: None, project: None, api_key: None, api_key_source: None, - prefer_profile: false, + prefer_api_key: false, api_url: None, app_url: None, ca_cert: None, @@ -466,13 +459,16 @@ mod tests { } #[test] - fn project_config_matches_explicit_profile_or_legacy_org() { + fn project_config_matches_org_and_ignores_legacy_profile() { let base = base_with_profile(Some("work")); let cases = [ (config(None, Some("acme"), Some("demo")), Some("demo")), (config(None, Some("other"), Some("demo")), None), - (config(None, None, Some("demo")), None), - (config(Some("other"), Some("acme"), Some("demo")), None), + (config(None, None, Some("demo")), Some("demo")), + ( + config(Some("other"), Some("acme"), Some("demo")), + Some("demo"), + ), ( config(Some("work"), Some("acme"), Some("demo")), Some("demo"), diff --git a/src/datasets/pipeline.rs b/src/datasets/pipeline.rs index f1c43e49..ae8bbd0f 100644 --- a/src/datasets/pipeline.rs +++ b/src/datasets/pipeline.rs @@ -2074,12 +2074,11 @@ mod tests { no_color: false, no_input: false, profile: None, - profile_explicit: false, org_name: None, project: None, api_key: None, api_key_source: None, - prefer_profile: false, + prefer_api_key: false, api_url: None, app_url: None, ca_cert: None, diff --git a/src/datasets/snapshots.rs b/src/datasets/snapshots.rs index 00f4a804..047f2d3e 100644 --- a/src/datasets/snapshots.rs +++ b/src/datasets/snapshots.rs @@ -8,11 +8,12 @@ use serde_json::json; use crate::{ args::BaseArgs, + auth, ui::{ apply_column_padding, header, is_interactive, print_command_status, print_with_pager, styled_table, truncate, with_spinner, with_spinner_visible, CommandStatus, }, - utils::{pluralize, profile_author_slug, resolve_profile_info, sanitize_name_segment}, + utils::{pluralize, profile_author_slug, sanitize_name_segment}, }; use super::{ @@ -1092,7 +1093,7 @@ fn resolve_default_snapshot_author(base: &BaseArgs, ctx: &ResolvedContext) -> Op return None; } - let profile = resolve_profile_info(base.profile.as_deref(), Some(ctx.client.org_name()))?; + let profile = auth::active_auth_info(base, Some(ctx.client.org_name()))?; profile_author_slug(&profile) } @@ -1102,11 +1103,13 @@ fn default_snapshot_name(author: &str, now: DateTime) -> String { } fn api_key_override_active(base: &BaseArgs) -> bool { - !base.prefer_profile - && base - .api_key - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) + matches!( + base.api_key_source, + Some(crate::args::ArgValueSource::CommandLine) + ) && base + .api_key + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) } #[cfg(test)] diff --git a/src/eval.rs b/src/eval.rs index ebc4f4dc..d279951a 100644 --- a/src/eval.rs +++ b/src/eval.rs @@ -746,7 +746,7 @@ async fn run_eval_attempt( options.jsonl, options.list, options.verbose, - base.profile.clone(), + base.org_name.clone(), ); let output = drive_eval_runner(spawned.process, console_policy, |event| ui.handle(event)).await?; @@ -2805,7 +2805,7 @@ struct EvalUi { deferred_errors: Vec, suppressed_stderr_lines: usize, finished: bool, - profile: Option, + org: Option, } struct EvalBarState { @@ -2815,7 +2815,7 @@ struct EvalBarState { } impl EvalUi { - fn new(jsonl: bool, list: bool, verbose: bool, profile: Option) -> Self { + fn new(jsonl: bool, list: bool, verbose: bool, org: Option) -> Self { let draw_target = if std::io::stderr().is_terminal() && animations_enabled() && !is_quiet() { ProgressDrawTarget::stderr_with_hz(10) @@ -2841,7 +2841,7 @@ impl EvalUi { deferred_errors: Vec::new(), suppressed_stderr_lines: 0, finished: false, - profile, + org, } } @@ -2869,7 +2869,7 @@ impl EvalUi { } } EvalEvent::Summary(summary) => { - let summary = enrich_experiment_summary(summary, self.profile.as_deref()); + let summary = enrich_experiment_summary(summary, self.org.as_deref()); if self.jsonl { if let Ok(line) = serde_json::to_string(&summary) { println!("{line}"); @@ -3350,10 +3350,10 @@ const COMPARE_MORE_HINT: &str = "append more experiment names at the end; max 7 fn enrich_experiment_summary( mut summary: ExperimentSummary, - profile: Option<&str>, + org: Option<&str>, ) -> ExperimentSummary { if summary.compare_command.is_none() { - summary.compare_command = build_experiment_compare_command(&summary, profile); + summary.compare_command = build_experiment_compare_command(&summary, org); } if summary.compare_command.is_some() && summary.compare_more.is_none() { summary.compare_more = Some(COMPARE_MORE_HINT.to_string()); @@ -3375,7 +3375,7 @@ fn format_experiment_compare_command(summary: &ExperimentSummary) -> Option, + org: Option<&str>, ) -> Option { let baseline = summary.comparison_experiment_name.as_deref()?.trim(); if baseline.is_empty() { @@ -3387,14 +3387,14 @@ fn build_experiment_compare_command( return None; } - let profile_args = profile + let org_args = org .map(str::trim) - .filter(|profile| !profile.is_empty()) - .map(|profile| format!(" --profile {}", shell_quote_arg(profile))) + .filter(|org| !org.is_empty()) + .map(|org| format!(" --org {}", shell_quote_arg(org))) .unwrap_or_default(); Some(format!( - "bt experiments compare{profile_args} -p {} {} {}", + "bt experiments compare{org_args} -p {} {} {}", shell_quote_arg(project), shell_quote_arg(baseline), shell_quote_arg(experiment), @@ -4544,7 +4544,7 @@ mod tests { } #[test] - fn enrich_experiment_summary_preserves_profile_in_compare_command() { + fn enrich_experiment_summary_preserves_org_in_compare_command() { let summary = ExperimentSummary { project_name: "test-project".to_string(), experiment_name: "challenger-a".to_string(), @@ -4564,11 +4564,11 @@ mod tests { compare_more: None, }; - let summary = enrich_experiment_summary(summary, Some("test-profile")); + let summary = enrich_experiment_summary(summary, Some("test-org")); assert_eq!( summary.compare_command.as_deref(), - Some("bt experiments compare --profile test-profile -p test-project baseline challenger-a") + Some("bt experiments compare --org test-org -p test-project baseline challenger-a") ); } diff --git a/src/functions/push.rs b/src/functions/push.rs index 4d986b2f..d3771ab2 100644 --- a/src/functions/push.rs +++ b/src/functions/push.rs @@ -4064,12 +4064,11 @@ mod tests { no_color: false, no_input: false, profile: None, - profile_explicit: false, org_name: None, project: None, api_key: None, api_key_source: None, - prefer_profile: false, + prefer_api_key: false, api_url: None, app_url: None, ca_cert: None, diff --git a/src/init.rs b/src/init.rs index e9976c1b..f9e1e03c 100644 --- a/src/init.rs +++ b/src/init.rs @@ -17,6 +17,26 @@ Examples: ")] pub struct InitArgs {} +fn select_saved_auth_org_for_init() -> Result> { + let mut orgs = auth::list_profiles()? + .into_iter() + .filter_map(|profile| profile.org_name) + .filter(|org| !org.trim().is_empty()) + .collect::>(); + orgs.sort(); + orgs.dedup(); + + match orgs.len() { + 0 => Ok(None), + 1 => Ok(orgs.into_iter().next()), + _ => { + let labels = orgs.iter().map(String::as_str).collect::>(); + let idx = crate::ui::fuzzy_select("Select organization", &labels, 0)?; + Ok(Some(orgs[idx].clone())) + } + } +} + pub async fn run(base: BaseArgs, _args: InitArgs) -> Result<()> { let config_path = config::local_save_path()?; if config_path.exists() { @@ -44,10 +64,8 @@ pub async fn run(base: BaseArgs, _args: InitArgs) -> Result<()> { bail!("--org and --project required in non-interactive mode"); } else { let mut login_base = base.clone(); - if login_base.org_name.is_none() && login_base.profile.is_none() { - if let Some(profile) = auth::select_profile_interactive(None)? { - login_base.profile = Some(profile); - } + if login_base.org_name.is_none() { + login_base.org_name = select_saved_auth_org_for_init()?; } let ctx = login(&login_base).await?; let client = ApiClient::new(&ctx)?; diff --git a/src/main.rs b/src/main.rs index 5f12e60f..9a7a5f12 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,7 +35,7 @@ mod ui; mod util_cmd; mod utils; -use crate::args::{has_explicit_profile_arg, ArgValueSource, BaseArgs, CLIArgs}; +use crate::args::{ArgValueSource, BaseArgs, CLIArgs}; const DEFAULT_CANARY_VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), "-canary.dev"); pub(crate) const CLI_VERSION: &str = match option_env!("BT_VERSION_STRING") { @@ -84,9 +84,9 @@ Additional update Update bt in-place Flags - --profile Use a saved login profile [env: BRAINTRUST_PROFILE] -o, --org Override active org [env: BRAINTRUST_ORG_NAME] - -p, --project Override active project [env: BRAINTRUST_DEFAULT_PROJECT] + -p, --project Override active project [env: BRAINTRUST_DEFAULT_PROJECT] + --prefer-api-key Prefer API key credentials for the selected org [env: BRAINTRUST_PREFER_API_KEY=] --json Output as JSON -v, --verbose Increase output verbosity [env: BRAINTRUST_VERBOSE=] -q, --quiet Reduce interactive UI output [env: BRAINTRUST_QUIET=] @@ -296,7 +296,6 @@ fn try_main() -> Result<()> { let matches = Cli::command().get_matches_from(&argv); let mut cli = Cli::from_arg_matches(&matches).expect("clap matches should parse"); apply_base_arg_sources(&matches, cli.command.base_mut()); - cli.command.base_mut().profile_explicit = has_explicit_profile_arg(&argv); apply_base_output_defaults(&mut cli.command); configure_output(cli.command.base()); apply_runtime_env_overrides(cli.command.base()); @@ -500,7 +499,7 @@ fn looks_like_user_error(err: &anyhow::Error) -> bool { fn print_error(err: &anyhow::Error, code: ExitCode, missing_credential: bool) { eprintln!("error: {err}"); if code == ExitCode::Auth && !missing_credential { - eprintln!("Your credentials may be expired or invalid. For OAuth profiles, try `bt auth refresh --profile `; if refresh fails, re-run `bt auth login --oauth --profile `. Run `bt auth profiles` and `bt status` to inspect profile status."); + eprintln!("Your credentials may be expired or invalid. For OAuth login, try `bt auth refresh --org `; if refresh fails, re-run `bt auth login --oauth --org `. Run `bt auth profiles` and `bt status` to inspect auth status."); } if code == ExitCode::Error { eprintln!("If this seems like a bug, file an issue at https://github.com/braintrustdata/bt/issues/new and include `bt --version`, `bt status --json`, and the command you ran."); diff --git a/src/setup/mod.rs b/src/setup/mod.rs index 45ebfbcd..9d23a4f3 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -1467,21 +1467,18 @@ async fn run_setup_browser_auth( org_id: completed.org_id.clone(), description: None, }; - let stored_profiles = auth::list_profiles()?; - let profile_name = setup_browser_profile_name(profile_name, &org.name, &stored_profiles); - + let _ = profile_name; auth::commit_api_key_profile( - &profile_name, &completed.api_key, login.api_url.clone(), Some(login.app_url.clone()), - Some(org.name.clone()), + org.id.clone(), + org.name.clone(), ) - .context("failed to save Braintrust auth profile after browser setup")?; + .context("failed to save Braintrust auth login after browser setup")?; base.api_key = Some(completed.api_key); base.api_key_source = None; - base.profile = Some(profile_name); base.org_name = Some(org.name); base.project = Some(selected_project.name.clone()); @@ -1492,6 +1489,7 @@ async fn run_setup_browser_auth( }) } +#[cfg(test)] fn setup_browser_profile_name( selected_profile_name: Option<&str>, org_name: &str, @@ -1534,7 +1532,7 @@ fn resolve_profile_name_for_setup( return Ok(Some(profile_name.to_string())); } bail!( - "profile '{profile_name}' not found; run `bt auth profiles` to see available profiles" + "auth login '{profile_name}' not found; run `bt auth profiles` to see available logins" ); } @@ -1550,20 +1548,25 @@ fn resolve_profile_name_for_setup( let mut matches = profiles .iter() .filter(|profile| profile.org_name.as_deref() == Some(org_name)) - .map(|profile| profile.name.clone()) .collect::>(); - matches.sort(); + matches.sort_by(|left, right| left.name.cmp(&right.name)); return match matches.len() { 0 => Ok(None), - 1 => Ok(Some(matches.remove(0))), + 1 => Ok(Some(matches[0].name.clone())), _ if prompt_for_choice => auth::select_profile_interactive(Some(org_name))? .map(Some) - .ok_or_else(|| anyhow!("no profile selected")), - _ => bail!( - "multiple profiles for org '{org_name}': {}. Use --profile to disambiguate.", - matches.join(", ") - ), + .ok_or_else(|| anyhow!("no auth login selected")), + _ => { + let labels = matches + .iter() + .map(|profile| auth::profile_info_label(profile)) + .collect::>() + .join(", "); + bail!( + "multiple auth logins for org '{org_name}': {labels}. Rerun interactively or remove one with `bt auth logout`." + ) + } }; } @@ -1574,7 +1577,7 @@ fn resolve_profile_name_for_setup( if prompt_for_choice && !profiles.is_empty() { auth::select_profile_interactive(None)? .map(Some) - .ok_or_else(|| anyhow!("no profile selected")) + .ok_or_else(|| anyhow!("no auth login selected")) } else { Ok(None) } @@ -1612,11 +1615,13 @@ fn find_available_org<'a>( orgs: &'a [auth::AvailableOrg], org_name: &str, ) -> Option<&'a auth::AvailableOrg> { - orgs.iter().find(|org| org.name == org_name).or_else(|| { - let lowered = org_name.to_ascii_lowercase(); - orgs.iter() - .find(|org| org.name.to_ascii_lowercase() == lowered) - }) + orgs.iter() + .find(|org| org.id == org_name || org.name == org_name) + .or_else(|| { + let lowered = org_name.to_ascii_lowercase(); + orgs.iter() + .find(|org| org.name.to_ascii_lowercase() == lowered) + }) } fn build_api_key_login_context( @@ -1724,24 +1729,6 @@ fn select_api_key_org_for_setup( bail!("organization choice required in non-interactive mode; pass --org ") } -fn matching_profile_org_names( - profiles: &[auth::StoredProfileInfo], - only_oauth: Option, -) -> Vec { - let mut org_names = profiles - .iter() - .filter(|profile| { - only_oauth - .map(|expected| profile.is_oauth == expected) - .unwrap_or(true) - }) - .filter_map(|profile| profile.org_name.clone()) - .collect::>(); - org_names.sort(); - org_names.dedup(); - org_names -} - async fn ensure_profile_or_setup_browser_auth( base: &mut BaseArgs, prompt_for_profile_choice: bool, @@ -1760,10 +1747,17 @@ async fn ensure_profile_or_setup_browser_auth( if let Some(profile_name) = selected_profile { auth_base.profile = Some(profile_name.clone()); + if auth_base.org_name.is_none() { + auth_base.org_name = profiles + .iter() + .find(|profile| profile.name == profile_name) + .and_then(|profile| profile.org_name.clone()); + } match auth::login(&auth_base).await { Ok(ctx) => { base.profile = auth_base.profile.clone(); + base.org_name = auth_base.org_name.clone(); let is_oauth = auth::resolve_auth(&auth_base).await?.is_oauth; return Ok(SetupAuthLogin { login: ctx, @@ -1774,7 +1768,7 @@ async fn ensure_profile_or_setup_browser_auth( Err(err) if auth::is_missing_credential_error(&err) => { if base.verbose { eprintln!( - " Profile '{}' credentials inaccessible ({}). Re-authenticating in the browser...", + " Auth login '{}' credentials inaccessible ({}). Re-authenticating in the browser...", profile_name, err ); } @@ -1799,10 +1793,10 @@ async fn ensure_profile_or_setup_browser_auth( if !can_prompt { if profiles.is_empty() { bail!( - "setup needs interactive browser authentication; rerun without --no-input/--json or pass a valid API key/profile" + "setup needs interactive browser authentication; rerun without --no-input/--json or pass a valid API key or org login" ); } - bail!("profile selection required in non-interactive mode; pass --profile "); + bail!("auth org selection required in non-interactive mode; pass --org "); } if base.verbose { @@ -1903,12 +1897,12 @@ async fn ensure_setup_auth( let profile = stored_profiles .iter() .find(|profile| profile.name == profile_name) - .ok_or_else(|| anyhow!("profile '{profile_name}' not found"))?; + .ok_or_else(|| anyhow!("auth login '{profile_name}' not found"))?; let target_org = match org_name.as_deref() { Some(org_name) => { if profile.org_name.as_deref() != Some(org_name) { bail!( - "profile '{profile_name}' belongs to org '{}' but '{}' was requested", + "auth login '{profile_name}' belongs to org '{}' but '{}' was requested", profile.org_name.as_deref().unwrap_or("(none)"), org_name ); @@ -1916,7 +1910,7 @@ async fn ensure_setup_auth( org_name } None => profile.org_name.as_deref().ok_or_else(|| { - anyhow!("profile '{profile_name}' does not have a default org") + anyhow!("auth login '{profile_name}' does not have a default org") })?, }; @@ -1969,9 +1963,7 @@ async fn ensure_setup_auth( .iter() .filter(|profile| profile.org_name.as_deref() == Some(org_name)) .count(); - if base.prefer_profile && matching_profile_count == 0 { - bail!("no profile found for org '{org_name}'"); - } + let _ = matching_profile_count; if let Some(org) = find_available_org(&available_orgs, org_name) { let client = build_api_key_client(base, api_key, org).await?; @@ -2017,92 +2009,6 @@ async fn ensure_setup_auth( .await; } - if base.prefer_profile { - if stored_profiles.is_empty() { - let matched_orgs = match project_name.as_deref() { - Some(project_name) => { - orgs_with_project(base, api_key, &available_orgs, project_name).await? - } - None => available_orgs.clone(), - }; - if matched_orgs.is_empty() { - return ensure_profile_or_setup_browser_auth_context( - base, - prompt_for_profile_choice, - needs_api_key, - project_name.as_deref(), - project_was_explicit, - org_name.as_deref(), - ) - .await; - } - let org = select_api_key_org_for_setup( - base, - &matched_orgs, - project_name.as_deref(), - &[], - )?; - let client = build_api_key_client(base, api_key, &org).await?; - let api_url = base - .api_url - .clone() - .or_else(|| org.api_url.clone()) - .unwrap_or_else(|| DEFAULT_API_URL.to_string()); - auth::commit_api_key_profile( - &org.name, - api_key, - api_url, - base.app_url.clone(), - Some(org.name.clone()), - )?; - return build_setup_auth_context(base, client, false, needs_api_key, None).await; - } - - let preferred_org_names = matching_profile_org_names(&stored_profiles, None); - let matching_orgs = available_orgs - .iter() - .filter(|org| preferred_org_names.iter().any(|name| name == &org.name)) - .cloned() - .collect::>(); - if matching_orgs.is_empty() { - return ensure_profile_or_setup_browser_auth_context( - base, - prompt_for_profile_choice, - needs_api_key, - project_name.as_deref(), - project_was_explicit, - org_name.as_deref(), - ) - .await; - } - - let candidate_orgs = match project_name.as_deref() { - Some(project_name) => { - orgs_with_project(base, api_key, &matching_orgs, project_name).await? - } - None => matching_orgs, - }; - if candidate_orgs.is_empty() { - return ensure_profile_or_setup_browser_auth_context( - base, - prompt_for_profile_choice, - needs_api_key, - project_name.as_deref(), - project_was_explicit, - org_name.as_deref(), - ) - .await; - } - let org = select_api_key_org_for_setup( - base, - &candidate_orgs, - project_name.as_deref(), - &preferred_org_names, - )?; - let client = build_api_key_client(base, api_key, &org).await?; - return build_setup_auth_context(base, client, false, needs_api_key, None).await; - } - let candidate_orgs = match project_name.as_deref() { Some(project_name) => { orgs_with_project(base, api_key, &available_orgs, project_name).await? @@ -5435,12 +5341,11 @@ mod tests { no_color: false, no_input: false, profile: None, - profile_explicit: false, org_name: None, project: None, api_key: None, api_key_source: None, - prefer_profile: false, + prefer_api_key: false, api_url: None, app_url: None, ca_cert: None, @@ -5778,6 +5683,7 @@ mod tests { let profiles = vec![ auth::ProfileInfo { name: "zeta".to_string(), + auth_method: "api_key".to_string(), org_name: Some("Zeta Org".to_string()), user_name: None, email: None, @@ -5785,6 +5691,7 @@ mod tests { }, auth::ProfileInfo { name: "alpha".to_string(), + auth_method: "api_key".to_string(), org_name: Some("Alpha Org".to_string()), user_name: None, email: None, @@ -5813,6 +5720,7 @@ mod tests { base.profile = Some("missing".to_string()); let profiles = vec![auth::ProfileInfo { name: "work".to_string(), + auth_method: "api_key".to_string(), org_name: Some("Acme".to_string()), user_name: None, email: None, @@ -5821,7 +5729,7 @@ mod tests { let err = resolve_profile_name_for_setup(&base, &profiles, false).expect_err("missing profile"); - assert!(err.to_string().contains("profile 'missing' not found")); + assert!(err.to_string().contains("auth login 'missing' not found")); } #[test] @@ -5829,6 +5737,7 @@ mod tests { let profiles = vec![ auth::ProfileInfo { name: "Acme".to_string(), + auth_method: "api_key".to_string(), org_name: Some("Acme".to_string()), user_name: None, email: None, @@ -5836,6 +5745,7 @@ mod tests { }, auth::ProfileInfo { name: "Acme-2".to_string(), + auth_method: "api_key".to_string(), org_name: Some("Acme".to_string()), user_name: None, email: None, diff --git a/src/status.rs b/src/status.rs index 93b555ed..6893975e 100644 --- a/src/status.rs +++ b/src/status.rs @@ -4,7 +4,7 @@ use serde::Serialize; use crate::args::BaseArgs; use crate::auth; -use crate::{config, utils::resolve_profile_info}; +use crate::config; #[derive(Debug, Clone, Args)] #[command(after_help = "\ @@ -19,13 +19,14 @@ pub struct StatusArgs {} struct StatusOutput { org: Option, project: Option, - profile: Option, #[serde(skip_serializing_if = "Option::is_none")] user_name: Option, #[serde(skip_serializing_if = "Option::is_none")] user_email: Option, #[serde(skip_serializing_if = "Option::is_none")] api_key_hint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + auth_method: Option, source: Option, } @@ -40,6 +41,13 @@ fn format_identity(p: &auth::ProfileInfo) -> Option { } } +fn format_auth(p: &auth::ProfileInfo) -> String { + match format_identity(p) { + Some(identity) => format!("{} — {identity}", p.auth_method), + None => p.auth_method.clone(), + } +} + pub async fn run(base: BaseArgs, _args: StatusArgs) -> Result<()> { let global_path = config::global_path().ok(); let global_cfg = config::load_global().unwrap_or_default(); @@ -51,7 +59,7 @@ pub async fn run(base: BaseArgs, _args: StatusArgs) -> Result<()> { let cli_org = cli_flag_value(&["--org", "-o"]); let cli_project = cli_flag_value(&["--project", "-p"]); - let (mut org, mut project, source) = resolve_config( + let (org, mut project, source) = resolve_config( cli_org, cli_project, &global_cfg, @@ -60,27 +68,7 @@ pub async fn run(base: BaseArgs, _args: StatusArgs) -> Result<()> { &global_path, ); let merged_cfg = global_cfg.merge(&local_cfg); - let selected_profile = base - .profile - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .or_else(|| { - merged_cfg - .profile - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - }); - let profile_info = resolve_profile_info(selected_profile.as_deref(), org.as_deref()); - - if selected_profile.is_some() && org.as_deref().map(str::trim).is_none_or(str::is_empty) { - if let Some(profile_org) = profile_info.as_ref().and_then(|p| p.org_name.clone()) { - org = Some(profile_org); - } - } + let auth_info = auth::active_auth_info(&base, org.as_deref()); if base .project @@ -88,27 +76,17 @@ pub async fn run(base: BaseArgs, _args: StatusArgs) -> Result<()> { .map(str::trim) .is_none_or(str::is_empty) { - let mut project_base = base.clone(); - if project_base - .profile - .as_deref() - .map(str::trim) - .is_none_or(str::is_empty) - { - project_base.profile = selected_profile.clone(); - } - project = - config::project_from_config_for_context(&project_base, &merged_cfg, org.as_deref()); + project = config::project_from_config_for_context(&base, &merged_cfg, org.as_deref()); } if base.json { let output = StatusOutput { org, project, - profile: profile_info.as_ref().map(|p| p.name.clone()), - user_name: profile_info.as_ref().and_then(|p| p.user_name.clone()), - user_email: profile_info.as_ref().and_then(|p| p.email.clone()), - api_key_hint: profile_info.as_ref().and_then(|p| p.api_key_hint.clone()), + user_name: auth_info.as_ref().and_then(|p| p.user_name.clone()), + user_email: auth_info.as_ref().and_then(|p| p.email.clone()), + api_key_hint: auth_info.as_ref().and_then(|p| p.api_key_hint.clone()), + auth_method: auth_info.as_ref().map(|p| p.auth_method.clone()), source, }; println!("{}", serde_json::to_string(&output)?); @@ -116,13 +94,19 @@ pub async fn run(base: BaseArgs, _args: StatusArgs) -> Result<()> { } if base.verbose { - println!("org: {}", org.as_deref().unwrap_or("(unset)")); + let org_display = if org.is_none() + && auth_info + .as_ref() + .is_some_and(|p| p.auth_method == "oauth" && p.org_name.is_none()) + { + "cross-org" + } else { + org.as_deref().unwrap_or("(unset)") + }; + println!("org: {org_display}"); println!("project: {}", project.as_deref().unwrap_or("(unset)")); - if let Some(ref p) = profile_info { - println!("profile: {}", p.name); - if let Some(id) = format_identity(p) { - println!("user: {id}"); - } + if let Some(ref p) = auth_info { + println!("auth: {}", format_auth(p)); } if let Some(src) = source { println!("source: {src}"); @@ -134,14 +118,19 @@ pub async fn run(base: BaseArgs, _args: StatusArgs) -> Result<()> { _ => unreachable!(), }; println!("{scope}"); - let profile_line = match &profile_info { - Some(p) => match format_identity(p) { - Some(id) => format!(" profile: {} — {id}", p.name), - None => format!(" profile: {}", p.name), - }, - None => " profile: (none)".to_string(), + let auth_line = match &auth_info { + Some(p) => format!(" auth: {}", format_auth(p)), + None => " auth: (none)".to_string(), }; - println!("{profile_line}"); + println!("{auth_line}"); + } else if auth_info + .as_ref() + .is_some_and(|p| p.auth_method == "oauth" && p.org_name.is_none()) + { + println!("cross-org"); + if let Some(p) = &auth_info { + println!(" auth: {}", format_auth(p)); + } } else { println!("No org/project configured. Run `bt switch` to set one."); } @@ -345,6 +334,12 @@ mod tests { ) -> auth::ProfileInfo { auth::ProfileInfo { name: name.into(), + auth_method: if api_key_hint.is_some() { + "api_key" + } else { + "oauth" + } + .into(), org_name: None, user_name: user_name.map(Into::into), email: email.map(Into::into), diff --git a/src/switch.rs b/src/switch.rs index 35495bb6..d079a212 100644 --- a/src/switch.rs +++ b/src/switch.rs @@ -54,59 +54,15 @@ pub async fn run(base: BaseArgs, args: SwitchArgs) -> Result<()> { let current_cfg = config::load().unwrap_or_default(); let (resolved_org, resolved_project) = args.resolve_target(&base); let mut interactive = false; - let has_api_key_override = base - .api_key - .as_ref() - .is_some_and(|value| !value.trim().is_empty()); - - let profile_name = match &resolved_org { - Some(org_or_profile) => { - if base.profile.is_some() { - None - } else { - let profiles = auth::list_profiles()?; - Some(auth::resolve_org_to_profile(org_or_profile, &profiles)?) - } - } - None => resolve_profile_for_switch( - has_api_key_override, - resolved_project.is_none(), - is_interactive(), - || auth::select_profile_interactive(current_cfg.org.as_deref()), - &mut interactive, - )?, - }; - // When we resolved a profile from an org identifier, clear org_name — the raw identifier - // (e.g. "staging") may differ from the profile's actual org (e.g. "staging-org"). Letting - // org_name stay would override the profile's stored org_name in resolve_auth_from_store. - // - // When no org was specified (project-only switch), load the current config org so - // resolve_auth can find the right profile for authentication. - let login_base = match &profile_name { - Some(profile) if base.profile.is_none() => BaseArgs { - profile: Some(profile.clone()), - org_name: None, - ..base.clone() - }, - _ => { - let mut b = base.clone(); - if !has_api_key_override && b.org_name.is_none() && b.profile.is_none() { - b.org_name = current_cfg.org.clone(); - } - if !has_api_key_override && b.org_name.is_none() && b.profile.is_none() { - let profiles = auth::list_profiles()?; - if profiles.len() > 1 { - let names: Vec<&str> = profiles.iter().map(|p| p.name.as_str()).collect(); - bail!( - "multiple auth profiles found: {}. Use --profile to disambiguate.", - names.join(", ") - ); - } - } - b - } - }; + let mut login_base = base.clone(); + if login_base.org_name.is_none() { + let saved_org = select_saved_auth_org_for_switch()?; + login_base.org_name = resolved_org + .clone() + .or_else(|| current_cfg.org.clone()) + .or(saved_org); + } let ctx = login(&login_base).await?; let client = ApiClient::new(&ctx)?; @@ -147,15 +103,7 @@ pub async fn run(base: BaseArgs, args: SwitchArgs) -> Result<()> { }; let mut cfg = config::load_file(&path); - let config_profile = - config::trimmed_option(profile_name.as_deref().or(base.profile.as_deref())) - .map(str::to_string); - apply_switch_config( - &mut cfg, - config_profile.as_deref(), - Some(&org_name), - Some(&project), - ); + apply_switch_config(&mut cfg, None, Some(&org_name), Some(&project)); config::save_file(&path, &cfg) .context(format!("Could not save config to {}", path.display()))?; @@ -164,7 +112,6 @@ pub async fn run(base: BaseArgs, args: SwitchArgs) -> Result<()> { "org": org_name, "project": project.name, "project_id": project.id, - "profile": config_profile, "scope": scope, "path": path.display().to_string(), }); @@ -181,6 +128,30 @@ pub async fn run(base: BaseArgs, args: SwitchArgs) -> Result<()> { Ok(()) } +fn select_saved_auth_org_for_switch() -> Result> { + if !is_interactive() { + return Ok(None); + } + + let mut orgs = auth::list_profiles()? + .into_iter() + .filter_map(|profile| profile.org_name) + .filter(|org| !org.trim().is_empty()) + .collect::>(); + orgs.sort(); + orgs.dedup(); + + match orgs.len() { + 0 => Ok(None), + 1 => Ok(orgs.into_iter().next()), + _ => { + let labels = orgs.iter().map(String::as_str).collect::>(); + let idx = crate::ui::fuzzy_select("Select organization", &labels, 0)?; + Ok(Some(orgs[idx].clone())) + } + } +} + pub(crate) fn select_scope() -> Result<(std::path::PathBuf, &'static str)> { let global = config::global_path()?; let local = config::local_path().unwrap(); @@ -258,13 +229,11 @@ pub(crate) async fn validate_or_create_project( pub(crate) fn apply_switch_config( cfg: &mut config::Config, - profile_name: Option<&str>, + _profile_name: Option<&str>, org_name: Option<&str>, project: Option<&api::Project>, ) { - if let Some(profile_name) = config::trimmed_option(profile_name) { - cfg.profile = Some(profile_name.to_string()); - } + cfg.profile = None; cfg.org = config::trimmed_option(org_name).map(str::to_string); match project { Some(project) => { @@ -278,6 +247,7 @@ pub(crate) fn apply_switch_config( } } +#[cfg(test)] fn resolve_profile_for_switch( has_api_key_override: bool, prompting_for_project_only: bool, @@ -326,12 +296,11 @@ mod tests { no_color: false, no_input: false, profile: None, - profile_explicit: false, org_name: org.map(String::from), project: project.map(String::from), api_key: None, api_key_source: None, - prefer_profile: false, + prefer_api_key: false, api_url: None, app_url: None, ca_cert: None, @@ -342,6 +311,7 @@ mod tests { fn profile_info(name: &str, org_name: Option<&str>) -> ProfileInfo { ProfileInfo { name: name.to_string(), + auth_method: "api_key".to_string(), org_name: org_name.map(String::from), user_name: None, email: None, @@ -548,14 +518,14 @@ mod tests { apply_switch_config(&mut cfg, Some("work"), Some("acme-org"), Some(&project)); - assert_eq!(cfg.profile.as_deref(), Some("work")); + assert_eq!(cfg.profile, None); assert_eq!(cfg.org.as_deref(), Some("acme-org")); assert_eq!(cfg.project.as_deref(), Some("my-project")); assert_eq!(cfg.project_id.as_deref(), Some("proj_123")); } #[test] - fn apply_switch_config_preserves_existing_profile_when_no_new_profile() { + fn apply_switch_config_clears_existing_profile() { let mut cfg = config::Config { profile: Some("work".to_string()), ..Default::default() @@ -569,7 +539,7 @@ mod tests { apply_switch_config(&mut cfg, None, Some("acme-org"), Some(&project)); - assert_eq!(cfg.profile.as_deref(), Some("work")); + assert_eq!(cfg.profile, None); assert_eq!(cfg.project.as_deref(), Some("next-project")); } @@ -585,7 +555,7 @@ mod tests { apply_switch_config(&mut cfg, Some("next"), None, None); - assert_eq!(cfg.profile.as_deref(), Some("next")); + assert_eq!(cfg.profile, None); assert_eq!(cfg.org, None); assert_eq!(cfg.project, None); assert_eq!(cfg.project_id, None); diff --git a/src/traces.rs b/src/traces.rs index 6ef7ff1c..cc59c4ca 100644 --- a/src/traces.rs +++ b/src/traces.rs @@ -33,7 +33,7 @@ use unicode_width::UnicodeWidthStr; use urlencoding::{decode, encode}; use crate::args::BaseArgs; -use crate::auth::{self, login}; +use crate::auth::login; use crate::experiments::api as experiments_api; use crate::http::ApiClient; use crate::ui::{fuzzy_select, is_interactive, with_spinner}; @@ -1060,7 +1060,7 @@ async fn run_logs_command(base: BaseArgs, client: ApiClient, args: LogsArgs) -> let has_more = next_cursor.is_some(); let rows = parse_summary_rows(response.data); let object_ref_arg = format_object_ref_arg(&object_ref); - let profile_flag = base.profile.as_deref(); + let org_flag = base.org_name.as_deref(); if base.json { let payload = json!({ @@ -1082,7 +1082,7 @@ async fn run_logs_command(base: BaseArgs, client: ApiClient, args: LogsArgs) -> has_more, &object_ref_arg, next_cursor.as_deref(), - profile_flag, + org_flag, args.limit, ), }); @@ -1092,7 +1092,7 @@ async fn run_logs_command(base: BaseArgs, client: ApiClient, args: LogsArgs) -> &rows, args.list_mode, &object_ref_arg, - profile_flag, + org_flag, args.limit, args.preview_length, next_cursor.as_deref(), @@ -1141,7 +1141,7 @@ async fn run_trace_command(base: BaseArgs, client: ApiClient, args: TraceArgs) - next_cursor_if_full_page(response.cursor.clone(), response.data.len(), selector.limit); let has_more = next_cursor.is_some(); let object_ref_arg = format_object_ref_arg(&target.object_ref); - let profile_flag = base.profile.as_deref(); + let org_flag = base.org_name.as_deref(); if base.json { let payload = json!({ @@ -1164,7 +1164,7 @@ async fn run_trace_command(base: BaseArgs, client: ApiClient, args: TraceArgs) - &object_ref_arg, &target.root_span_id, next_cursor.as_deref(), - profile_flag, + org_flag, selector.limit, ), }); @@ -1174,7 +1174,7 @@ async fn run_trace_command(base: BaseArgs, client: ApiClient, args: TraceArgs) - &target.root_span_id, &response.data, &object_ref_arg, - profile_flag, + org_flag, selector.limit, selector.preview_length, next_cursor.as_deref(), @@ -1231,11 +1231,11 @@ async fn run_thread_command(base: BaseArgs, client: ApiClient, args: ThreadArgs) }, "summary": summary, "messages": messages, - "hints": thread_hints(base.profile.as_deref()), + "hints": thread_hints(base.org_name.as_deref()), }); println!("{}", serde_json::to_string_pretty(&payload)?); } else { - print_thread_text(&target, &messages, &summary, base.profile.as_deref()); + print_thread_text(&target, &messages, &summary, base.org_name.as_deref()); } Ok(()) @@ -1287,14 +1287,14 @@ async fn run_waterfall_command( "has_more": has_more, }, "waterfall": waterfall, - "hints": waterfall_hints(base.profile.as_deref(), has_more), + "hints": waterfall_hints(base.org_name.as_deref(), has_more), }); println!("{}", serde_json::to_string_pretty(&payload)?); } else { waterfall::print_waterfall_text( &target, &waterfall, - base.profile.as_deref(), + base.org_name.as_deref(), selector.limit, has_more, ); @@ -5683,9 +5683,9 @@ fn format_object_ref_arg(object_ref: &ObjectRef) -> String { format!("{}:{}", object_ref.object_type, object_ref.object_name) } -fn profile_flag_suffix(profile: Option<&str>) -> String { - match profile.filter(|p| !p.trim().is_empty()) { - Some(profile) => format!(" --profile {}", btql_quote(profile)), +fn profile_flag_suffix(org: Option<&str>) -> String { + match org.filter(|p| !p.trim().is_empty()) { + Some(org) => format!(" --org {}", btql_quote(org)), None => String::new(), } } @@ -5715,21 +5715,7 @@ fn parse_startup_trace_url_from_view_args(args: &ViewArgs) -> Result) -> BaseArgs { - apply_url_hints_with_profile_resolver(base, parsed_url, |org| { - let profiles = auth::list_profiles().ok()?; - auth::resolve_org_to_profile(org, &profiles).ok() - }) -} - -fn apply_url_hints_with_profile_resolver( - mut base: BaseArgs, - parsed_url: Option<&ParsedTraceUrl>, - resolve_profile_for_org: F, -) -> BaseArgs -where - F: Fn(&str) -> Option, -{ +fn apply_url_hints_to_base(mut base: BaseArgs, parsed_url: Option<&ParsedTraceUrl>) -> BaseArgs { let Some(parsed) = parsed_url else { return base; }; @@ -5742,28 +5728,30 @@ where return base; }; - let has_profile_override = base - .profile - .as_deref() - .map(str::trim) - .is_some_and(|v| !v.is_empty()); let has_org_override = base .org_name .as_deref() .map(str::trim) .is_some_and(|v| !v.is_empty()); - if !has_org_override && !has_profile_override { + if !has_org_override { base.org_name = Some(url_org.to_string()); } - if !has_profile_override && !has_org_override { - if let Some(profile_name) = resolve_profile_for_org(url_org) { - base.profile = Some(profile_name); - } - } base } +#[cfg(test)] +fn apply_url_hints_with_profile_resolver( + base: BaseArgs, + parsed_url: Option<&ParsedTraceUrl>, + _resolve_profile_for_org: F, +) -> BaseArgs +where + F: Fn(&str) -> Option, +{ + apply_url_hints_to_base(base, parsed_url) +} + fn select_startup_url( long_url: Option<&str>, positional_url: Option<&str>, @@ -6729,12 +6717,11 @@ mod tests { no_color: false, no_input: false, profile: None, - profile_explicit: false, org_name: None, project: None, api_key: None, api_key_source: None, - prefer_profile: false, + prefer_api_key: false, api_url: None, app_url: None, ca_cert: None, @@ -6963,7 +6950,7 @@ mod tests { } #[test] - fn apply_url_hints_infers_profile_from_url_org() { + fn apply_url_hints_infers_org_from_url() { let base = base_args(); let parsed = parsed_url_with_org("Lovable"); @@ -6972,21 +6959,20 @@ mod tests { }); assert_eq!(updated.org_name.as_deref(), Some("Lovable")); - assert_eq!(updated.profile.as_deref(), Some("lovable-profile")); + assert_eq!(updated.profile, None); } #[test] - fn apply_url_hints_preserves_explicit_profile() { + fn apply_url_hints_preserves_explicit_org() { let mut base = base_args(); - base.profile = Some("explicit-profile".to_string()); + base.org_name = Some("explicit-org".to_string()); let parsed = parsed_url_with_org("Lovable"); let updated = apply_url_hints_with_profile_resolver(base, Some(&parsed), |_| { Some("other".to_string()) }); - assert_eq!(updated.profile.as_deref(), Some("explicit-profile")); - assert!(updated.org_name.is_none()); + assert_eq!(updated.org_name.as_deref(), Some("explicit-org")); } #[test] diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 05429ee0..52a4f874 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -14,4 +14,4 @@ pub use git::GitRepo; pub(crate) use ids::new_uuid_id; pub(crate) use json_object::lookup_object_path; pub use plurals::pluralize; -pub(crate) use profile::{profile_author_slug, resolve_profile_info, sanitize_name_segment}; +pub(crate) use profile::{profile_author_slug, sanitize_name_segment}; diff --git a/src/utils/profile.rs b/src/utils/profile.rs index b97812b2..620d681c 100644 --- a/src/utils/profile.rs +++ b/src/utils/profile.rs @@ -1,13 +1,6 @@ -use crate::auth::{self, ProfileInfo}; - -pub(crate) fn resolve_profile_info( - profile: Option<&str>, - org: Option<&str>, -) -> Option { - let profiles = auth::list_profiles().ok()?; - resolve_profile_info_from_profiles(profile, org, profiles) -} +use crate::auth::ProfileInfo; +#[cfg(test)] fn resolve_profile_info_from_profiles( profile: Option<&str>, org: Option<&str>, @@ -40,6 +33,17 @@ fn resolve_profile_info_from_profiles( .into_iter() .find(|profile| profile.name == profile_name); } + let oauth_matches: Vec<&ProfileInfo> = org_matches + .iter() + .copied() + .filter(|profile| profile.email.is_some() || profile.user_name.is_some()) + .collect(); + if oauth_matches.len() == 1 { + let profile_name = oauth_matches[0].name.clone(); + return profiles + .into_iter() + .find(|profile| profile.name == profile_name); + } return None; } @@ -54,7 +58,7 @@ pub(crate) fn profile_author_slug(profile: &ProfileInfo) -> Option { [ profile.user_name.as_deref(), profile.email.as_deref().and_then(email_local_part), - Some(profile.name.as_str()), + profile.org_name.as_deref(), ] .into_iter() .flatten() @@ -105,6 +109,12 @@ mod tests { ) -> ProfileInfo { ProfileInfo { name: name.to_string(), + auth_method: if email.is_some() || user_name.is_some() { + "oauth" + } else { + "api_key" + } + .to_string(), org_name: org_name.map(ToOwned::to_owned), user_name: user_name.map(ToOwned::to_owned), email: email.map(ToOwned::to_owned), @@ -155,12 +165,9 @@ mod tests { } #[test] - fn profile_author_slug_falls_back_to_profile_name() { + fn profile_author_slug_ignores_internal_profile_name() { let profile = profile_info("Work Profile", None, None, None); - assert_eq!( - profile_author_slug(&profile).as_deref(), - Some("work-profile") - ); + assert_eq!(profile_author_slug(&profile), None); } #[test] diff --git a/tests/cli.rs b/tests/cli.rs index acb09bfd..a120455c 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -10,7 +10,6 @@ fn bt_command() -> Command { fn clear_braintrust_auth_env(cmd: &mut Command) { for key in [ "BRAINTRUST_API_KEY", - "BRAINTRUST_PROFILE", "BRAINTRUST_ORG_NAME", "BRAINTRUST_DEFAULT_PROJECT", ] { @@ -113,13 +112,7 @@ fn top_level_help_shows_update_not_self() { fn topics_report_help_accepts_global_org_short_conflict_free() { bt_command() .args([ - "topics", - "report", - "--profile", - "test-profile", - "--id", - "fn_123", - "--help", + "topics", "report", "--org", "test-org", "--id", "fn_123", "--help", ]) .assert() .success() @@ -167,7 +160,7 @@ fn status_json_keeps_local_org_when_global_profile_has_different_org() { .success() .stdout(predicate::str::contains(r#""org":"local-org""#)) .stdout(predicate::str::contains(r#""project":"local-project""#)) - .stdout(predicate::str::contains(r#""profile":"default-profile""#)) + .stdout(predicate::str::contains(r#""profile""#).not()) .stdout(predicate::str::contains(r#""org":"profile-org""#).not()); } @@ -459,12 +452,12 @@ fn setup_mcp_only_requires_auth_in_non_interactive_mode() { .assert() .failure() .stderr(predicate::str::contains( - "profile selection required in non-interactive mode", + "auth org selection required in non-interactive mode", )); } #[test] -fn datasets_requires_profile_selection_when_multiple_profiles_exist() { +fn datasets_requires_org_selection_when_multiple_api_key_logins_exist() { let repo = make_git_repo(); let home = tempfile::tempdir().expect("home tempdir"); let config_home = tempfile::tempdir().expect("config tempdir"); @@ -481,8 +474,10 @@ fn datasets_requires_profile_selection_when_multiple_profiles_exist() { .args(["datasets", "--no-input"]) .assert() .failure() - .stderr(predicate::str::contains("multiple auth profiles available")) - .stderr(predicate::str::contains("--profile ")) + .stderr(predicate::str::contains( + "multiple API key logins available", + )) + .stderr(predicate::str::contains("--org ")) .stderr(predicate::str::contains("alpha")) .stderr(predicate::str::contains("beta")); } diff --git a/tests/functions.rs b/tests/functions.rs index 03f70300..f91c25d2 100644 --- a/tests/functions.rs +++ b/tests/functions.rs @@ -669,15 +669,17 @@ fn functions_push_rejects_invalid_language() { #[test] fn functions_push_requires_app_url_with_custom_api_url() { + let config_dir = tempdir().expect("config tempdir"); let output = Command::new(bt_binary_path()) .arg("functions") .arg("--json") .arg("push") + .env("XDG_CONFIG_HOME", config_dir.path()) + .env("APPDATA", config_dir.path()) .env("BRAINTRUST_API_KEY", "test-key") .env("BRAINTRUST_API_URL", "http://127.0.0.1:1") .env_remove("BRAINTRUST_APP_URL") .env_remove("BRAINTRUST_ORG_NAME") - .env_remove("BRAINTRUST_PROFILE") .output() .expect("run push with custom API URL and no app URL"); @@ -713,10 +715,8 @@ fn auth_profiles_ignores_api_key_env_override() { assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stdout.contains("No saved profiles. Run `bt auth login` to create one.")); + assert!(stdout.contains("No saved auth logins. Run `bt auth login` to create one.")); assert!(!stdout.contains("Auth source: --api-key/BRAINTRUST_API_KEY override")); - assert!(!stderr.contains("pass --prefer-profile or unset BRAINTRUST_API_KEY")); } #[test] @@ -732,10 +732,8 @@ fn auth_profiles_ignores_api_key_from_dotenv() { assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stdout.contains("No saved profiles. Run `bt auth login` to create one.")); + assert!(stdout.contains("No saved auth logins. Run `bt auth login` to create one.")); assert!(!stdout.contains("Auth source: --api-key/BRAINTRUST_API_KEY override")); - assert!(!stderr.contains("pass --prefer-profile or unset BRAINTRUST_API_KEY")); } #[test] @@ -753,28 +751,8 @@ fn auth_profiles_json_with_no_profiles_emits_empty_array() { assert_eq!(stdout, "[]"); } -#[test] -fn auth_profiles_profile_not_found_is_actionable() { - let cwd = tempdir().expect("create temp cwd"); - let config_dir = tempdir().expect("create temp config dir"); - - let output = auth_profiles_command(cwd.path(), config_dir.path()) - .arg("--profile") - .arg("test-profile") - .output() - .expect("run bt auth profiles --profile test-profile"); - - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("profile 'test-profile' not found")); - assert!( - stderr.contains("run `bt auth profiles` to see available profiles"), - "expected actionable hint, got: {stderr}" - ); -} - -/// Seed a synthetic api_key `test-profile` so verification reports "missing" -/// without touching the network or keychain. +/// Seed a synthetic API-key auth login so refresh can fail before touching the +/// network or keychain. fn seed_api_key_profile(config_dir: &Path) { fs::create_dir_all(config_dir.join("bt")).expect("create bt config dir"); fs::write( @@ -794,7 +772,6 @@ fn auth_sub_command(cwd: &Path, config_dir: &Path, sub: &[&str]) -> Command { .env("XDG_CONFIG_HOME", config_dir) .env("APPDATA", config_dir) .env("BRAINTRUST_NO_COLOR", "1") - .env_remove("BRAINTRUST_PROFILE") .env_remove("BRAINTRUST_ORG_NAME") .env_remove("BRAINTRUST_API_URL") .env_remove("BRAINTRUST_APP_URL") @@ -817,9 +794,9 @@ fn auth_logout_json_with_no_profiles_emits_empty_status() { } #[test] -fn auth_refresh_errors_for_api_key_profile_even_with_json() { - // --json must not swallow a real error: an api_key profile cannot be - // refreshed, and the command should fail with an actionable message. +fn auth_refresh_errors_when_no_oauth_login_exists_even_with_json() { + // --json must not swallow a real error: refresh only applies to OAuth + // logins, and an org with only API-key auth should fail actionably. let cwd = tempdir().expect("create temp cwd"); let config_dir = tempdir().expect("create temp config dir"); @@ -828,15 +805,15 @@ fn auth_refresh_errors_for_api_key_profile_even_with_json() { let output = auth_sub_command( cwd.path(), config_dir.path(), - &["refresh", "--profile", "test-profile", "--json"], + &["refresh", "--org", "test-org", "--json"], ) .output() - .expect("run bt auth refresh --profile test-profile --json"); + .expect("run bt auth refresh --org test-org --json"); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("only applies to oauth profiles"), + stderr.contains("no OAuth login selected"), "expected oauth-only refresh hint, got: {stderr}" ); assert!(String::from_utf8_lossy(&output.stdout).trim().is_empty()); @@ -1877,7 +1854,6 @@ exit 24 .env("BRAINTRUST_API_URL", &server.base_url) .env("BRAINTRUST_APP_URL", &server.base_url) .env("BRAINTRUST_NO_COLOR", "1") - .env_remove("BRAINTRUST_PROFILE") .output() .expect("run bt functions push"); @@ -2066,7 +2042,6 @@ exit 24 .env("BRAINTRUST_API_URL", &server.base_url) .env("BRAINTRUST_APP_URL", &server.base_url) .env("BRAINTRUST_NO_COLOR", "1") - .env_remove("BRAINTRUST_PROFILE") .output() .expect("run bt functions push"); @@ -2200,7 +2175,6 @@ exit 24 .env("BRAINTRUST_API_URL", &server.base_url) .env("BRAINTRUST_APP_URL", &server.base_url) .env("BRAINTRUST_NO_COLOR", "1") - .env_remove("BRAINTRUST_PROFILE") .output() .expect("run bt functions push"); @@ -2347,7 +2321,6 @@ exit 24 .env("BRAINTRUST_API_URL", &server.base_url) .env("BRAINTRUST_APP_URL", &server.base_url) .env("BRAINTRUST_NO_COLOR", "1") - .env_remove("BRAINTRUST_PROFILE") .output() .expect("run bt functions push"); @@ -2410,7 +2383,6 @@ async fn functions_view_by_positional_id_does_not_require_project_context() { .env("BRAINTRUST_APP_URL", &server.base_url) .env("BRAINTRUST_NO_COLOR", "1") .env("BRAINTRUST_NO_INPUT", "1") - .env_remove("BRAINTRUST_PROFILE") .env_remove("BRAINTRUST_DEFAULT_PROJECT") .env_remove("BT_FUNCTIONS_VIEW_ID") .env_remove("BT_FUNCTIONS_VIEW_VERSION") @@ -2485,7 +2457,6 @@ async fn functions_view_by_id_passes_version() { .env("BRAINTRUST_APP_URL", &server.base_url) .env("BRAINTRUST_NO_COLOR", "1") .env("BRAINTRUST_NO_INPUT", "1") - .env_remove("BRAINTRUST_PROFILE") .env_remove("BRAINTRUST_DEFAULT_PROJECT") .env_remove("BT_FUNCTIONS_VIEW_ID") .env_remove("BT_FUNCTIONS_VIEW_VERSION") @@ -2569,7 +2540,6 @@ async fn functions_view_by_slug_passes_version() { .env("BRAINTRUST_DEFAULT_PROJECT", "mock-project") .env("BRAINTRUST_NO_COLOR", "1") .env("BRAINTRUST_NO_INPUT", "1") - .env_remove("BRAINTRUST_PROFILE") .env_remove("BT_FUNCTIONS_VIEW_ID") .env_remove("BT_FUNCTIONS_VIEW_VERSION") .output() @@ -2660,7 +2630,6 @@ async fn functions_pull_works_against_mock_api() { .env("BRAINTRUST_API_URL", &server.base_url) .env("BRAINTRUST_APP_URL", &server.base_url) .env("BRAINTRUST_NO_COLOR", "1") - .env_remove("BRAINTRUST_PROFILE") .output() .expect("run bt functions pull"); @@ -2779,7 +2748,6 @@ async fn functions_pull_skips_untracked_existing_file_without_force() { .env("BRAINTRUST_API_URL", &server.base_url) .env("BRAINTRUST_APP_URL", &server.base_url) .env("BRAINTRUST_NO_COLOR", "1") - .env_remove("BRAINTRUST_PROFILE") .output() .expect("run bt functions pull"); @@ -2859,7 +2827,6 @@ async fn functions_pull_selector_with_unsupported_only_rows_still_succeeds() { .env("BRAINTRUST_API_URL", &server.base_url) .env("BRAINTRUST_APP_URL", &server.base_url) .env("BRAINTRUST_NO_COLOR", "1") - .env_remove("BRAINTRUST_PROFILE") .output() .expect("run bt functions pull"); From 894226823d4c36e9bb01bd10a2289aa166c512a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Thu, 16 Jul 2026 13:19:33 -0700 Subject: [PATCH 2/8] follow up and remove all profile code --- src/args.rs | 9 +- src/auth.rs | 1368 ++------------------------------------ src/config/mod.rs | 33 +- src/datasets/pipeline.rs | 3 +- src/experiments/mod.rs | 7 +- src/functions/push.rs | 3 +- src/main.rs | 28 + src/setup/mod.rs | 357 +++------- src/status.rs | 164 +++-- src/switch.rs | 213 +----- src/traces.rs | 41 +- src/traces/waterfall.rs | 8 +- src/utils/profile.rs | 91 +-- tests/cli.rs | 9 +- 14 files changed, 353 insertions(+), 1981 deletions(-) diff --git a/src/args.rs b/src/args.rs index f0f20d64..930cfe9d 100644 --- a/src/args.rs +++ b/src/args.rs @@ -38,13 +38,13 @@ pub struct BaseArgs { #[arg(long, env = "BRAINTRUST_NO_INPUT", global = true, value_parser = clap::builder::BoolishValueParser::new(), default_value_t = false)] pub no_input: bool, - #[arg(skip)] - pub profile: Option, - /// Override active org (or via BRAINTRUST_ORG_NAME) #[arg(short = 'o', long = "org", env = "BRAINTRUST_ORG_NAME", global = true)] pub org_name: Option, + #[arg(skip)] + pub org_name_source: Option, + /// Override active project #[arg( short = 'p', @@ -55,6 +55,9 @@ pub struct BaseArgs { )] pub project: Option, + #[arg(skip)] + pub project_source: Option, + /// Override stored API key (or via BRAINTRUST_API_KEY) #[arg(long, env = "BRAINTRUST_API_KEY", global = true, hide = true)] pub api_key: Option, diff --git a/src/auth.rs b/src/auth.rs index 32f1ae1d..8ab0df8c 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -62,7 +62,6 @@ pub struct ResolvedAuth { #[derive(Debug, Clone)] pub struct ProfileInfo { - pub name: String, pub auth_method: String, pub org_name: Option, pub user_name: Option, @@ -70,12 +69,6 @@ pub struct ProfileInfo { pub api_key_hint: Option, } -#[derive(Debug, Clone)] -pub(crate) struct StoredProfileInfo { - pub name: String, - pub org_name: Option, -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct AvailableOrg { pub id: String, @@ -127,120 +120,11 @@ pub fn list_profiles() -> Result> { let store = load_auth_store()?; Ok(store .profiles - .iter() - .map(|(name, p)| profile_info_from_store_entry(name, p)) - .collect()) -} - -pub(crate) fn list_stored_profiles() -> Result> { - let store = load_auth_store()?; - Ok(store - .profiles - .iter() - .map(|(name, profile)| StoredProfileInfo { - name: name.clone(), - org_name: profile.org_name.clone(), - }) + .values() + .map(profile_info_from_store_entry) .collect()) } -#[cfg(test)] -pub fn resolve_org_to_profile(identifier: &str, profiles: &[ProfileInfo]) -> Result { - if profiles.is_empty() { - bail!("no auth profiles found. Run `bt auth login` to create one."); - } - - if let Some(p) = profiles.iter().find(|p| p.name == identifier) { - return Ok(p.name.clone()); - } - - let matches: Vec<&ProfileInfo> = profiles - .iter() - .filter(|p| p.org_name.as_deref() == Some(identifier)) - .collect(); - - match matches.len() { - 0 => { - let available: Vec = profiles - .iter() - .filter_map(|p| { - p.org_name - .as_ref() - .map(|org| format!(" {} (profile: {})", org, p.name)) - }) - .collect(); - bail!( - "no profile found for '{identifier}'.\nAvailable:\n{}", - available.join("\n") - ); - } - 1 => Ok(matches[0].name.clone()), - _ => { - if !ui::can_prompt() { - bail!( - "multiple auth logins for org '{identifier}': {}. Use --org to disambiguate.", - matches - .iter() - .map(|p| p.name.as_str()) - .collect::>() - .join(", ") - ); - } - let names: Vec<&str> = matches.iter().map(|p| p.name.as_str()).collect(); - let idx = crate::ui::fuzzy_select( - &format!("Multiple profiles for '{identifier}'. Select one"), - &names, - 0, - )?; - Ok(matches[idx].name.clone()) - } - } -} - -fn profile_info_identity_label(profile: &ProfileInfo) -> Option { - if let Some(email) = profile.email.as_deref() { - return match profile.user_name.as_deref() { - Some(name) => Some(format!("{name} ({email})")), - None => Some(email.to_string()), - }; - } - profile.api_key_hint.clone() -} - -pub(crate) fn profile_info_label(profile: &ProfileInfo) -> String { - let mut parts = vec![profile - .org_name - .clone() - .unwrap_or_else(|| "cross-org".to_string())]; - parts.push(profile.auth_method.clone()); - if let Some(identity) = profile_info_identity_label(profile) { - parts.push(identity); - } - parts.join(" — ") -} - -pub fn select_profile_interactive(current: Option<&str>) -> Result> { - let profiles = list_profiles()?; - if profiles.is_empty() { - bail!("no auth logins found. Run `bt auth login` to create one."); - } - if profiles.len() == 1 { - return Ok(Some(profiles[0].name.clone())); - } - - let labels: Vec = profiles.iter().map(profile_info_label).collect(); - - let default = current - .and_then(|c| { - profiles - .iter() - .position(|p| p.name == c || p.org_name.as_deref() == Some(c)) - }) - .unwrap_or(0); - let idx = crate::ui::fuzzy_select("Select auth login", &labels, default)?; - Ok(Some(profiles[idx].name.clone())) -} - pub async fn list_available_orgs(base: &BaseArgs) -> Result> { let resolved = resolve_auth(base).await?; let app_url = resolved @@ -842,11 +726,6 @@ fn resolve_env_api_key(base: &BaseArgs) -> Option { Some(value.to_string()) } -#[cfg(test)] -fn resolve_api_key_override(base: &BaseArgs) -> Option { - resolve_cli_api_key_override(base) -} - fn config_auth_context(base: &BaseArgs) -> Option { let cfg = crate::config::load().unwrap_or_default(); config_auth_context_from_config(base, &cfg) @@ -1185,32 +1064,6 @@ fn auth_slot_label(name: &str, profile: &AuthProfile) -> String { parts.join(" — ") } -#[cfg(test)] -fn resolve_profile_for_org<'a>(org: &str, store: &'a AuthStore) -> Option<&'a str> { - let matches: Vec<&str> = store - .profiles - .iter() - .filter(|(name, p)| name.as_str() == org || profile_matches_org_identifier(p, org)) - .map(|(name, _)| name.as_str()) - .collect(); - - match matches.len() { - 0 => None, - 1 => Some(matches[0]), - _ => None, - } -} - -#[cfg(test)] -fn profile_names_for_org<'a>(org: &str, store: &'a AuthStore) -> Vec<&'a str> { - store - .profiles - .iter() - .filter(|(_, profile)| profile_matches_org_identifier(profile, org)) - .map(|(name, _)| name.as_str()) - .collect() -} - fn is_cross_org_oauth_profile(profile: &AuthProfile) -> bool { profile.auth_kind == AuthKind::Oauth && profile @@ -1241,9 +1094,8 @@ fn auth_profile_names_by_kind<'a>( .collect() } -fn profile_info_from_store_entry(name: &str, profile: &AuthProfile) -> ProfileInfo { +fn profile_info_from_store_entry(profile: &AuthProfile) -> ProfileInfo { ProfileInfo { - name: name.to_string(), auth_method: auth_kind_label(profile.auth_kind).to_string(), org_name: profile.org_name.clone(), user_name: profile.user_name.clone(), @@ -1253,15 +1105,11 @@ fn profile_info_from_store_entry(name: &str, profile: &AuthProfile) -> ProfileIn } fn profile_info_for_candidate(store: &AuthStore, name: &str) -> Option { - store - .profiles - .get(name) - .map(|profile| profile_info_from_store_entry(name, profile)) + store.profiles.get(name).map(profile_info_from_store_entry) } fn ad_hoc_api_key_profile(org: Option<&str>, api_key: &str) -> ProfileInfo { ProfileInfo { - name: String::new(), auth_method: auth_kind_label(AuthKind::ApiKey).to_string(), org_name: org.map(str::to_string), user_name: None, @@ -1458,148 +1306,6 @@ fn select_auth_profile_candidate( } } -#[cfg(test)] -fn maybe_select_profile_for_auth( - base: &BaseArgs, - store: &AuthStore, - cfg_org: &Option, - can_prompt: bool, -) -> Result> { - if resolve_api_key_override(base).is_some() { - return Ok(None); - } - - let requested_profile = base - .profile - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()); - if requested_profile.is_some() { - return Ok(None); - } - - let effective_org = base.org_name.as_deref().or(cfg_org.as_deref()); - if let Some(org) = effective_org { - if resolve_profile_for_org(org, store).is_some() { - return Ok(None); - } - - let matching_profiles = profile_names_for_org(org, store); - if matching_profiles.is_empty() { - return Ok(None); - } - - if !can_prompt { - bail!( - "multiple auth logins for org '{org}': {}. Use --org to disambiguate.", - matching_profiles.join(", ") - ); - } - - return select_profile_from_store( - &format!("Multiple profiles for '{org}'. Select one"), - &matching_profiles, - Some(org), - store, - ) - .map(Some); - } - - if store.profiles.len() <= 1 { - return Ok(None); - } - - let names: Vec<&str> = store.profiles.keys().map(|name| name.as_str()).collect(); - if !can_prompt { - bail!( - "multiple auth logins available: {}. Pass --org to disambiguate.", - names.join(", ") - ); - } - - select_profile_from_store("Select org", &names, None, store).map(Some) -} - -#[cfg(test)] -fn resolve_auth_from_store_with_secret_lookup( - base: &BaseArgs, - store: &AuthStore, - load_secret: F, - cfg_org: &Option, -) -> Result -where - F: Fn(&str) -> Result>, -{ - if let Some(api_key) = resolve_api_key_override(base) { - return Ok(ResolvedAuth { - api_key: Some(api_key), - api_url: base.api_url.clone(), - app_url: base.app_url.clone(), - org_name: base.org_name.clone().or_else(|| cfg_org.clone()), - is_oauth: false, - }); - } - - let requested_profile = base - .profile - .as_ref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()); - - let effective_org = base.org_name.as_deref().or(cfg_org.as_deref()); - - let selected_profile_name = if let Some(profile) = requested_profile { - Some(profile) - } else if let Some(org) = effective_org { - resolve_profile_for_org(org, store) - } else if store.profiles.len() == 1 { - store.profiles.keys().next().map(|k| k.as_str()) - } else { - None - }; - - if let Some(profile_name) = selected_profile_name { - let profile = store.profiles.get(profile_name).ok_or_else(|| { - anyhow::anyhow!( - "auth login '{profile_name}' not found; run `bt auth profiles` or `bt auth login --org `" - ) - })?; - let is_oauth = profile.auth_kind == AuthKind::Oauth; - let api_key = if is_oauth { - None - } else { - Some(load_secret(profile_name)?.ok_or_else(|| { - recoverable_auth_error( - RecoverableAuthErrorKind::StoredCredential, - format!( - "no keychain credential found for auth login '{profile_name}'; re-run `bt auth login --org `" - ), - ) - })?) - }; - - return Ok(ResolvedAuth { - api_key, - api_url: base.api_url.clone().or_else(|| profile.api_url.clone()), - app_url: base.app_url.clone().or_else(|| profile.app_url.clone()), - org_name: base - .org_name - .clone() - .or_else(|| cfg_org.clone()) - .or_else(|| profile.org_name.clone()), - is_oauth, - }); - } - - Ok(ResolvedAuth { - api_key: None, - api_url: base.api_url.clone(), - app_url: base.app_url.clone(), - org_name: base.org_name.clone().or_else(|| cfg_org.clone()), - is_oauth: false, - }) -} - async fn run_login_set(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> { if args.oauth { return run_login_oauth(base, args).await; @@ -2016,301 +1722,67 @@ async fn run_login_refresh(base: &BaseArgs) -> Result<()> { ) } -#[cfg(test)] -fn resolve_selected_profile_name_for_debug( - base: &BaseArgs, - store: &AuthStore, -) -> Result<(String, &'static str)> { - if let Some(profile_name) = base.profile.as_deref() { - let profile_name = profile_name.trim(); - if !profile_name.is_empty() { - return Ok((profile_name.to_string(), "legacy profile")); - } - } - - if let Some(org) = base.org_name.as_deref() { - if let Some(profile_name) = resolve_profile_for_org(org, store) { - return Ok((profile_name.to_string(), "org-based resolution")); - } - } - - if store.profiles.len() == 1 { - let name = store.profiles.keys().next().unwrap().clone(); - return Ok((name, "only profile")); - } - - if store.profiles.len() > 1 && ui::can_prompt() { - if let Some(name) = select_profile_interactive(None)? { - return Ok((name, "interactive selection")); - } - } - - bail!("no auth login selected; pass --org or rerun interactively") -} - -#[cfg(test)] -fn resolve_profile_name( - explicit_profile: Option<&str>, - suggested_org_name: Option<&str>, -) -> Result { - if let Some(profile) = explicit_profile { - let profile = profile.trim(); - if profile.is_empty() { - bail!("profile name cannot be empty"); - } - return Ok(profile.to_string()); +fn format_login_success(selected_org: Option<&LoginOrgInfo>, api_url: &str) -> String { + match selected_org { + Some(org) => format!("Logged in as {} (api: {api_url})", org.name), + None => format!("Logged in (cross-org, api: {api_url})"), } - - Ok(suggested_org_name - .map(str::trim) - .filter(|name| !name.is_empty()) - .unwrap_or("profile") - .to_string()) } -#[cfg(test)] -fn default_login_org_name( - store: &AuthStore, - profile_name: Option<&str>, - requested_org_name: Option<&str>, -) -> Option { - if requested_org_name - .map(str::trim) - .is_some_and(|name| !name.is_empty()) - { - return None; +fn build_login_context_for_selected_org( + credential: &str, + api_url: &str, + app_url: &str, + selected_org: Option<&LoginOrgInfo>, +) -> LoginContext { + let login = LoginState::new(); + let _ = login.set( + credential.to_string(), + selected_org.map(|org| org.id.clone()).unwrap_or_default(), + selected_org.map(|org| org.name.clone()).unwrap_or_default(), + api_url.to_string(), + app_url.to_string(), + ); + LoginContext { + login, + api_url: api_url.to_string(), + app_url: app_url.to_string(), } - - let profile_name = profile_name - .map(str::trim) - .filter(|name| !name.is_empty())?; - let stored_org_name = store - .profiles - .get(profile_name) - .and_then(|profile| profile.org_name.as_deref()) - .map(str::trim) - .filter(|org_name| !org_name.is_empty()); - - Some(stored_org_name.unwrap_or(profile_name).to_string()) -} - -#[cfg(test)] -fn default_profile_name(suggested_org_name: Option<&str>) -> String { - suggested_org_name - .map(str::trim) - .filter(|name| !name.is_empty()) - .unwrap_or("profile") - .to_string() } -#[cfg(test)] -fn next_available_profile_name(base_name: &str, store: &AuthStore) -> String { - if !store.profiles.contains_key(base_name) { - return base_name.to_string(); +fn format_post_login_context( + selected_org: Option<&LoginOrgInfo>, + project: Option<&api::Project>, +) -> String { + match (selected_org, project) { + (Some(org), Some(project)) => format!("{}/{}", org.name, project.name), + (Some(org), None) => org.name.clone(), + (None, _) => "cross-org mode".to_string(), } - - (2u32..) - .map(|idx| format!("{base_name}-{idx}")) - .find(|candidate| !store.profiles.contains_key(candidate)) - .expect("profile name sequence is infinite") } -#[cfg(test)] -fn resolve_api_key_login_profile_name( - explicit_profile: Option<&str>, - suggested_org_name: Option<&str>, - selected_api_url: &str, - store: &AuthStore, -) -> Result<(String, bool)> { - if let Some(profile_name) = explicit_profile { - let profile_name = resolve_profile_name(Some(profile_name), suggested_org_name)?; - let should_confirm_overwrite = store.profiles.get(&profile_name).is_some_and(|profile| { - !profile_matches_api_key_login_target(profile, selected_api_url, suggested_org_name) - }); - return Ok((profile_name.clone(), should_confirm_overwrite)); - } - - let default_name = default_profile_name(suggested_org_name); - let has_matching_api_key_profile = store.profiles.values().any(|profile| { - profile.auth_kind == AuthKind::ApiKey - && profile.api_url.as_deref() == Some(selected_api_url) - && profile.org_name.as_deref() == suggested_org_name - }); - - if has_matching_api_key_profile { - return Ok((next_available_profile_name(&default_name, store), false)); - } +async fn resolve_post_login_project( + base: &BaseArgs, + credential: &str, + api_url: &str, + app_url: &str, + selected_org: Option<&LoginOrgInfo>, +) -> Result> { + let Some(project_name) = config::trimmed_option(base.project.as_deref()) else { + return Ok(None); + }; - Ok(( - default_name.clone(), - store.profiles.contains_key(&default_name), - )) -} - -#[cfg(test)] -fn resolve_oauth_login_profile_name( - explicit_profile: Option<&str>, - suggested_org_name: Option<&str>, - selected_api_url: &str, - app_url: &str, - jwt_id: &JwtIdentity, - store: &AuthStore, -) -> Result<(String, bool)> { - if let Some(profile_name) = explicit_profile { - let profile_name = resolve_profile_name(Some(profile_name), suggested_org_name)?; - let should_confirm_overwrite = store.profiles.get(&profile_name).is_some_and(|profile| { - !profile_matches_oauth_login_target( - profile, - selected_api_url, - app_url, - suggested_org_name, - jwt_id, - ) - }); - return Ok((profile_name.clone(), should_confirm_overwrite)); - } - - let matched_profile = store - .profiles - .iter() - .filter(|(_, profile)| { - profile_matches_oauth_login_target( - profile, - selected_api_url, - app_url, - suggested_org_name, - jwt_id, - ) - }) - .max_by(|(left_name, left), (right_name, right)| { - left.oauth_access_expires_at - .unwrap_or_default() - .cmp(&right.oauth_access_expires_at.unwrap_or_default()) - .then_with(|| left_name.cmp(right_name)) - }) - .map(|(name, _)| name.clone()); - - if let Some(profile_name) = matched_profile { - return Ok((profile_name, false)); - } - - let default_name = default_profile_name(suggested_org_name); - Ok(( - default_name.clone(), - store.profiles.contains_key(&default_name), - )) -} - -#[cfg(test)] -fn profile_matches_api_key_login_target( - profile: &AuthProfile, - selected_api_url: &str, - suggested_org_name: Option<&str>, -) -> bool { - profile.auth_kind == AuthKind::ApiKey - && profile.api_url.as_deref() == Some(selected_api_url) - && profile.org_name.as_deref() == suggested_org_name -} - -#[cfg(test)] -fn profile_matches_oauth_login_target( - profile: &AuthProfile, - selected_api_url: &str, - app_url: &str, - suggested_org_name: Option<&str>, - jwt_id: &JwtIdentity, -) -> bool { - profile.auth_kind == AuthKind::Oauth - && profile.api_url.as_deref() == Some(selected_api_url) - && profile.app_url.as_deref() == Some(app_url) - && profile.org_name.as_deref() == suggested_org_name - && profile.user_name == jwt_id.name - && profile.email == jwt_id.email -} - -#[cfg(test)] -#[allow(dead_code)] -fn confirm_profile_overwrite(profile_name: &str) -> Result<()> { - let store = load_auth_store()?; - if !store.profiles.contains_key(profile_name) { - return Ok(()); - } - let Some(term) = ui::prompt_term() else { - return Ok(()); - }; - let confirmed = Confirm::new() - .with_prompt(format!( - "Profile '{profile_name}' already exists. Overwrite?" - )) - .default(false) - .interact_on(&term)?; - if !confirmed { - bail!("login cancelled"); - } - Ok(()) -} - -fn format_login_success(selected_org: Option<&LoginOrgInfo>, api_url: &str) -> String { - match selected_org { - Some(org) => format!("Logged in as {} (api: {api_url})", org.name), - None => format!("Logged in (cross-org, api: {api_url})"), - } -} - -fn build_login_context_for_selected_org( - credential: &str, - api_url: &str, - app_url: &str, - selected_org: Option<&LoginOrgInfo>, -) -> LoginContext { - let login = LoginState::new(); - let _ = login.set( - credential.to_string(), - selected_org.map(|org| org.id.clone()).unwrap_or_default(), - selected_org.map(|org| org.name.clone()).unwrap_or_default(), - api_url.to_string(), - app_url.to_string(), - ); - LoginContext { - login, - api_url: api_url.to_string(), - app_url: app_url.to_string(), - } -} - -fn format_post_login_context( - selected_org: Option<&LoginOrgInfo>, - project: Option<&api::Project>, -) -> String { - match (selected_org, project) { - (Some(org), Some(project)) => format!("{}/{}", org.name, project.name), - (Some(org), None) => org.name.clone(), - (None, _) => "cross-org mode".to_string(), - } -} - -async fn resolve_post_login_project( - base: &BaseArgs, - credential: &str, - api_url: &str, - app_url: &str, - selected_org: Option<&LoginOrgInfo>, -) -> Result> { - let Some(project_name) = config::trimmed_option(base.project.as_deref()) else { - return Ok(None); - }; - - let selected_org = selected_org.ok_or_else(|| { - anyhow::anyhow!( - "cannot set a default project in cross-org mode; rerun `bt auth login --org --project `" - ) - })?; - let ctx = - build_login_context_for_selected_org(credential, api_url, app_url, Some(selected_org)); - let client = ApiClient::new(&ctx)?; - switch::validate_or_create_project(&client, project_name) - .await - .map(Some) + let selected_org = selected_org.ok_or_else(|| { + anyhow::anyhow!( + "cannot set a default project in cross-org mode; rerun `bt auth login --org --project `" + ) + })?; + let ctx = + build_login_context_for_selected_org(credential, api_url, app_url, Some(selected_org)); + let client = ApiClient::new(&ctx)?; + switch::validate_or_create_project(&client, project_name) + .await + .map(Some) } async fn persist_post_login_context( @@ -2331,7 +1803,6 @@ async fn persist_post_login_context( let mut cfg = config::load_file(&path); switch::apply_switch_config( &mut cfg, - None, selected_org.map(|org| org.name.as_str()), project.as_ref(), ); @@ -2700,8 +2171,8 @@ fn print_saved_profiles(store: &AuthStore, json: bool) -> Result<()> { if json { let output: Vec = store .profiles - .iter() - .map(|(_name, p)| { + .values() + .map(|p| { serde_json::json!({ "auth": auth_kind_label(p.auth_kind), "org": p.org_name, @@ -4283,9 +3754,10 @@ mod tests { quiet_source: None, no_color: false, no_input: false, - profile: None, project: None, + project_source: None, org_name: None, + org_name_source: None, api_key: None, api_key_source: None, prefer_api_key: false, @@ -4296,9 +3768,8 @@ mod tests { } } - fn auth_config(profile: Option<&str>, org: Option<&str>) -> crate::config::Config { + fn auth_config(org: Option<&str>) -> crate::config::Config { crate::config::Config { - profile: profile.map(str::to_string), org: org.map(str::to_string), ..Default::default() } @@ -4936,233 +4407,16 @@ mod tests { assert_eq!(profile.legacy_secret_key.as_deref(), Some("new")); } - #[test] - fn resolve_auth_uses_profile_when_no_api_key_override() { - let mut base = make_base(); - base.profile = Some("work".to_string()); - - let mut store = AuthStore::default(); - store.profiles.insert( - "work".to_string(), - AuthProfile { - auth_kind: AuthKind::ApiKey, - api_url: Some("https://api.example.com".to_string()), - app_url: Some("https://www.example.com".to_string()), - org_name: Some("Example Org".to_string()), - oauth_client_id: None, - oauth_access_expires_at: None, - ..Default::default() - }, - ); - - let resolved = resolve_auth_from_store_with_secret_lookup( - &base, - &store, - |_| Ok(Some("profile-key".to_string())), - &None, - ) - .expect("resolve"); - assert_eq!(resolved.api_key.as_deref(), Some("profile-key")); - assert_eq!(resolved.api_url.as_deref(), Some("https://api.example.com")); - assert_eq!(resolved.org_name.as_deref(), Some("Example Org")); - assert!(!resolved.is_oauth); - } - #[test] fn config_auth_context_returns_config_org() { let base = make_base(); - let cfg = auth_config(Some("default-profile"), Some("local-org")); + let cfg = auth_config(Some("local-org")); let org = config_auth_context_from_config(&base, &cfg); assert_eq!(org.as_deref(), Some("local-org")); } - #[test] - fn config_auth_context_ignores_legacy_profile_and_preserves_config_org() { - let mut base = make_base(); - base.profile = Some("explicit-profile".to_string()); - let cfg = auth_config(Some("config-profile"), Some("local-org")); - - let org = config_auth_context_from_config(&base, &cfg); - - assert_eq!(org.as_deref(), Some("local-org")); - } - - #[test] - fn resolve_auth_prefers_explicit_api_key() { - let mut base = make_base(); - base.api_key = Some("explicit-key".to_string()); - base.api_url = Some("https://override.example.com".to_string()); - - let mut store = AuthStore::default(); - store.profiles.insert( - "work".to_string(), - AuthProfile { - auth_kind: AuthKind::ApiKey, - api_url: Some("https://api.example.com".to_string()), - app_url: None, - org_name: None, - oauth_client_id: None, - oauth_access_expires_at: None, - ..Default::default() - }, - ); - - let resolved = resolve_auth_from_store_with_secret_lookup( - &base, - &store, - |_| Ok(Some("profile-key".to_string())), - &None, - ) - .expect("resolve"); - assert_eq!(resolved.api_key.as_deref(), Some("explicit-key")); - assert_eq!( - resolved.api_url.as_deref(), - Some("https://override.example.com") - ); - assert!(!resolved.is_oauth); - } - - #[test] - fn resolve_auth_prefer_api_key_uses_api_key_override() { - let mut base = make_base(); - base.api_key = Some("explicit-key".to_string()); - base.prefer_api_key = true; - base.profile = Some("work".to_string()); - - let mut store = AuthStore::default(); - store.profiles.insert( - "work".to_string(), - AuthProfile { - auth_kind: AuthKind::ApiKey, - api_url: Some("https://api.example.com".to_string()), - app_url: None, - org_name: Some("Example Org".to_string()), - oauth_client_id: None, - oauth_access_expires_at: None, - ..Default::default() - }, - ); - - let resolved = resolve_auth_from_store_with_secret_lookup( - &base, - &store, - |_| Ok(Some("profile-key".to_string())), - &None, - ) - .expect("resolve"); - assert_eq!(resolved.api_key.as_deref(), Some("explicit-key")); - assert_eq!(resolved.org_name, None); - } - - #[test] - fn resolve_auth_prefers_cli_api_key_even_with_prefer_api_key() { - let mut base = make_base(); - base.api_key = Some("explicit-key".to_string()); - base.api_key_source = Some(crate::args::ArgValueSource::CommandLine); - base.prefer_api_key = true; - base.profile = Some("work".to_string()); - - let mut store = AuthStore::default(); - store.profiles.insert( - "work".to_string(), - AuthProfile { - auth_kind: AuthKind::ApiKey, - api_url: Some("https://api.example.com".to_string()), - app_url: None, - org_name: Some("Example Org".to_string()), - oauth_client_id: None, - oauth_access_expires_at: None, - ..Default::default() - }, - ); - - let resolved = resolve_auth_from_store_with_secret_lookup( - &base, - &store, - |_| Ok(Some("profile-key".to_string())), - &None, - ) - .expect("resolve"); - assert_eq!(resolved.api_key.as_deref(), Some("explicit-key")); - assert_eq!(resolved.org_name, None); - } - - #[test] - fn resolve_auth_legacy_explicit_profile_does_not_override_api_key() { - let mut base = make_base(); - base.api_key = Some("explicit-key".to_string()); - base.profile = Some("work".to_string()); - - let mut store = AuthStore::default(); - store.profiles.insert( - "work".to_string(), - AuthProfile { - auth_kind: AuthKind::ApiKey, - api_url: Some("https://api.example.com".to_string()), - app_url: None, - org_name: Some("Example Org".to_string()), - oauth_client_id: None, - oauth_access_expires_at: None, - ..Default::default() - }, - ); - - let resolved = resolve_auth_from_store_with_secret_lookup( - &base, - &store, - |_| Ok(Some("profile-key".to_string())), - &None, - ) - .expect("resolve"); - assert_eq!(resolved.api_key.as_deref(), Some("explicit-key")); - assert_eq!(resolved.org_name, None); - } - - #[test] - fn resolve_auth_marks_oauth_profiles() { - let mut base = make_base(); - base.profile = Some("work".to_string()); - - let mut store = AuthStore::default(); - store.profiles.insert( - "work".to_string(), - AuthProfile { - auth_kind: AuthKind::Oauth, - api_url: Some("https://api.example.com".to_string()), - app_url: Some("https://www.example.com".to_string()), - org_name: Some("Example Org".to_string()), - oauth_client_id: Some("bt_cli_work".to_string()), - oauth_access_expires_at: None, - ..Default::default() - }, - ); - - let resolved = resolve_auth_from_store_with_secret_lookup( - &base, - &store, - |_| Ok(Some("should-not-be-used".to_string())), - &None, - ) - .expect("resolve"); - - assert!(resolved.is_oauth); - assert_eq!(resolved.api_key, None); - assert_eq!(resolved.org_name.as_deref(), Some("Example Org")); - } - - #[test] - fn refresh_profile_selector_prefers_explicit_profile() { - let mut base = make_base(); - base.profile = Some(" work ".to_string()); - let store = AuthStore::default(); - let (profile_name, source) = - resolve_selected_profile_name_for_debug(&base, &store).expect("resolve"); - assert_eq!(profile_name, "work"); - assert_eq!(source, "legacy profile"); - } - #[test] fn parse_oauth_callback_input_accepts_json_payload() { let parsed = @@ -5191,388 +4445,6 @@ mod tests { ); } - #[test] - fn resolve_profile_for_org_exact_profile_name() { - let mut store = AuthStore::default(); - store.profiles.insert( - "acme".into(), - AuthProfile { - org_name: Some("acme-corp".into()), - ..Default::default() - }, - ); - assert_eq!(resolve_profile_for_org("acme", &store), Some("acme")); - } - - #[test] - fn resolve_profile_for_org_by_org_name() { - let mut store = AuthStore::default(); - store.profiles.insert( - "work".into(), - AuthProfile { - org_name: Some("acme-corp".into()), - ..Default::default() - }, - ); - assert_eq!(resolve_profile_for_org("acme-corp", &store), Some("work")); - } - - #[test] - fn resolve_profile_for_org_no_match() { - let mut store = AuthStore::default(); - store.profiles.insert( - "work".into(), - AuthProfile { - org_name: Some("acme-corp".into()), - ..Default::default() - }, - ); - assert_eq!(resolve_profile_for_org("unknown", &store), None); - } - - #[test] - fn resolve_profile_for_org_multiple_returns_none() { - let mut store = AuthStore::default(); - store.profiles.insert( - "work-1".into(), - AuthProfile { - org_name: Some("acme".into()), - ..Default::default() - }, - ); - store.profiles.insert( - "work-2".into(), - AuthProfile { - org_name: Some("acme".into()), - ..Default::default() - }, - ); - assert_eq!(resolve_profile_for_org("acme", &store), None); - } - - #[test] - fn profile_selection_requires_choice_when_multiple_profiles_without_prompt() { - let base = make_base(); - let mut store = AuthStore::default(); - store.profiles.insert( - "alpha".into(), - AuthProfile { - org_name: Some("alpha-org".into()), - ..Default::default() - }, - ); - store.profiles.insert( - "beta".into(), - AuthProfile { - org_name: Some("beta-org".into()), - ..Default::default() - }, - ); - - let err = maybe_select_profile_for_auth(&base, &store, &None, false) - .expect_err("selection should be required"); - - assert!(err.to_string().contains("multiple auth logins available")); - assert!(err.to_string().contains("alpha")); - assert!(err.to_string().contains("beta")); - assert!(err.to_string().contains("--org ")); - } - - #[test] - fn profile_selection_requires_choice_for_ambiguous_org_without_prompt() { - let mut base = make_base(); - base.org_name = Some("acme".into()); - - let mut store = AuthStore::default(); - store.profiles.insert( - "work-1".into(), - AuthProfile { - org_name: Some("acme".into()), - ..Default::default() - }, - ); - store.profiles.insert( - "work-2".into(), - AuthProfile { - org_name: Some("acme".into()), - ..Default::default() - }, - ); - - let err = maybe_select_profile_for_auth(&base, &store, &None, false) - .expect_err("org selection should be required"); - - assert!(err - .to_string() - .contains("multiple auth logins for org 'acme'")); - assert!(err.to_string().contains("work-1")); - assert!(err.to_string().contains("work-2")); - } - - #[test] - fn profile_selection_skips_when_api_key_override_is_active() { - let mut base = make_base(); - base.api_key = Some("explicit-key".into()); - - let mut store = AuthStore::default(); - store - .profiles - .insert("alpha".into(), AuthProfile::default()); - store.profiles.insert("beta".into(), AuthProfile::default()); - - let selection = maybe_select_profile_for_auth(&base, &store, &None, false) - .expect("api key override should skip profile selection"); - - assert_eq!(selection, None); - } - - #[test] - fn resolve_auth_uses_org_to_find_profile() { - let mut base = make_base(); - base.org_name = Some("acme-corp".into()); - - let mut store = AuthStore::default(); - store.profiles.insert( - "work".into(), - AuthProfile { - org_name: Some("acme-corp".into()), - api_url: Some("https://api.acme.com".into()), - ..Default::default() - }, - ); - - let resolved = resolve_auth_from_store_with_secret_lookup( - &base, - &store, - |_| Ok(Some("profile-key".into())), - &None, - ) - .expect("resolve"); - assert_eq!(resolved.api_key.as_deref(), Some("profile-key")); - assert_eq!(resolved.org_name.as_deref(), Some("acme-corp")); - } - - #[test] - fn resolve_auth_uses_config_org_to_find_profile() { - let base = make_base(); - - let mut store = AuthStore::default(); - store.profiles.insert( - "work".into(), - AuthProfile { - org_name: Some("acme-corp".into()), - api_url: Some("https://api.acme.com".into()), - ..Default::default() - }, - ); - let cfg_org = Some("acme-corp".to_string()); - - let resolved = resolve_auth_from_store_with_secret_lookup( - &base, - &store, - |_| Ok(Some("profile-key".into())), - &cfg_org, - ) - .expect("resolve"); - assert_eq!(resolved.api_key.as_deref(), Some("profile-key")); - assert_eq!(resolved.org_name.as_deref(), Some("acme-corp")); - } - - #[test] - fn resolve_auth_config_org_overrides_profile_org() { - let mut base = make_base(); - base.profile = Some("default-profile".to_string()); - - let mut store = AuthStore::default(); - store.profiles.insert( - "default-profile".into(), - AuthProfile { - org_name: Some("profile-org".into()), - ..Default::default() - }, - ); - let cfg_org = Some("local-org".to_string()); - - let resolved = resolve_auth_from_store_with_secret_lookup( - &base, - &store, - |_| Ok(Some("profile-key".into())), - &cfg_org, - ) - .expect("resolve"); - assert_eq!(resolved.api_key.as_deref(), Some("profile-key")); - assert_eq!(resolved.org_name.as_deref(), Some("local-org")); - } - - #[test] - fn resolve_auth_api_key_override_keeps_config_org() { - let mut base = make_base(); - base.api_key = Some("explicit-key".into()); - - let store = AuthStore::default(); - let cfg_org = Some("local-org".to_string()); - - let resolved = - resolve_auth_from_store_with_secret_lookup(&base, &store, |_| Ok(None), &cfg_org) - .expect("resolve"); - - assert_eq!(resolved.api_key.as_deref(), Some("explicit-key")); - assert_eq!(resolved.org_name.as_deref(), Some("local-org")); - } - - #[test] - fn resolve_auth_explicit_profile_overrides_org_resolution() { - let mut base = make_base(); - base.profile = Some("other".into()); - base.org_name = Some("acme-corp".into()); - - let mut store = AuthStore::default(); - store.profiles.insert( - "work".into(), - AuthProfile { - org_name: Some("acme-corp".into()), - ..Default::default() - }, - ); - store.profiles.insert( - "other".into(), - AuthProfile { - org_name: Some("other-org".into()), - api_url: Some("https://api.other.com".into()), - ..Default::default() - }, - ); - - let resolved = resolve_auth_from_store_with_secret_lookup( - &base, - &store, - |_| Ok(Some("other-key".into())), - &None, - ) - .expect("resolve"); - assert_eq!(resolved.api_key.as_deref(), Some("other-key")); - assert_eq!(resolved.org_name.as_deref(), Some("acme-corp")); - } - - #[test] - fn resolve_api_key_login_profile_name_creates_new_profile_for_matching_org() { - let mut store = AuthStore::default(); - store.profiles.insert( - "acme".into(), - AuthProfile { - auth_kind: AuthKind::ApiKey, - api_url: Some("https://api.acme.example".into()), - org_name: Some("acme".into()), - ..Default::default() - }, - ); - - let (profile_name, should_confirm) = resolve_api_key_login_profile_name( - None, - Some("acme"), - "https://api.acme.example", - &store, - ) - .expect("resolve"); - - assert_eq!(profile_name, "acme-2"); - assert!(!should_confirm); - } - - #[test] - fn resolve_api_key_login_profile_name_updates_explicit_matching_profile_without_confirm() { - let mut store = AuthStore::default(); - store.profiles.insert( - "work".into(), - AuthProfile { - auth_kind: AuthKind::ApiKey, - api_url: Some("https://api.test.example".into()), - org_name: Some("test-org".into()), - ..Default::default() - }, - ); - - let (profile_name, should_confirm) = resolve_api_key_login_profile_name( - Some("work"), - Some("test-org"), - "https://api.test.example", - &store, - ) - .expect("resolve"); - - assert_eq!(profile_name, "work"); - assert!(!should_confirm); - } - - #[test] - fn resolve_api_key_login_profile_name_confirms_explicit_different_target() { - let mut store = AuthStore::default(); - store.profiles.insert( - "work".into(), - AuthProfile { - auth_kind: AuthKind::ApiKey, - api_url: Some("https://api.test.example".into()), - org_name: Some("test-org".into()), - ..Default::default() - }, - ); - - let (profile_name, should_confirm) = resolve_api_key_login_profile_name( - Some("work"), - Some("other-org"), - "https://api.test.example", - &store, - ) - .expect("resolve"); - - assert_eq!(profile_name, "work"); - assert!(should_confirm); - } - - #[test] - fn default_login_org_name_uses_profile_org_when_org_not_requested() { - let mut store = AuthStore::default(); - store.profiles.insert( - "work".into(), - AuthProfile { - org_name: Some("acme".into()), - ..Default::default() - }, - ); - - assert_eq!( - default_login_org_name(&store, Some(" work "), None).as_deref(), - Some("acme") - ); - } - - #[test] - fn default_login_org_name_falls_back_to_profile_name() { - let store = AuthStore::default(); - - assert_eq!( - default_login_org_name(&store, Some(" acme "), None).as_deref(), - Some("acme") - ); - } - - #[test] - fn default_login_org_name_ignores_profile_when_org_requested() { - let mut store = AuthStore::default(); - store.profiles.insert( - "work".into(), - AuthProfile { - org_name: Some("acme".into()), - ..Default::default() - }, - ); - - assert_eq!( - default_login_org_name(&store, Some("work"), Some("other")), - None - ); - } - #[test] fn move_default_login_org_first_moves_matching_org() { let mut orgs = vec![ @@ -5595,122 +4467,6 @@ mod tests { assert_eq!(orgs[1].name, "beta"); } - #[test] - fn resolve_oauth_login_profile_name_reuses_most_recent_matching_profile() { - let mut store = AuthStore::default(); - store.profiles.insert( - "older".into(), - AuthProfile { - auth_kind: AuthKind::Oauth, - api_url: Some("https://api.acme.example".into()), - app_url: Some("https://www.acme.example".into()), - org_name: Some("acme".into()), - oauth_access_expires_at: Some(100), - user_name: Some("Alice".into()), - email: Some("alice@example.com".into()), - ..Default::default() - }, - ); - store.profiles.insert( - "newer".into(), - AuthProfile { - auth_kind: AuthKind::Oauth, - api_url: Some("https://api.acme.example".into()), - app_url: Some("https://www.acme.example".into()), - org_name: Some("acme".into()), - oauth_access_expires_at: Some(200), - user_name: Some("Alice".into()), - email: Some("alice@example.com".into()), - ..Default::default() - }, - ); - - let jwt_id = JwtIdentity { - name: Some("Alice".into()), - email: Some("alice@example.com".into()), - }; - let (profile_name, should_confirm) = resolve_oauth_login_profile_name( - None, - Some("acme"), - "https://api.acme.example", - "https://www.acme.example", - &jwt_id, - &store, - ) - .expect("resolve"); - - assert_eq!(profile_name, "newer"); - assert!(!should_confirm); - } - - #[test] - fn resolve_oauth_login_profile_name_updates_explicit_matching_profile_without_confirm() { - let mut store = AuthStore::default(); - store.profiles.insert( - "work".into(), - AuthProfile { - auth_kind: AuthKind::Oauth, - api_url: Some("https://api.test.example".into()), - app_url: Some("https://app.test.example".into()), - org_name: Some("test-org".into()), - user_name: Some("Test User".into()), - email: Some("user@test.example".into()), - ..Default::default() - }, - ); - let jwt_id = JwtIdentity { - name: Some("Test User".into()), - email: Some("user@test.example".into()), - }; - - let (profile_name, should_confirm) = resolve_oauth_login_profile_name( - Some("work"), - Some("test-org"), - "https://api.test.example", - "https://app.test.example", - &jwt_id, - &store, - ) - .expect("resolve"); - - assert_eq!(profile_name, "work"); - assert!(!should_confirm); - } - - #[test] - fn resolve_oauth_login_profile_name_confirms_explicit_different_target() { - let mut store = AuthStore::default(); - store.profiles.insert( - "work".into(), - AuthProfile { - auth_kind: AuthKind::Oauth, - api_url: Some("https://api.test.example".into()), - app_url: Some("https://app.test.example".into()), - org_name: Some("test-org".into()), - user_name: Some("Test User".into()), - email: Some("user@test.example".into()), - ..Default::default() - }, - ); - let jwt_id = JwtIdentity { - name: Some("Test User".into()), - email: Some("user@test.example".into()), - }; - - let (profile_name, should_confirm) = resolve_oauth_login_profile_name( - Some("work"), - Some("other-org"), - "https://api.test.example", - "https://app.test.example", - &jwt_id, - &store, - ) - .expect("resolve"); - - assert_eq!(profile_name, "work"); - assert!(should_confirm); - } - fn login_org(id: &str, name: &str) -> LoginOrgInfo { LoginOrgInfo { id: id.to_string(), @@ -5723,7 +4479,6 @@ mod tests { async fn persist_post_login_context_clears_stale_project_for_org_only_login() { let _env = TestEnv::new(None, None).await; crate::config::save_global(&crate::config::Config { - profile: Some("old-profile".to_string()), org: Some("old-org".to_string()), project: Some("stale-project".to_string()), project_id: Some("proj_stale".to_string()), @@ -5743,7 +4498,6 @@ mod tests { let cfg = crate::config::load_global().expect("load global config"); assert_eq!(update.display, "acme"); - assert_eq!(cfg.profile, None); assert_eq!(cfg.org.as_deref(), Some("acme")); assert_eq!(cfg.project, None); assert_eq!(cfg.project_id, None); diff --git a/src/config/mod.rs b/src/config/mod.rs index c6b51604..b6dc85fb 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -18,8 +18,6 @@ mod set; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(default)] pub struct Config { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub profile: Option, pub org: Option, pub project: Option, pub project_id: Option, @@ -82,7 +80,6 @@ impl Config { self.project_id.clone() }; Config { - profile: None, org: other.org.clone().or_else(|| self.org.clone()), project, project_id, @@ -128,9 +125,7 @@ pub fn load_file(path: &Path) -> Config { } }; - // Legacy config files may contain `profile`; profiles are no longer a - // user-facing selector, so ignore it rather than warning or preserving it. - config.profile = None; + config.extra.remove("profile"); for key in config.extra.keys() { print_command_status( @@ -427,7 +422,7 @@ mod tests { assert_eq!(merged.project, Some("other-proj".into())); } - fn base_with_profile(profile: Option<&str>) -> BaseArgs { + fn base_args() -> BaseArgs { BaseArgs { json: false, verbose: false, @@ -436,9 +431,10 @@ mod tests { quiet_source: None, no_color: false, no_input: false, - profile: profile.map(str::to_string), org_name: None, + org_name_source: None, project: None, + project_source: None, api_key: None, api_key_source: None, prefer_api_key: false, @@ -449,9 +445,8 @@ mod tests { } } - fn config(profile: Option<&str>, org: Option<&str>, project: Option<&str>) -> Config { + fn config(org: Option<&str>, project: Option<&str>) -> Config { Config { - profile: profile.map(str::to_string), org: org.map(str::to_string), project: project.map(str::to_string), ..Default::default() @@ -459,20 +454,12 @@ mod tests { } #[test] - fn project_config_matches_org_and_ignores_legacy_profile() { - let base = base_with_profile(Some("work")); + fn project_config_matches_org() { + let base = base_args(); let cases = [ - (config(None, Some("acme"), Some("demo")), Some("demo")), - (config(None, Some("other"), Some("demo")), None), - (config(None, None, Some("demo")), Some("demo")), - ( - config(Some("other"), Some("acme"), Some("demo")), - Some("demo"), - ), - ( - config(Some("work"), Some("acme"), Some("demo")), - Some("demo"), - ), + (config(Some("acme"), Some("demo")), Some("demo")), + (config(Some("other"), Some("demo")), None), + (config(None, Some("demo")), Some("demo")), ]; for (cfg, expected) in cases { diff --git a/src/datasets/pipeline.rs b/src/datasets/pipeline.rs index ae8bbd0f..cd726927 100644 --- a/src/datasets/pipeline.rs +++ b/src/datasets/pipeline.rs @@ -2073,9 +2073,10 @@ mod tests { quiet_source: None, no_color: false, no_input: false, - profile: None, org_name: None, + org_name_source: None, project: None, + project_source: None, api_key: None, api_key_source: None, prefer_api_key: false, diff --git a/src/experiments/mod.rs b/src/experiments/mod.rs index a3eaabbb..341ec84d 100644 --- a/src/experiments/mod.rs +++ b/src/experiments/mod.rs @@ -219,17 +219,12 @@ fn apply_experiment_url_hints_to_base( } } - let has_profile_override = base - .profile - .as_deref() - .map(str::trim) - .is_some_and(|v| !v.is_empty()); let has_org_override = base .org_name .as_deref() .map(str::trim) .is_some_and(|v| !v.is_empty()); - if !has_profile_override && !has_org_override { + if !has_org_override { if let Some(org) = parsed_url .org .as_deref() diff --git a/src/functions/push.rs b/src/functions/push.rs index d3771ab2..b4d1f1fb 100644 --- a/src/functions/push.rs +++ b/src/functions/push.rs @@ -4063,9 +4063,10 @@ mod tests { quiet_source: None, no_color: false, no_input: false, - profile: None, org_name: None, + org_name_source: None, project: None, + project_source: None, api_key: None, api_key_source: None, prefer_api_key: false, diff --git a/src/main.rs b/src/main.rs index 9a7a5f12..009cc335 100644 --- a/src/main.rs +++ b/src/main.rs @@ -346,6 +346,8 @@ fn try_main() -> Result<()> { fn apply_base_arg_sources(matches: &ArgMatches, base: &mut BaseArgs) { base.verbose_source = find_value_source(matches, "verbose").and_then(map_value_source); base.quiet_source = find_value_source(matches, "quiet").and_then(map_value_source); + base.org_name_source = find_value_source(matches, "org_name").and_then(map_value_source); + base.project_source = find_value_source(matches, "project").and_then(map_value_source); base.api_key_source = find_value_source(matches, "api_key").and_then(map_value_source); } @@ -546,6 +548,32 @@ mod tests { ); } + #[test] + fn apply_base_arg_sources_tracks_cli_org_and_project() { + let matches = Cli::command() + .try_get_matches_from([ + "bt", + "status", + "--org", + "test-org", + "--project", + "test-project", + ]) + .expect("matches"); + let mut cli = Cli::from_arg_matches(&matches).expect("cli"); + + apply_base_arg_sources(&matches, cli.command.base_mut()); + + assert_eq!( + cli.command.base().org_name_source, + Some(ArgValueSource::CommandLine) + ); + assert_eq!( + cli.command.base().project_source, + Some(ArgValueSource::CommandLine) + ); + } + #[test] fn apply_base_arg_sources_leaves_api_key_source_empty_when_unset() { let _guard = env_test_lock().lock().expect("env test lock"); diff --git a/src/setup/mod.rs b/src/setup/mod.rs index 9d23a4f3..b98e79f4 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -1401,7 +1401,6 @@ async fn poll_setup_wizard_completion( async fn run_setup_browser_auth( base: &mut BaseArgs, - profile_name: Option<&str>, _project_name: Option<&str>, _project_was_explicit: bool, _requested_org: Option<&str>, @@ -1467,7 +1466,6 @@ async fn run_setup_browser_auth( org_id: completed.org_id.clone(), description: None, }; - let _ = profile_name; auth::commit_api_key_profile( &completed.api_key, login.api_url.clone(), @@ -1489,97 +1487,43 @@ async fn run_setup_browser_auth( }) } -#[cfg(test)] -fn setup_browser_profile_name( - selected_profile_name: Option<&str>, - org_name: &str, - profiles: &[auth::ProfileInfo], -) -> String { - if let Some(profile_name) = selected_profile_name +fn saved_auth_orgs(profiles: &[auth::ProfileInfo]) -> Vec { + let mut orgs = profiles + .iter() + .filter_map(|profile| profile.org_name.as_deref()) .map(str::trim) - .filter(|value| !value.is_empty()) - { - return profile_name.to_string(); - } - - let base_name = if org_name.trim().is_empty() { - "default" - } else { - org_name.trim() - }; - if !profiles.iter().any(|profile| profile.name == base_name) { - return base_name.to_string(); - } - - (2u32..) - .map(|idx| format!("{base_name}-{idx}")) - .find(|candidate| !profiles.iter().any(|profile| profile.name == *candidate)) - .expect("profile name sequence is infinite") + .filter(|org| !org.is_empty()) + .map(str::to_string) + .collect::>(); + orgs.sort(); + orgs.dedup(); + orgs } -fn resolve_profile_name_for_setup( +fn resolve_org_name_for_setup( base: &BaseArgs, profiles: &[auth::ProfileInfo], prompt_for_choice: bool, ) -> Result> { - if let Some(profile_name) = base - .profile + if let Some(org_name) = base + .org_name .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { - if profiles.iter().any(|profile| profile.name == profile_name) { - return Ok(Some(profile_name.to_string())); - } - bail!( - "auth login '{profile_name}' not found; run `bt auth profiles` to see available logins" - ); + return Ok(Some(org_name.to_string())); } - if let Some(org_name) = base.org_name.as_deref() { - if let Some(profile_name) = profiles - .iter() - .find(|profile| profile.name == org_name) - .map(|profile| profile.name.clone()) - { - return Ok(Some(profile_name)); + let orgs = saved_auth_orgs(profiles); + match orgs.len() { + 0 => Ok(None), + 1 => Ok(orgs.into_iter().next()), + _ if prompt_for_choice => { + let labels = orgs.iter().map(String::as_str).collect::>(); + let idx = ui::fuzzy_select("Select organization", &labels, 0)?; + Ok(Some(orgs[idx].clone())) } - - let mut matches = profiles - .iter() - .filter(|profile| profile.org_name.as_deref() == Some(org_name)) - .collect::>(); - matches.sort_by(|left, right| left.name.cmp(&right.name)); - - return match matches.len() { - 0 => Ok(None), - 1 => Ok(Some(matches[0].name.clone())), - _ if prompt_for_choice => auth::select_profile_interactive(Some(org_name))? - .map(Some) - .ok_or_else(|| anyhow!("no auth login selected")), - _ => { - let labels = matches - .iter() - .map(|profile| auth::profile_info_label(profile)) - .collect::>() - .join(", "); - bail!( - "multiple auth logins for org '{org_name}': {labels}. Rerun interactively or remove one with `bt auth logout`." - ) - } - }; - } - - if profiles.len() == 1 { - return Ok(Some(profiles[0].name.clone())); - } - - if prompt_for_choice && !profiles.is_empty() { - auth::select_profile_interactive(None)? - .map(Some) - .ok_or_else(|| anyhow!("no auth login selected")) - } else { - Ok(None) + _ => Ok(None), } } @@ -1729,64 +1673,60 @@ fn select_api_key_org_for_setup( bail!("organization choice required in non-interactive mode; pass --org ") } -async fn ensure_profile_or_setup_browser_auth( +async fn ensure_org_or_setup_browser_auth( base: &mut BaseArgs, - prompt_for_profile_choice: bool, + prompt_for_org_choice: bool, project_name: Option<&str>, project_was_explicit: bool, requested_org: Option<&str>, ) -> Result { let profiles = auth::list_profiles()?; let can_prompt = setup_can_prompt(base); - let should_prompt_for_profile_choice = prompt_for_profile_choice && can_prompt; - let selected_profile = - resolve_profile_name_for_setup(base, &profiles, should_prompt_for_profile_choice)?; + let should_prompt_for_org_choice = prompt_for_org_choice && can_prompt; + let selected_org = resolve_org_name_for_setup(base, &profiles, should_prompt_for_org_choice)?; let mut auth_base = base.clone(); auth_base.api_key = None; auth_base.api_key_source = None; - if let Some(profile_name) = selected_profile { - auth_base.profile = Some(profile_name.clone()); - if auth_base.org_name.is_none() { - auth_base.org_name = profiles - .iter() - .find(|profile| profile.name == profile_name) - .and_then(|profile| profile.org_name.clone()); - } - - match auth::login(&auth_base).await { - Ok(ctx) => { - base.profile = auth_base.profile.clone(); - base.org_name = auth_base.org_name.clone(); - let is_oauth = auth::resolve_auth(&auth_base).await?.is_oauth; - return Ok(SetupAuthLogin { - login: ctx, - is_oauth, - selected_project: None, - }); - } - Err(err) if auth::is_missing_credential_error(&err) => { - if base.verbose { - eprintln!( - " Auth login '{}' credentials inaccessible ({}). Re-authenticating in the browser...", - profile_name, err - ); + if let Some(org_name) = selected_org.as_deref() { + auth_base.org_name = Some(org_name.to_string()); + let has_saved_auth_for_org = profiles + .iter() + .any(|profile| profile.org_name.as_deref() == Some(org_name)); + + if has_saved_auth_for_org { + match auth::login(&auth_base).await { + Ok(ctx) => { + base.org_name = auth_base.org_name.clone(); + let is_oauth = auth::resolve_auth(&auth_base).await?.is_oauth; + return Ok(SetupAuthLogin { + login: ctx, + is_oauth, + selected_project: None, + }); } - if !can_prompt { - bail!( - "setup needs interactive browser authentication; rerun without --no-input/--json or pass a working API key" - ); + Err(err) if auth::is_missing_credential_error(&err) => { + if base.verbose { + eprintln!( + " Auth login for org '{}' has inaccessible credentials ({}). Re-authenticating in the browser...", + org_name, err + ); + } + if !can_prompt { + bail!( + "setup needs interactive browser authentication; rerun without --no-input/--json or pass a working API key" + ); + } + return run_setup_browser_auth( + base, + project_name, + project_was_explicit, + requested_org, + ) + .await; } - return run_setup_browser_auth( - base, - Some(&profile_name), - project_name, - project_was_explicit, - requested_org, - ) - .await; + Err(err) => return Err(err), } - Err(err) => return Err(err), } } @@ -1802,27 +1742,20 @@ async fn ensure_profile_or_setup_browser_auth( if base.verbose { eprintln!("Starting browser setup.\n"); } - run_setup_browser_auth( - base, - None, - project_name, - project_was_explicit, - requested_org, - ) - .await + run_setup_browser_auth(base, project_name, project_was_explicit, requested_org).await } -async fn ensure_profile_or_setup_browser_auth_context( +async fn ensure_org_or_setup_browser_auth_context( base: &mut BaseArgs, - prompt_for_profile_choice: bool, + prompt_for_org_choice: bool, needs_api_key: bool, project_name: Option<&str>, project_was_explicit: bool, requested_org: Option<&str>, ) -> Result { - let login = ensure_profile_or_setup_browser_auth( + let login = ensure_org_or_setup_browser_auth( base, - prompt_for_profile_choice, + prompt_for_org_choice, project_name, project_was_explicit, requested_org, @@ -1841,7 +1774,7 @@ async fn ensure_profile_or_setup_browser_auth_context( async fn ensure_setup_auth( base: &mut BaseArgs, - prompt_for_profile_choice: bool, + prompt_for_org_choice: bool, needs_api_key: bool, ) -> Result { let project_was_explicit = base @@ -1857,7 +1790,6 @@ async fn ensure_setup_auth( .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string); - let stored_profiles = auth::list_stored_profiles()?; let mut project_name = base .project .as_deref() @@ -1870,12 +1802,6 @@ async fn ensure_setup_auth( .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string); - let profile_name = base - .profile - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); if let Some(api_key) = explicit_api_key.as_deref() { let app_url = base @@ -1883,7 +1809,7 @@ async fn ensure_setup_auth( .clone() .unwrap_or_else(|| DEFAULT_APP_URL.to_string()); let available_orgs = list_available_orgs_for_setup(api_key, &app_url).await?; - if project_name.is_some() && org_name.is_none() && profile_name.is_none() { + if project_name.is_some() && org_name.is_none() { let name = project_name .as_deref() .expect("project name exists when probing all orgs"); @@ -1893,78 +1819,7 @@ async fn ensure_setup_auth( } } - if let Some(profile_name) = profile_name.as_deref() { - let profile = stored_profiles - .iter() - .find(|profile| profile.name == profile_name) - .ok_or_else(|| anyhow!("auth login '{profile_name}' not found"))?; - let target_org = match org_name.as_deref() { - Some(org_name) => { - if profile.org_name.as_deref() != Some(org_name) { - bail!( - "auth login '{profile_name}' belongs to org '{}' but '{}' was requested", - profile.org_name.as_deref().unwrap_or("(none)"), - org_name - ); - } - org_name - } - None => profile.org_name.as_deref().ok_or_else(|| { - anyhow!("auth login '{profile_name}' does not have a default org") - })?, - }; - - if let Some(org) = find_available_org(&available_orgs, target_org) { - let client = build_api_key_client(base, api_key, org).await?; - ensure_selected_setup_project( - base, - &client, - &mut project_name, - project_was_explicit, - &org.name, - ) - .await?; - return build_setup_auth_context(base, client, false, needs_api_key, None).await; - } - - let login = ensure_profile_or_setup_browser_auth( - base, - prompt_for_profile_choice, - project_name.as_deref(), - project_was_explicit, - org_name.as_deref(), - ) - .await?; - let selected_project = login.selected_project.clone(); - let client = ApiClient::new(&login.login)?; - let resolved_org = client.org_name().to_string(); - if selected_project.is_none() { - ensure_selected_setup_project( - base, - &client, - &mut project_name, - project_was_explicit, - &resolved_org, - ) - .await?; - } - return build_setup_auth_context( - base, - client, - login.is_oauth, - needs_api_key, - selected_project, - ) - .await; - } - if let Some(org_name) = org_name.as_deref() { - let matching_profile_count = stored_profiles - .iter() - .filter(|profile| profile.org_name.as_deref() == Some(org_name)) - .count(); - let _ = matching_profile_count; - if let Some(org) = find_available_org(&available_orgs, org_name) { let client = build_api_key_client(base, api_key, org).await?; ensure_selected_setup_project( @@ -1978,9 +1833,9 @@ async fn ensure_setup_auth( return build_setup_auth_context(base, client, false, needs_api_key, None).await; } - let login = ensure_profile_or_setup_browser_auth( + let login = ensure_org_or_setup_browser_auth( base, - prompt_for_profile_choice, + prompt_for_org_choice, project_name.as_deref(), project_was_explicit, Some(org_name), @@ -2016,9 +1871,9 @@ async fn ensure_setup_auth( None => available_orgs.clone(), }; if candidate_orgs.is_empty() { - return ensure_profile_or_setup_browser_auth_context( + return ensure_org_or_setup_browser_auth_context( base, - prompt_for_profile_choice, + prompt_for_org_choice, needs_api_key, project_name.as_deref(), project_was_explicit, @@ -2032,9 +1887,9 @@ async fn ensure_setup_auth( return build_setup_auth_context(base, client, false, needs_api_key, None).await; } - ensure_profile_or_setup_browser_auth_context( + ensure_org_or_setup_browser_auth_context( base, - prompt_for_profile_choice, + prompt_for_org_choice, needs_api_key, project_name.as_deref(), project_was_explicit, @@ -5340,9 +5195,10 @@ mod tests { quiet_source: None, no_color: false, no_input: false, - profile: None, org_name: None, + org_name_source: None, project: None, + project_source: None, api_key: None, api_key_source: None, prefer_api_key: false, @@ -5678,11 +5534,10 @@ mod tests { } #[test] - fn resolve_profile_name_for_setup_requires_prompt_when_multiple_profiles_exist() { + fn resolve_org_name_for_setup_requires_prompt_when_multiple_orgs_exist() { let base = make_base_args(); let profiles = vec![ auth::ProfileInfo { - name: "zeta".to_string(), auth_method: "api_key".to_string(), org_name: Some("Zeta Org".to_string()), user_name: None, @@ -5690,7 +5545,6 @@ mod tests { api_key_hint: None, }, auth::ProfileInfo { - name: "alpha".to_string(), auth_method: "api_key".to_string(), org_name: Some("Alpha Org".to_string()), user_name: None, @@ -5699,72 +5553,69 @@ mod tests { }, ]; - let resolved = - resolve_profile_name_for_setup(&base, &profiles, false).expect("resolve profile"); + let resolved = resolve_org_name_for_setup(&base, &profiles, false).expect("resolve org"); assert_eq!(resolved, None); } #[test] - fn resolve_profile_name_for_setup_allows_oauth_fallback_when_no_profiles_exist() { + fn resolve_org_name_for_setup_allows_browser_fallback_when_no_orgs_exist() { let base = make_base_args(); let profiles = Vec::new(); - let resolved = - resolve_profile_name_for_setup(&base, &profiles, true).expect("resolve profile"); + let resolved = resolve_org_name_for_setup(&base, &profiles, true).expect("resolve org"); assert_eq!(resolved, None); } #[test] - fn resolve_profile_name_for_setup_errors_for_unknown_explicit_profile() { + fn resolve_org_name_for_setup_uses_explicit_org_without_profile_lookup() { let mut base = make_base_args(); - base.profile = Some("missing".to_string()); + base.org_name = Some("Acme".to_string()); let profiles = vec![auth::ProfileInfo { - name: "work".to_string(), auth_method: "api_key".to_string(), - org_name: Some("Acme".to_string()), + org_name: Some("Other".to_string()), user_name: None, email: None, api_key_hint: None, }]; - let err = - resolve_profile_name_for_setup(&base, &profiles, false).expect_err("missing profile"); - assert!(err.to_string().contains("auth login 'missing' not found")); + let resolved = resolve_org_name_for_setup(&base, &profiles, false).expect("resolve org"); + assert_eq!(resolved.as_deref(), Some("Acme")); } #[test] - fn setup_browser_profile_name_reuses_selected_profile_or_suffixed_org_name() { + fn saved_auth_orgs_dedupes_and_sorts_profile_orgs() { let profiles = vec![ auth::ProfileInfo { - name: "Acme".to_string(), auth_method: "api_key".to_string(), - org_name: Some("Acme".to_string()), + org_name: Some("Zeta".to_string()), user_name: None, email: None, api_key_hint: None, }, auth::ProfileInfo { - name: "Acme-2".to_string(), + auth_method: "oauth".to_string(), + org_name: Some("Acme".to_string()), + user_name: Some("Test User".to_string()), + email: Some("user@example.test".to_string()), + api_key_hint: None, + }, + auth::ProfileInfo { auth_method: "api_key".to_string(), org_name: Some("Acme".to_string()), user_name: None, email: None, + api_key_hint: Some("sk-****abcde".to_string()), + }, + auth::ProfileInfo { + auth_method: "oauth".to_string(), + org_name: None, + user_name: Some("Test User".to_string()), + email: Some("user@example.test".to_string()), api_key_hint: None, }, ]; - assert_eq!( - setup_browser_profile_name(Some("work"), "Acme", &profiles), - "work" - ); - assert_eq!( - setup_browser_profile_name(None, "Acme", &profiles), - "Acme-3" - ); - assert_eq!( - setup_browser_profile_name(None, "New Org", &profiles), - "New Org" - ); + assert_eq!(saved_auth_orgs(&profiles), vec!["Acme", "Zeta"]); } #[test] diff --git a/src/status.rs b/src/status.rs index 6893975e..e6073031 100644 --- a/src/status.rs +++ b/src/status.rs @@ -57,11 +57,9 @@ pub async fn run(base: BaseArgs, _args: StatusArgs) -> Result<()> { .map(|p| config::load_file(p)) .unwrap_or_default(); - let cli_org = cli_flag_value(&["--org", "-o"]); - let cli_project = cli_flag_value(&["--project", "-p"]); + let overrides = ConfigOverrides::from_base(&base); let (org, mut project, source) = resolve_config( - cli_org, - cli_project, + overrides, &global_cfg, &local_cfg, &local_path, @@ -138,21 +136,54 @@ pub async fn run(base: BaseArgs, _args: StatusArgs) -> Result<()> { Ok(()) } -/// Precedence (clig.dev): CLI flag > env var > local config > global config. -pub(crate) fn resolve_config( +#[derive(Default)] +pub(crate) struct ConfigOverrides { cli_org: Option, + env_org: Option, cli_project: Option, + env_project: Option, +} + +impl ConfigOverrides { + fn from_base(base: &BaseArgs) -> Self { + use crate::args::ArgValueSource; + + let (cli_org, env_org) = match base.org_name_source { + Some(ArgValueSource::CommandLine) => (base.org_name.clone(), None), + Some(ArgValueSource::EnvVariable) => (None, base.org_name.clone()), + None => (None, None), + }; + let (cli_project, env_project) = match base.project_source { + Some(ArgValueSource::CommandLine) => (base.project.clone(), None), + Some(ArgValueSource::EnvVariable) => (None, base.project.clone()), + None => (None, None), + }; + + Self { + cli_org, + env_org, + cli_project, + env_project, + } + } +} + +/// Precedence (clig.dev): CLI flag > env var > local config > global config. +pub(crate) fn resolve_config( + overrides: ConfigOverrides, global: &config::Config, local: &config::Config, local_path: &Option, global_path: &Option, ) -> (Option, Option, Option) { - let env_org = std::env::var("BRAINTRUST_ORG_NAME") - .ok() - .filter(|s| !s.is_empty()); - let env_project = std::env::var("BRAINTRUST_DEFAULT_PROJECT") - .ok() - .filter(|s| !s.is_empty()); + let ConfigOverrides { + cli_org, + env_org, + cli_project, + env_project, + } = overrides; + let env_org = env_org.filter(|s| !s.is_empty()); + let env_project = env_project.filter(|s| !s.is_empty()); let org = cli_org .clone() @@ -181,32 +212,6 @@ pub(crate) fn resolve_config( (org, project, source) } -fn cli_flag_value(flags: &[&str]) -> Option { - let args: Vec = std::env::args().collect(); - for (i, arg) in args.iter().enumerate() { - if arg == "--" { - break; - } - for flag in flags { - if arg == *flag { - return args.get(i + 1).cloned(); - } - if let Some(val) = arg.strip_prefix(&format!("{flag}=")) { - return Some(val.to_string()); - } - // Handle -oVALUE style (short flags only) - if flag.len() == 2 && flag.starts_with('-') { - if let Some(val) = arg.strip_prefix(flag) { - if !val.is_empty() { - return Some(val.to_string()); - } - } - } - } - } - None -} - #[cfg(test)] mod tests { use super::*; @@ -232,8 +237,11 @@ mod tests { let global_path = Some(PathBuf::from("/home/.bt/config.json")); let (org, project, source) = resolve_config( - s("cli-org"), - s("cli-proj"), + ConfigOverrides { + cli_org: s("cli-org"), + cli_project: s("cli-proj"), + ..Default::default() + }, &global, &local, &local_path, @@ -245,6 +253,30 @@ mod tests { assert_eq!(source, s("cli")); } + #[test] + fn env_overrides_config_below_cli() { + let global = config(Some("global-org"), Some("global-proj")); + let local = config(Some("local-org"), Some("local-proj")); + let local_path = Some(PathBuf::from("/project/.bt/config.json")); + let global_path = Some(PathBuf::from("/home/.bt/config.json")); + + let (org, project, source) = resolve_config( + ConfigOverrides { + env_org: s("env-org"), + env_project: s("env-proj"), + ..Default::default() + }, + &global, + &local, + &local_path, + &global_path, + ); + + assert_eq!(org, s("env-org")); + assert_eq!(project, s("env-proj")); + assert_eq!(source, s("env")); + } + #[test] fn local_overrides_global() { let global = config(Some("global-org"), Some("global-proj")); @@ -252,8 +284,13 @@ mod tests { let local_path = Some(PathBuf::from("/project/.bt/config.json")); let global_path = Some(PathBuf::from("/home/.bt/config.json")); - let (org, project, source) = - resolve_config(None, None, &global, &local, &local_path, &global_path); + let (org, project, source) = resolve_config( + ConfigOverrides::default(), + &global, + &local, + &local_path, + &global_path, + ); assert_eq!(org, s("local-org")); assert_eq!(project, s("local-proj")); @@ -267,8 +304,13 @@ mod tests { let local_path = Some(PathBuf::from("/project/.bt/config.json")); let global_path = Some(PathBuf::from("/home/.bt/config.json")); - let (org, project, source) = - resolve_config(None, None, &global, &local, &local_path, &global_path); + let (org, project, source) = resolve_config( + ConfigOverrides::default(), + &global, + &local, + &local_path, + &global_path, + ); assert_eq!(org, s("global-org")); assert_eq!(project, s("global-proj")); @@ -282,8 +324,13 @@ mod tests { let local_path = Some(PathBuf::from("/project/.bt/config.json")); let global_path = Some(PathBuf::from("/home/.bt/config.json")); - let (org, project, source) = - resolve_config(None, None, &global, &local, &local_path, &global_path); + let (org, project, source) = resolve_config( + ConfigOverrides::default(), + &global, + &local, + &local_path, + &global_path, + ); assert_eq!(org, None); assert_eq!(project, None); @@ -298,8 +345,10 @@ mod tests { let global_path = Some(PathBuf::from("/home/.bt/config.json")); let (org, project, source) = resolve_config( - s("cli-org"), - None, + ConfigOverrides { + cli_org: s("cli-org"), + ..Default::default() + }, &global, &local, &local_path, @@ -318,8 +367,13 @@ mod tests { let local_path = Some(PathBuf::from("/project/.bt/config.json")); let global_path = Some(PathBuf::from("/home/.bt/config.json")); - let (org, project, source) = - resolve_config(None, None, &global, &local, &local_path, &global_path); + let (org, project, source) = resolve_config( + ConfigOverrides::default(), + &global, + &local, + &local_path, + &global_path, + ); assert_eq!(org, s("global-org")); assert_eq!(project, s("local-proj")); @@ -327,13 +381,11 @@ mod tests { } fn profile( - name: &str, user_name: Option<&str>, email: Option<&str>, api_key_hint: Option<&str>, ) -> auth::ProfileInfo { auth::ProfileInfo { - name: name.into(), auth_method: if api_key_hint.is_some() { "api_key" } else { @@ -349,7 +401,7 @@ mod tests { #[test] fn format_identity_name_and_email() { - let p = profile("work", Some("Alice"), Some("alice@example.com"), None); + let p = profile(Some("Alice"), Some("alice@example.com"), None); assert_eq!( format_identity(&p), Some("Alice (alice@example.com)".into()) @@ -358,19 +410,19 @@ mod tests { #[test] fn format_identity_email_only() { - let p = profile("work", None, Some("alice@example.com"), None); + let p = profile(None, Some("alice@example.com"), None); assert_eq!(format_identity(&p), Some("alice@example.com".into())); } #[test] fn format_identity_api_key_hint() { - let p = profile("work", None, None, Some("sk-****zhJwO")); + let p = profile(None, None, Some("sk-****zhJwO")); assert_eq!(format_identity(&p), Some("sk-****zhJwO".into())); } #[test] fn format_identity_none() { - let p = profile("work", None, None, None); + let p = profile(None, None, None); assert_eq!(format_identity(&p), None); } } diff --git a/src/switch.rs b/src/switch.rs index d079a212..021ccc16 100644 --- a/src/switch.rs +++ b/src/switch.rs @@ -103,7 +103,7 @@ pub async fn run(base: BaseArgs, args: SwitchArgs) -> Result<()> { }; let mut cfg = config::load_file(&path); - apply_switch_config(&mut cfg, None, Some(&org_name), Some(&project)); + apply_switch_config(&mut cfg, Some(&org_name), Some(&project)); config::save_file(&path, &cfg) .context(format!("Could not save config to {}", path.display()))?; @@ -229,11 +229,9 @@ pub(crate) async fn validate_or_create_project( pub(crate) fn apply_switch_config( cfg: &mut config::Config, - _profile_name: Option<&str>, org_name: Option<&str>, project: Option<&api::Project>, ) { - cfg.profile = None; cfg.org = config::trimmed_option(org_name).map(str::to_string); match project { Some(project) => { @@ -247,37 +245,9 @@ pub(crate) fn apply_switch_config( } } -#[cfg(test)] -fn resolve_profile_for_switch( - has_api_key_override: bool, - prompting_for_project_only: bool, - is_interactive: bool, - select_profile_interactive: F, - interactive: &mut bool, -) -> Result> -where - F: FnOnce() -> Result>, -{ - if has_api_key_override { - if prompting_for_project_only && is_interactive { - *interactive = true; - } - return Ok(None); - } - - if prompting_for_project_only && is_interactive { - *interactive = true; - select_profile_interactive() - } else { - Ok(None) - } -} - #[cfg(test)] mod tests { use super::*; - use crate::auth::{resolve_org_to_profile, ProfileInfo}; - fn switch_args(target: Option<&str>) -> SwitchArgs { SwitchArgs { global: false, @@ -295,9 +265,10 @@ mod tests { quiet_source: None, no_color: false, no_input: false, - profile: None, org_name: org.map(String::from), + org_name_source: None, project: project.map(String::from), + project_source: None, api_key: None, api_key_source: None, prefer_api_key: false, @@ -308,17 +279,6 @@ mod tests { } } - fn profile_info(name: &str, org_name: Option<&str>) -> ProfileInfo { - ProfileInfo { - name: name.to_string(), - auth_method: "api_key".to_string(), - org_name: org_name.map(String::from), - user_name: None, - email: None, - api_key_hint: None, - } - } - // --- resolve_target tests (unchanged) --- #[test] @@ -413,99 +373,6 @@ mod tests { ); } - // --- resolve_org_to_profile tests --- - - #[test] - fn resolve_by_exact_profile_name() { - let profiles = vec![profile_info("acme", Some("acme-corp"))]; - assert_eq!(resolve_org_to_profile("acme", &profiles).unwrap(), "acme"); - } - - #[test] - fn resolve_by_org_name_when_profile_name_differs() { - let profiles = vec![profile_info("work", Some("acme-corp"))]; - assert_eq!( - resolve_org_to_profile("acme-corp", &profiles).unwrap(), - "work" - ); - } - - #[test] - fn resolve_no_match_errors() { - let profiles = vec![profile_info("work", Some("acme-corp"))]; - assert!(resolve_org_to_profile("unknown", &profiles).is_err()); - } - - #[test] - fn resolve_empty_profiles_errors() { - let profiles: Vec = vec![]; - let err = resolve_org_to_profile("anything", &profiles).unwrap_err(); - assert!(err.to_string().contains("no auth profiles found")); - } - - #[test] - fn resolve_prefers_profile_name_over_org_name() { - let profiles = vec![ - profile_info("acme", Some("other")), - profile_info("x", Some("acme")), - ]; - assert_eq!(resolve_org_to_profile("acme", &profiles).unwrap(), "acme"); - } - - #[test] - fn resolve_profile_without_org() { - let profiles = vec![profile_info("default", None)]; - assert_eq!( - resolve_org_to_profile("default", &profiles).unwrap(), - "default" - ); - } - - // --- login_base org_name clearing tests --- - - #[test] - fn login_base_clears_org_name_when_profile_resolved() { - let base = BaseArgs { - org_name: Some("staging".into()), - ..base_args(None, Some("foobar")) - }; - let profile_name = Some("staging".to_string()); - - let login_base = match &profile_name { - Some(profile) if base.profile.is_none() => BaseArgs { - profile: Some(profile.clone()), - org_name: None, - ..base.clone() - }, - _ => base.clone(), - }; - - assert_eq!(login_base.profile, Some("staging".into())); - assert_eq!(login_base.org_name, None); - } - - #[test] - fn login_base_preserves_org_when_explicit_profile_flag() { - let base = BaseArgs { - profile: Some("staging".into()), - org_name: Some("custom-org".into()), - ..base_args(None, Some("foobar")) - }; - let profile_name: Option = None; - - let login_base = match &profile_name { - Some(profile) if base.profile.is_none() => BaseArgs { - profile: Some(profile.clone()), - org_name: None, - ..base.clone() - }, - _ => base.clone(), - }; - - assert_eq!(login_base.profile, Some("staging".into())); - assert_eq!(login_base.org_name, Some("custom-org".into())); - } - #[test] fn apply_switch_config_sets_project_id_with_project_name_and_org() { let mut cfg = config::Config::default(); @@ -516,96 +383,26 @@ mod tests { description: None, }; - apply_switch_config(&mut cfg, Some("work"), Some("acme-org"), Some(&project)); + apply_switch_config(&mut cfg, Some("acme-org"), Some(&project)); - assert_eq!(cfg.profile, None); assert_eq!(cfg.org.as_deref(), Some("acme-org")); assert_eq!(cfg.project.as_deref(), Some("my-project")); assert_eq!(cfg.project_id.as_deref(), Some("proj_123")); } - #[test] - fn apply_switch_config_clears_existing_profile() { - let mut cfg = config::Config { - profile: Some("work".to_string()), - ..Default::default() - }; - let project = api::Project { - id: "proj_456".to_string(), - name: "next-project".to_string(), - org_id: "org_123".to_string(), - description: None, - }; - - apply_switch_config(&mut cfg, None, Some("acme-org"), Some(&project)); - - assert_eq!(cfg.profile, None); - assert_eq!(cfg.project.as_deref(), Some("next-project")); - } - #[test] fn apply_switch_config_clears_project_and_org_when_context_is_org_only() { let mut cfg = config::Config { - profile: Some("work".to_string()), org: Some("old-org".to_string()), project: Some("stale-project".to_string()), project_id: Some("proj_stale".to_string()), ..Default::default() }; - apply_switch_config(&mut cfg, Some("next"), None, None); + apply_switch_config(&mut cfg, None, None); - assert_eq!(cfg.profile, None); assert_eq!(cfg.org, None); assert_eq!(cfg.project, None); assert_eq!(cfg.project_id, None); } - - #[test] - fn resolve_profile_for_switch_skips_org_prompt_when_api_key_infers_profile() { - let mut interactive = false; - let profile = resolve_profile_for_switch( - true, - true, - true, - || panic!("org picker should not be called"), - &mut interactive, - ) - .expect("resolve"); - - assert_eq!(profile, None); - assert!(interactive); - } - - #[test] - fn resolve_profile_for_switch_prompts_when_no_inferred_profile() { - let mut interactive = false; - let profile = resolve_profile_for_switch( - false, - true, - true, - || Ok(Some("picked-profile".to_string())), - &mut interactive, - ) - .expect("resolve"); - - assert_eq!(profile.as_deref(), Some("picked-profile")); - assert!(interactive); - } - - #[test] - fn resolve_profile_for_switch_skips_org_prompt_when_api_key_override_has_no_profile_match() { - let mut interactive = false; - let profile = resolve_profile_for_switch( - true, - true, - true, - || panic!("org picker should not be called"), - &mut interactive, - ) - .expect("resolve"); - - assert_eq!(profile, None); - assert!(interactive); - } } diff --git a/src/traces.rs b/src/traces.rs index cc59c4ca..396b4de0 100644 --- a/src/traces.rs +++ b/src/traces.rs @@ -5277,7 +5277,7 @@ fn logs_hints( if let Some(cursor) = next_cursor { hints.push(format!( "Next page: bt view logs{} --object-ref {object_ref} --cursor {cursor}{limit_suffix}", - profile_flag_suffix(profile) + org_flag_suffix(profile) )); } else { hints.push(format!( @@ -5299,7 +5299,7 @@ fn trace_hints( let mut hints = vec![ format!( "Trace fetch returns truncated span rows; use `bt view span{} --object-ref {object_ref} --id ` for full single-span payloads.", - profile_flag_suffix(profile) + org_flag_suffix(profile) ), "Write output to a file for long traces.".to_string(), ]; @@ -5312,7 +5312,7 @@ fn trace_hints( if let Some(cursor) = next_cursor { hints.push(format!( "Next page: bt view trace{} --object-ref {object_ref} --trace-id {trace_id} --cursor {cursor}{limit_suffix}", - profile_flag_suffix(profile) + org_flag_suffix(profile) )); } else { hints.push(format!( @@ -5341,7 +5341,7 @@ fn thread_hints(profile: Option<&str>) -> Vec { vec![ format!( "Open an interactive thread view with `bt view thread{} --trace-id `.", - profile_flag_suffix(profile) + org_flag_suffix(profile) ), "Use `--non-interactive` for a compact text transcript.".to_string(), ] @@ -5351,7 +5351,7 @@ fn waterfall_hints(profile: Option<&str>, has_more: bool) -> Vec { let mut hints = vec![ format!( "Render an agent-readable trace report with `bt view waterfall{} --trace-id `.", - profile_flag_suffix(profile) + org_flag_suffix(profile) ), "Use `--json` for computed offsets, token counts, costs, cache metrics, and raw ids." .to_string(), @@ -5406,7 +5406,7 @@ fn print_logs_text( println!("next_cursor: {cursor}"); println!( "next: bt view logs{} --object-ref {object_ref} --cursor {cursor} --non-interactive{limit_suffix}", - profile_flag_suffix(profile) + org_flag_suffix(profile) ); } else { println!("No additional rows."); @@ -5552,7 +5552,7 @@ fn print_thread_text( println!( "\njson: bt view thread --json{} --trace-id {}", - profile_flag_suffix(profile), + org_flag_suffix(profile), target.root_span_id ); } @@ -5589,7 +5589,7 @@ fn print_trace_text( } println!( "\nTrace output is truncated. Re-run with --json for the full trace (`bt view trace --json{0} --object-ref {1} --trace-id {2}`), or fetch a single span with `bt view span{0} --object-ref {1} --id `.", - profile_flag_suffix(profile), + org_flag_suffix(profile), object_ref, trace_id ); @@ -5602,7 +5602,7 @@ fn print_trace_text( println!("next_cursor: {cursor}"); println!( "next: bt view trace{} --object-ref {} --trace-id {} --cursor {} --non-interactive{limit_suffix}", - profile_flag_suffix(profile), + org_flag_suffix(profile), object_ref, trace_id, cursor @@ -5683,7 +5683,7 @@ fn format_object_ref_arg(object_ref: &ObjectRef) -> String { format!("{}:{}", object_ref.object_type, object_ref.object_name) } -fn profile_flag_suffix(org: Option<&str>) -> String { +fn org_flag_suffix(org: Option<&str>) -> String { match org.filter(|p| !p.trim().is_empty()) { Some(org) => format!(" --org {}", btql_quote(org)), None => String::new(), @@ -5741,14 +5741,7 @@ fn apply_url_hints_to_base(mut base: BaseArgs, parsed_url: Option<&ParsedTraceUr } #[cfg(test)] -fn apply_url_hints_with_profile_resolver( - base: BaseArgs, - parsed_url: Option<&ParsedTraceUrl>, - _resolve_profile_for_org: F, -) -> BaseArgs -where - F: Fn(&str) -> Option, -{ +fn apply_url_hints_for_test(base: BaseArgs, parsed_url: Option<&ParsedTraceUrl>) -> BaseArgs { apply_url_hints_to_base(base, parsed_url) } @@ -6716,9 +6709,10 @@ mod tests { quiet_source: None, no_color: false, no_input: false, - profile: None, org_name: None, + org_name_source: None, project: None, + project_source: None, api_key: None, api_key_source: None, prefer_api_key: false, @@ -6954,12 +6948,9 @@ mod tests { let base = base_args(); let parsed = parsed_url_with_org("Lovable"); - let updated = apply_url_hints_with_profile_resolver(base, Some(&parsed), |org| { - (org == "Lovable").then(|| "lovable-profile".to_string()) - }); + let updated = apply_url_hints_for_test(base, Some(&parsed)); assert_eq!(updated.org_name.as_deref(), Some("Lovable")); - assert_eq!(updated.profile, None); } #[test] @@ -6968,9 +6959,7 @@ mod tests { base.org_name = Some("explicit-org".to_string()); let parsed = parsed_url_with_org("Lovable"); - let updated = apply_url_hints_with_profile_resolver(base, Some(&parsed), |_| { - Some("other".to_string()) - }); + let updated = apply_url_hints_for_test(base, Some(&parsed)); assert_eq!(updated.org_name.as_deref(), Some("explicit-org")); } diff --git a/src/traces/waterfall.rs b/src/traces/waterfall.rs index fbdf5fc7..84caefa2 100644 --- a/src/traces/waterfall.rs +++ b/src/traces/waterfall.rs @@ -6,8 +6,8 @@ use serde_json::{Map, Value}; use super::{ build_span_entries, extract_duration_seconds, extract_model_name, extract_parent_span_id, extract_span_end_seconds, extract_span_name_and_type, extract_start_time, - format_compact_duration, format_object_ref_arg, format_u64_with_commas, parse_f64ish, - parse_u64ish, profile_flag_suffix, project_label, span_has_error, value_as_object_owned, + format_compact_duration, format_object_ref_arg, format_u64_with_commas, org_flag_suffix, + parse_f64ish, parse_u64ish, project_label, span_has_error, value_as_object_owned, ResolvedTraceCommandTarget, }; @@ -197,13 +197,13 @@ pub(super) fn print_waterfall_text( } println!( "\nspan detail: bt view span{} --object-ref {} --id ", - profile_flag_suffix(profile), + org_flag_suffix(profile), format_object_ref_arg(&target.object_ref) ); println!("Use the inline `id=` value from a span row."); println!( "json: bt view waterfall --json{} --trace-id {}", - profile_flag_suffix(profile), + org_flag_suffix(profile), target.root_span_id ); } diff --git a/src/utils/profile.rs b/src/utils/profile.rs index 620d681c..613710a0 100644 --- a/src/utils/profile.rs +++ b/src/utils/profile.rs @@ -1,59 +1,5 @@ use crate::auth::ProfileInfo; -#[cfg(test)] -fn resolve_profile_info_from_profiles( - profile: Option<&str>, - org: Option<&str>, - profiles: Vec, -) -> Option { - if let Some(profile_name) = profile { - if let Some(profile) = profiles - .iter() - .find(|profile| profile.name == profile_name) - .cloned() - { - return Some(profile); - } - } - - if let Some(org_name) = org { - if profiles.iter().any(|profile| profile.name == org_name) { - return profiles - .into_iter() - .find(|profile| profile.name == org_name); - } - - let org_matches: Vec<&ProfileInfo> = profiles - .iter() - .filter(|profile| profile.org_name.as_deref() == Some(org_name)) - .collect(); - if org_matches.len() == 1 { - let profile_name = org_matches[0].name.clone(); - return profiles - .into_iter() - .find(|profile| profile.name == profile_name); - } - let oauth_matches: Vec<&ProfileInfo> = org_matches - .iter() - .copied() - .filter(|profile| profile.email.is_some() || profile.user_name.is_some()) - .collect(); - if oauth_matches.len() == 1 { - let profile_name = oauth_matches[0].name.clone(); - return profiles - .into_iter() - .find(|profile| profile.name == profile_name); - } - return None; - } - - if profiles.len() == 1 { - return profiles.into_iter().next(); - } - - None -} - pub(crate) fn profile_author_slug(profile: &ProfileInfo) -> Option { [ profile.user_name.as_deref(), @@ -102,13 +48,11 @@ mod tests { use super::*; fn profile_info( - name: &str, org_name: Option<&str>, user_name: Option<&str>, email: Option<&str>, ) -> ProfileInfo { ProfileInfo { - name: name.to_string(), auth_method: if email.is_some() || user_name.is_some() { "oauth" } else { @@ -122,36 +66,9 @@ mod tests { } } - #[test] - fn resolve_profile_info_prefers_explicit_profile() { - let profile = resolve_profile_info_from_profiles( - Some("work"), - Some("other-org"), - vec![ - profile_info("other", Some("other-org"), None, None), - profile_info("work", Some("work-org"), None, None), - ], - ) - .expect("profile"); - - assert_eq!(profile.name, "work"); - } - - #[test] - fn resolve_profile_info_finds_profile_by_org_name() { - let profile = resolve_profile_info_from_profiles( - None, - Some("work-org"), - vec![profile_info("work", Some("work-org"), None, None)], - ) - .expect("profile"); - - assert_eq!(profile.name, "work"); - } - #[test] fn profile_author_slug_prefers_user_name() { - let profile = profile_info("work", None, Some("Alice Smith"), Some("alice@example.com")); + let profile = profile_info(None, Some("Alice Smith"), Some("alice@example.com")); assert_eq!( profile_author_slug(&profile).as_deref(), Some("alice-smith") @@ -160,13 +77,13 @@ mod tests { #[test] fn profile_author_slug_falls_back_to_email_local_part() { - let profile = profile_info("work", None, None, Some("alice.dev@example.com")); + let profile = profile_info(None, None, Some("alice.dev@example.com")); assert_eq!(profile_author_slug(&profile).as_deref(), Some("alice-dev")); } #[test] - fn profile_author_slug_ignores_internal_profile_name() { - let profile = profile_info("Work Profile", None, None, None); + fn profile_author_slug_returns_none_without_identity_or_org() { + let profile = profile_info(None, None, None); assert_eq!(profile_author_slug(&profile), None); } diff --git a/tests/cli.rs b/tests/cli.rs index a120455c..f40c0682 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -130,12 +130,12 @@ fn status_quiet_and_verbose_conflict() { } #[test] -fn status_json_keeps_local_org_when_global_profile_has_different_org() { +fn status_json_prefers_local_org_over_global_org() { let repo = make_git_repo(); fs::create_dir_all(repo.path().join(".bt")).expect("create local bt dir"); fs::write( repo.path().join(".bt/config.json"), - r#"{"profile":null,"org":"local-org","project":"local-project","project_id":null}"#, + r#"{"org":"local-org","project":"local-project","project_id":null}"#, ) .expect("write local config"); @@ -145,11 +145,9 @@ fn status_json_keeps_local_org_when_global_profile_has_different_org() { fs::create_dir_all(&global_bt_dir).expect("create global bt dir"); fs::write( global_bt_dir.join("config.json"), - r#"{"profile":"default-profile","org":"profile-org"}"#, + r#"{"org":"profile-org"}"#, ) .expect("write global config"); - write_auth_store(config_home.path(), &[("default-profile", "profile-org")]); - let mut cmd = bt_command(); clear_braintrust_auth_env(&mut cmd); cmd.current_dir(repo.path()) @@ -160,7 +158,6 @@ fn status_json_keeps_local_org_when_global_profile_has_different_org() { .success() .stdout(predicate::str::contains(r#""org":"local-org""#)) .stdout(predicate::str::contains(r#""project":"local-project""#)) - .stdout(predicate::str::contains(r#""profile""#).not()) .stdout(predicate::str::contains(r#""org":"profile-org""#).not()); } From 2a8191aa2dbaa5944e53eb0651ba863fe76b814d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Thu, 16 Jul 2026 13:43:02 -0700 Subject: [PATCH 3/8] chore: add compatibility code to prevent users from having to re-login to use the new auth storage format orgid,email --- src/auth.rs | 641 ++++++++++++++++++++++++++++++++++++++++----- src/config/mod.rs | 15 ++ src/init.rs | 3 + src/switch.rs | 9 +- tests/functions.rs | 19 ++ 5 files changed, 614 insertions(+), 73 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 8ab0df8c..9833bfad 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -58,6 +58,7 @@ pub struct ResolvedAuth { pub app_url: Option, pub org_name: Option, pub is_oauth: bool, + slot_key: Option, } #[derive(Debug, Clone)] @@ -179,7 +180,7 @@ pub(crate) async fn list_available_orgs_for_api_key( .collect()) } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] struct AuthStore { #[serde(default)] profiles: BTreeMap, @@ -191,7 +192,7 @@ struct SecretStore { secrets: BTreeMap, } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] struct AuthProfile { #[serde(default)] auth_kind: AuthKind, @@ -425,7 +426,16 @@ pub async fn login(base: &BaseArgs) -> Result { builder = builder.default_project(project); } let login = match builder.build().await { - Ok(client) => client.wait_for_login().await?, + Ok(client) => match client.wait_for_login().await { + Ok(login) => login, + Err(err) => { + let err: anyhow::Error = err.into(); + if !auth.is_oauth && is_unauthorized_auth_error(&err) { + return Err(err.context("API key is not valid")); + } + return Err(err); + } + }, Err(_err) if auth.is_oauth => { let org_name = auth.org_name.clone().unwrap_or_default(); let login = LoginState::new(); @@ -442,9 +452,17 @@ pub async fn login(base: &BaseArgs) -> Result { ); login } - Err(err) => return Err(err.into()), + Err(err) => { + let err: anyhow::Error = err.into(); + if is_unauthorized_auth_error(&err) { + return Err(err.context("API key is not valid")); + } + return Err(err); + } }; + reconcile_resolved_auth_slot(&auth, &login)?; + let api_url = login .api_url() .or(auth.api_url.clone()) @@ -696,6 +714,24 @@ fn has_cached_project_id(base: &BaseArgs) -> bool { .is_some_and(|project_id| !project_id.trim().is_empty()) } +fn is_unauthorized_auth_error(err: &anyhow::Error) -> bool { + err.chain().any(|source| { + if let Some(http_error) = source.downcast_ref::() { + return matches!(http_error.status.as_u16(), 401 | 403); + } + if let Some(sdk_error) = source.downcast_ref::() { + return matches!( + sdk_error, + braintrust_sdk_rust::BraintrustError::Api { + status: 401 | 403, + .. + } + ); + } + false + }) +} + fn maybe_warn_api_key_override(_base: &BaseArgs) {} fn resolve_cli_api_key_override(base: &BaseArgs) -> Option { @@ -755,6 +791,7 @@ pub async fn resolve_auth(base: &BaseArgs) -> Result { app_url: base.app_url.clone(), org_name: effective_org_name(base, &cfg_org).map(str::to_string), is_oauth: false, + slot_key: None, }); } @@ -789,6 +826,7 @@ pub async fn resolve_auth(base: &BaseArgs) -> Result { app_url: base.app_url.clone(), org_name: effective_org_name(base, &cfg_org).map(str::to_string), is_oauth: false, + slot_key: None, }); } @@ -804,6 +842,7 @@ pub async fn resolve_auth(base: &BaseArgs) -> Result { app_url: base.app_url.clone(), org_name: effective_org_name(base, &cfg_org).map(str::to_string), is_oauth: false, + slot_key: None, }) } @@ -825,6 +864,7 @@ fn resolve_preferred_api_key_auth( app_url: base.app_url.clone(), org_name: Some(org.to_string()), is_oauth: false, + slot_key: None, })); } @@ -843,9 +883,11 @@ fn resolve_api_key_profile_auth( cfg_org: &Option, profile_name: &str, ) -> Result { - let profile = store.profiles.get(profile_name).cloned().ok_or_else(|| { - anyhow::anyhow!("auth login '{profile_name}' not found; run `bt auth profiles`") - })?; + let profile = store + .profiles + .get(profile_name) + .cloned() + .ok_or_else(|| anyhow::anyhow!("saved auth login not found; run `bt auth profiles`"))?; let api_key = load_profile_secret_with_legacy( profile_name, profile.legacy_secret_key.as_deref(), @@ -868,51 +910,172 @@ fn resolve_api_key_profile_auth( .map(str::to_string) .or_else(|| profile.org_name.clone()), is_oauth: false, + slot_key: Some(profile_name.to_string()), }; maybe_rekey_api_key_profile_after_secret_load(store, profile_name, &api_key)?; Ok(resolved) } +fn replace_with_canonical_auth_profile( + store: &mut AuthStore, + current_key: &str, + mut profile: AuthProfile, +) -> bool { + let canonical_key = canonical_profile_key(current_key, &profile); + if canonical_key != current_key && profile.legacy_secret_key.is_none() { + // Keep the old key as a lazy keychain fallback. Secrets are relocated + // only when they are next saved, avoiding platform-specific migration + // work while auth.json is being upgraded. + profile.legacy_secret_key = Some(current_key.to_string()); + } + + let unchanged = canonical_key == current_key + && store + .profiles + .get(current_key) + .is_some_and(|existing| existing == &profile); + if unchanged { + return false; + } + + if canonical_key != current_key { + store.profiles.remove(current_key); + if let Some(existing) = store.profiles.get(&canonical_key) { + if !should_replace_migrated_profile(existing, &profile) { + return true; + } + } + } + store.profiles.insert(canonical_key, profile); + true +} + fn maybe_rekey_api_key_profile_after_secret_load( store: &mut AuthStore, profile_name: &str, api_key: &str, ) -> Result<()> { - let Some(profile) = store.profiles.get(profile_name).cloned() else { + let Some(mut profile) = store.profiles.get(profile_name).cloned() else { + return Ok(()); + }; + if profile.auth_kind != AuthKind::ApiKey + || profile + .org_id + .as_deref() + .map(str::trim) + .is_none_or(str::is_empty) + { + return Ok(()); + } + + profile.api_key_hash = Some(api_key_hash(api_key)); + if replace_with_canonical_auth_profile(store, profile_name, profile) { + save_auth_store(store)?; + } + Ok(()) +} + +async fn reconcile_oauth_slot_from_access_token( + store: &mut AuthStore, + slot_key: &str, + access_token: &str, + app_url: &str, +) -> Result<()> { + let Some(mut profile) = store.profiles.get(slot_key).cloned() else { return Ok(()); }; - if profile.auth_kind != AuthKind::ApiKey { + if profile.auth_kind != AuthKind::Oauth || profile.org_id.is_some() { return Ok(()); } - let Some(org_id) = profile - .org_id + + let Some(org_name) = profile + .org_name .as_deref() .map(str::trim) - .filter(|value| !value.is_empty()) + .filter(|org| !org.is_empty()) else { + profile.org_id = Some(String::new()); + if replace_with_canonical_auth_profile(store, slot_key, profile) { + save_auth_store(store)?; + } return Ok(()); }; - let hash = api_key_hash(api_key); - let new_key = api_key_slot_key(&hash, org_id); - let already_canonical = - profile_name == new_key && profile.api_key_hash.as_deref() == Some(&hash); - if already_canonical { + // A legacy auth entry only cached the org name. Resolve the stable ID from + // the newly refreshed token; a failed best-effort lookup must not turn a + // successful token refresh into a failed command. + let Ok(orgs) = fetch_login_orgs(access_token, app_url).await else { + return Ok(()); + }; + let Some(org) = find_login_org(&orgs, org_name) else { + return Ok(()); + }; + + profile.org_id = Some(org.id.clone()); + profile.org_name = Some(org.name.clone()); + let identity = decode_jwt_identity(access_token); + if identity.email.is_some() { + profile.email = identity.email; + } + if identity.name.is_some() { + profile.user_name = identity.name; + } + if replace_with_canonical_auth_profile(store, slot_key, profile) { + save_auth_store(store)?; + } + Ok(()) +} + +fn reconcile_resolved_auth_slot(auth: &ResolvedAuth, login: &LoginState) -> Result<()> { + let Some(slot_key) = auth.slot_key.as_deref() else { + return Ok(()); + }; + let mut store = load_auth_store()?; + let Some(mut profile) = store.profiles.get(slot_key).cloned() else { + return Ok(()); + }; + + let login_org_id = login.org_id().unwrap_or_default(); + let is_cross_org = profile.auth_kind == AuthKind::Oauth + && auth + .org_name + .as_deref() + .is_none_or(|org| org.trim().is_empty()); + if login_org_id.trim().is_empty() && !is_cross_org { + // The SDK's OAuth compatibility path does not always return org + // metadata. `bt auth profiles` performs the same reconciliation after + // its explicit credential verification request. return Ok(()); } - let mut updated = profile; - updated.api_key_hash = Some(hash); - if profile_name != new_key && updated.legacy_secret_key.is_none() { - updated.legacy_secret_key = Some(profile_name.to_string()); + profile.org_id = Some(login_org_id); + profile.org_name = if is_cross_org { + None + } else { + login + .org_name() + .filter(|org| !org.trim().is_empty()) + .or_else(|| auth.org_name.clone()) + }; + match profile.auth_kind { + AuthKind::ApiKey => { + let Some(api_key) = auth.api_key.as_deref() else { + return Ok(()); + }; + profile.api_key_hash = Some(api_key_hash(api_key)); + if profile.api_key_hint.is_none() { + profile.api_key_hint = Some(obscure_api_key(api_key)); + } + } + AuthKind::Oauth if profile.email.as_deref().is_none_or(str::is_empty) => return Ok(()), + AuthKind::Oauth => {} } - if profile_name != new_key { - store.profiles.remove(profile_name); + if replace_with_canonical_auth_profile(&mut store, slot_key, profile) { + save_auth_store(&store)?; } - store.profiles.insert(new_key, updated); - save_auth_store(store) + Ok(()) } async fn resolve_oauth_profile_auth( @@ -921,11 +1084,10 @@ async fn resolve_oauth_profile_auth( cfg_org: &Option, profile_name: &str, ) -> Result { - let profile = store - .profiles - .get(profile_name) - .cloned() - .ok_or_else(|| anyhow::anyhow!("oauth login '{profile_name}' not found"))?; + let profile = + store.profiles.get(profile_name).cloned().ok_or_else(|| { + anyhow::anyhow!("saved OAuth login not found; run `bt auth profiles`") + })?; let client_id = profile.oauth_client_id.as_deref().ok_or_else(|| { recoverable_auth_error( RecoverableAuthErrorKind::OauthClientId, @@ -951,6 +1113,7 @@ async fn resolve_oauth_profile_auth( app_url, org_name, is_oauth: true, + slot_key: Some(profile_name.to_string()), }; if let Some(cached_access_token) = load_valid_cached_oauth_access_token( @@ -972,8 +1135,9 @@ async fn resolve_oauth_profile_auth( ), ) })?; + let login_label = auth_slot_label(profile_name, &profile); let refreshed = - refresh_oauth_access_token(&api_url, &refresh_token, client_id, profile_name).await?; + refresh_oauth_access_token(&api_url, &refresh_token, client_id, &login_label).await?; save_profile_oauth_access_token(profile_name, &refreshed.access_token)?; let mut refresh_rotated = false; if let Some(next_refresh_token) = refreshed.refresh_token.as_ref() { @@ -993,6 +1157,13 @@ async fn resolve_oauth_profile_auth( } } save_auth_store(store)?; + reconcile_oauth_slot_from_access_token( + store, + profile_name, + &refreshed.access_token, + auth.app_url.as_deref().unwrap_or(DEFAULT_APP_URL), + ) + .await?; auth.api_key = Some(refreshed.access_token); Ok(auth) } @@ -1053,13 +1224,11 @@ fn profile_identity_label(profile: &AuthProfile) -> Option { } } -fn auth_slot_label(name: &str, profile: &AuthProfile) -> String { +fn auth_slot_label(_name: &str, profile: &AuthProfile) -> String { let mut parts = vec![profile_org_label(profile)]; parts.push(auth_kind_label(profile.auth_kind).to_string()); if let Some(identity) = profile_identity_label(profile) { parts.push(identity); - } else if !name.contains("::") { - parts.push(name.to_string()); } parts.join(" — ") } @@ -1212,7 +1381,7 @@ fn profile_label_from_store(name: &str, store: &AuthStore) -> String { .profiles .get(name) .map(|profile| auth_slot_label(name, profile)) - .unwrap_or_else(|| name.to_string()) + .unwrap_or_else(|| "saved auth login".to_string()) } fn select_profile_from_store( @@ -1248,8 +1417,11 @@ fn candidate_identities<'a>(names: &[&'a str], store: &'a AuthStore) -> Vec Result<()> { .get(profile_name.as_str()) .cloned() .ok_or_else(|| { - anyhow::anyhow!( - "OAuth login '{}' not found; run `bt auth profiles` to see available logins", - profile_name - ) + anyhow::anyhow!("OAuth login not found; run `bt auth profiles` to see available logins") })?; let api_url = profile @@ -1669,9 +1838,9 @@ async fn run_login_refresh(base: &BaseArgs) -> Result<()> { eprintln!("Cached access token expiry before refresh: unknown"); } + let login_label = auth_slot_label(&profile_name, &profile); let refreshed = - refresh_oauth_access_token(&api_url, &refresh_token, &client_id, profile_name.as_str()) - .await?; + refresh_oauth_access_token(&api_url, &refresh_token, &client_id, &login_label).await?; save_profile_oauth_access_token(profile_name.as_str(), &refreshed.access_token)?; let mut refresh_rotated = false; if let Some(next_refresh_token) = refreshed.refresh_token.as_ref() { @@ -1693,6 +1862,13 @@ async fn run_login_refresh(base: &BaseArgs) -> Result<()> { } } save_auth_store(&store)?; + reconcile_oauth_slot_from_access_token( + &mut store, + profile_name.as_str(), + &refreshed.access_token, + profile.app_url.as_deref().unwrap_or(DEFAULT_APP_URL), + ) + .await?; if let Some(expires_at) = new_expires_at { let now = current_unix_timestamp(); @@ -1827,7 +2003,7 @@ fn emit_result(json: bool, payload: serde_json::Value, human: impl FnOnce()) -> } async fn run_profiles(base: &BaseArgs, _args: AuthProfilesArgs) -> Result<()> { - let store = load_auth_store()?; + let mut store = load_auth_store()?; if store.profiles.is_empty() { return emit_result(base.json, serde_json::json!([]), || { println!("No saved auth logins. Run `bt auth login` to create one.") @@ -1835,6 +2011,7 @@ async fn run_profiles(base: &BaseArgs, _args: AuthProfilesArgs) -> Result<()> { } let verifications = verify_all_profiles_from_store(&store).await; + reconcile_verified_auth_slots(&mut store, &verifications)?; let all_network_errors = verifications .iter() .all(|v| v.status == "error" && !v.error.as_deref().unwrap_or("").contains("invalid")); @@ -1892,16 +2069,19 @@ fn run_login_delete(profile_name: &str, force: bool, base_json: bool) -> Result< let label = auth_slot_label(profile_name, &profile); if !force { - if let Some(term) = ui::prompt_term() { - let confirmed = Confirm::new() - .with_prompt(format!("Delete {label}?")) - .default(false) - .interact_on(&term)?; - if !confirmed { - return emit_result(base_json, auth_profile_json(&profile, "cancelled"), || { - eprintln!("Cancelled") - }); - } + let term = ui::prompt_term().ok_or_else(|| { + anyhow::anyhow!( + "logout confirmation requires an interactive terminal; rerun with --force" + ) + })?; + let confirmed = Confirm::new() + .with_prompt(format!("Delete {label}?")) + .default(false) + .interact_on(&term)?; + if !confirmed { + return emit_result(base_json, auth_profile_json(&profile, "cancelled"), || { + eprintln!("Cancelled") + }); } } @@ -2021,6 +2201,8 @@ fn load_credential_for_profile(name: &str, profile: &AuthProfile) -> CredentialL pub struct ProfileVerification { #[serde(skip_serializing)] pub name: String, + #[serde(skip_serializing)] + slot_hash: Option, pub auth: String, #[serde(skip_serializing_if = "Option::is_none")] pub org: Option, @@ -2054,6 +2236,7 @@ fn build_verification( }; ProfileVerification { name: name.to_string(), + slot_hash: None, auth: auth_kind.to_string(), org, org_id, @@ -2102,17 +2285,38 @@ async fn verify_profile_full(name: &str, profile: &AuthProfile) -> ProfileVerifi }; match fetch_login_orgs(&credential, app_url).await { - Ok(_) => mk(ProfileStatus::Ok, jwt_id, hint), + Ok(orgs) => { + let mut verification = mk(ProfileStatus::Ok, jwt_id, hint); + if !is_cross_org_oauth_profile(profile) { + if let Some(org) = profile + .org_id + .as_deref() + .and_then(|id| find_login_org(&orgs, id)) + .or_else(|| { + profile + .org_name + .as_deref() + .and_then(|name| find_login_org(&orgs, name)) + }) + { + verification.org = Some(org.name.clone()); + verification.org_id = Some(org.id.clone()); + } + } + if profile.auth_kind == AuthKind::ApiKey { + verification.slot_hash = Some(api_key_hash(&credential)); + } + verification + } Err(e) => { - let msg = e.to_string(); - let status = if msg.contains("401") || msg.contains("Unauthorized") { + let status = if is_unauthorized_auth_error(&e) { if profile.auth_kind == AuthKind::Oauth { ProfileStatus::Expired } else { ProfileStatus::Error("invalid API key".to_string()) } } else { - ProfileStatus::Error(msg) + ProfileStatus::Error(e.to_string()) }; mk(status, None, hint) } @@ -2137,6 +2341,59 @@ async fn verify_all_profiles_from_store(store: &AuthStore) -> Vec Result<()> { + let mut changed = false; + for verification in verifications + .iter() + .filter(|verification| verification.status == "ok") + { + let Some(mut profile) = store.profiles.get(&verification.name).cloned() else { + continue; + }; + + if let Some(org_id) = verification.org_id.as_deref() { + profile.org_id = Some(org_id.to_string()); + profile.org_name = verification.org.clone(); + } else if is_cross_org_oauth_profile(&profile) { + profile.org_id = Some(String::new()); + profile.org_name = None; + } + + match profile.auth_kind { + AuthKind::ApiKey => { + let Some(hash) = verification.slot_hash.as_deref() else { + continue; + }; + if profile.org_id.as_deref().is_none_or(str::is_empty) { + continue; + } + profile.api_key_hash = Some(hash.to_string()); + } + AuthKind::Oauth => { + if let Some(email) = verification.user_email.as_deref() { + profile.email = Some(email.to_string()); + } + if let Some(user_name) = verification.user_name.as_deref() { + profile.user_name = Some(user_name.to_string()); + } + if profile.email.as_deref().is_none_or(str::is_empty) { + continue; + } + } + } + + changed |= replace_with_canonical_auth_profile(store, verification.name.as_str(), profile); + } + + if changed { + save_auth_store(store)?; + } + Ok(()) +} + fn format_verification_line(v: &ProfileVerification) -> String { let mut parts = vec![ v.org.clone().unwrap_or_else(|| "cross-org".to_string()), @@ -3559,13 +3816,29 @@ fn load_auth_store_from_path(path: &Path) -> Result { .with_context(|| format!("failed to read auth config {}", path.display()))?; let store: AuthStore = serde_json::from_str(&data) .with_context(|| format!("failed to parse auth config {}", path.display()))?; - Ok(migrate_auth_store(store)) + let migrated = migrate_auth_store(store.clone()); + if migrated != store { + save_auth_store_to_path(path, &migrated).with_context(|| { + format!("failed to persist auth config migration {}", path.display()) + })?; + } + Ok(migrated) } fn migrate_auth_store(store: AuthStore) -> AuthStore { let mut migrated = AuthStore::default(); for (old_key, mut profile) in store.profiles { normalize_profile_cached_fields_from_key(&old_key, &mut profile); + if profile.auth_kind == AuthKind::Oauth + && profile.org_id.is_none() + && profile + .org_name + .as_deref() + .is_none_or(|org| org.trim().is_empty()) + { + profile.org_id = Some(String::new()); + profile.org_name = None; + } let new_key = canonical_profile_key(&old_key, &profile); if new_key != old_key && profile.legacy_secret_key.is_none() { profile.legacy_secret_key = Some(old_key.clone()); @@ -3605,9 +3878,7 @@ fn normalize_profile_cached_fields_from_key(current_key: &str, profile: &mut Aut AuthKind::Oauth => { let key_matches_email = profile.email.as_deref() == Some(right); if key_matches_email || (profile.email.is_none() && right.contains('@')) { - if profile.org_id.is_none() { - profile.org_id = Some(left.to_string()); - } + profile.org_id = Some(left.to_string()); if profile.email.is_none() { profile.email = Some(right.to_string()); } @@ -3615,12 +3886,8 @@ fn normalize_profile_cached_fields_from_key(current_key: &str, profile: &mut Aut } AuthKind::ApiKey => { if looks_like_sha256_hex(left) && !right.trim().is_empty() { - if profile.api_key_hash.is_none() { - profile.api_key_hash = Some(left.to_string()); - } - if profile.org_id.is_none() { - profile.org_id = Some(right.to_string()); - } + profile.api_key_hash = Some(left.to_string()); + profile.org_id = Some(right.to_string()); } } } @@ -4238,6 +4505,105 @@ mod tests { assert_eq!(info.org_name, None); } + fn save_cached_oauth_login(store: &mut AuthStore, org_id: &str, org_name: &str) -> String { + let slot_key = oauth_slot_key(org_id, "user@example.test"); + store.profiles.insert( + slot_key.clone(), + AuthProfile { + auth_kind: AuthKind::Oauth, + api_url: Some("https://api.example.test".to_string()), + app_url: Some("https://www.example.test".to_string()), + org_id: Some(org_id.to_string()), + org_name: Some(org_name.to_string()), + oauth_client_id: Some("bt_cli".to_string()), + oauth_access_expires_at: Some(current_unix_timestamp() + 3600), + user_name: Some("Test User".to_string()), + email: Some("user@example.test".to_string()), + ..Default::default() + }, + ); + save_profile_secret_plaintext( + &oauth_access_secret_key(&slot_key), + "cached-oauth-access-token", + ) + .expect("save cached OAuth token"); + slot_key + } + + #[tokio::test] + async fn auth_precedence_keeps_env_api_key_below_oauth() { + let _env = TestEnv::new(None, None).await; + let mut store = AuthStore::default(); + save_cached_oauth_login(&mut store, "org_fake", "test-org"); + save_auth_store(&store).expect("save auth store"); + let mut base = make_base(); + base.org_name = Some("test-org".to_string()); + base.api_key = Some("environment-api-key".to_string()); + base.api_key_source = Some(crate::args::ArgValueSource::EnvVariable); + + let resolved = resolve_auth(&base).await.expect("resolve auth"); + + assert!(resolved.is_oauth); + assert_eq!( + resolved.api_key.as_deref(), + Some("cached-oauth-access-token") + ); + } + + #[tokio::test] + async fn auth_precedence_cli_api_key_overrides_oauth() { + let _env = TestEnv::new(None, None).await; + let mut store = AuthStore::default(); + save_cached_oauth_login(&mut store, "org_fake", "test-org"); + save_auth_store(&store).expect("save auth store"); + let mut base = make_base(); + base.org_name = Some("test-org".to_string()); + base.api_key = Some("command-line-api-key".to_string()); + base.api_key_source = Some(crate::args::ArgValueSource::CommandLine); + + let resolved = resolve_auth(&base).await.expect("resolve auth"); + + assert!(!resolved.is_oauth); + assert_eq!(resolved.api_key.as_deref(), Some("command-line-api-key")); + } + + #[tokio::test] + async fn auth_precedence_prefer_api_key_promotes_env_api_key() { + let _env = TestEnv::new(None, None).await; + let mut store = AuthStore::default(); + save_cached_oauth_login(&mut store, "org_fake", "test-org"); + save_auth_store(&store).expect("save auth store"); + let mut base = make_base(); + base.org_name = Some("test-org".to_string()); + base.api_key = Some("environment-api-key".to_string()); + base.api_key_source = Some(crate::args::ArgValueSource::EnvVariable); + base.prefer_api_key = true; + + let resolved = resolve_auth(&base).await.expect("resolve auth"); + + assert!(!resolved.is_oauth); + assert_eq!(resolved.api_key.as_deref(), Some("environment-api-key")); + } + + #[tokio::test] + async fn auth_precedence_prefer_api_key_falls_back_to_oauth_without_key() { + let _env = TestEnv::new(None, None).await; + let mut store = AuthStore::default(); + save_cached_oauth_login(&mut store, "org_fake", "test-org"); + save_auth_store(&store).expect("save auth store"); + let mut base = make_base(); + base.org_name = Some("test-org".to_string()); + base.prefer_api_key = true; + + let resolved = resolve_auth(&base).await.expect("resolve auth"); + + assert!(resolved.is_oauth); + assert_eq!( + resolved.api_key.as_deref(), + Some("cached-oauth-access-token") + ); + } + #[tokio::test] async fn active_auth_info_prefer_api_key_selects_stored_key_for_org() { let _env = TestEnv::new(None, None).await; @@ -4356,6 +4722,34 @@ mod tests { assert_eq!(profile.oauth_client_id.as_deref(), Some("bt_cli_work")); } + #[test] + fn migrate_auth_store_rekeys_cross_org_oauth_with_empty_org_id() { + let mut store = AuthStore::default(); + store.profiles.insert( + "legacy-cross-org".to_string(), + AuthProfile { + auth_kind: AuthKind::Oauth, + org_name: None, + email: Some("user@example.test".to_string()), + oauth_client_id: Some("bt_cli_legacy".to_string()), + ..Default::default() + }, + ); + + let migrated = migrate_auth_store(store); + let profile = migrated + .profiles + .get(&oauth_slot_key("", "user@example.test")) + .expect("cross-org OAuth slot"); + + assert_eq!(profile.org_id.as_deref(), Some("")); + assert_eq!( + profile.legacy_secret_key.as_deref(), + Some("legacy-cross-org") + ); + assert_eq!(profile.oauth_client_id.as_deref(), Some("bt_cli_legacy")); + } + #[test] fn migrate_auth_store_rekeys_api_key_slots_by_hash_and_org() { let hash = api_key_hash("test-api-key"); @@ -4380,6 +4774,113 @@ mod tests { assert_eq!(profile.api_key_hint.as_deref(), Some("test-****i-key")); } + #[test] + fn load_auth_store_persists_canonical_slot_migration() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let dir = std::env::temp_dir().join(format!("bt-auth-migration-test-{unique}")); + fs::create_dir_all(&dir).expect("create dir"); + let path = dir.join("auth.json"); + + let mut store = AuthStore::default(); + store.profiles.insert( + "legacy-login".to_string(), + AuthProfile { + auth_kind: AuthKind::Oauth, + org_id: Some("org_fake".to_string()), + org_name: Some("test-org".to_string()), + email: Some("user@example.test".to_string()), + oauth_client_id: Some("bt_cli_legacy".to_string()), + ..Default::default() + }, + ); + save_auth_store_to_path(&path, &store).expect("save legacy store"); + + let loaded = load_auth_store_from_path(&path).expect("load and migrate"); + let persisted: AuthStore = + serde_json::from_str(&fs::read_to_string(&path).expect("read migrated store")) + .expect("parse migrated store"); + let slot_key = oauth_slot_key("org_fake", "user@example.test"); + + for migrated in [&loaded, &persisted] { + let profile = migrated + .profiles + .get(&slot_key) + .expect("canonical OAuth slot"); + assert_eq!(profile.legacy_secret_key.as_deref(), Some("legacy-login")); + assert_eq!(profile.oauth_client_id.as_deref(), Some("bt_cli_legacy")); + } + + let _ = fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn verified_legacy_slots_gain_stable_keys() { + let _env = TestEnv::new(None, None).await; + let mut store = AuthStore::default(); + store.profiles.insert( + "legacy-oauth".to_string(), + AuthProfile { + auth_kind: AuthKind::Oauth, + org_name: Some("test-org".to_string()), + oauth_client_id: Some("bt_cli_legacy".to_string()), + ..Default::default() + }, + ); + store.profiles.insert( + "legacy-api-key".to_string(), + AuthProfile { + auth_kind: AuthKind::ApiKey, + org_name: Some("test-org".to_string()), + api_key_hint: Some("sk-****abcde".to_string()), + ..Default::default() + }, + ); + let hash = api_key_hash("test-api-key"); + let verifications = vec![ + ProfileVerification { + name: "legacy-oauth".to_string(), + slot_hash: None, + auth: "oauth".to_string(), + org: Some("test-org".to_string()), + org_id: Some("org_fake".to_string()), + user_name: Some("Test User".to_string()), + user_email: Some("user@example.test".to_string()), + api_key_hint: None, + status: "ok".to_string(), + error: None, + }, + ProfileVerification { + name: "legacy-api-key".to_string(), + slot_hash: Some(hash.clone()), + auth: "api_key".to_string(), + org: Some("test-org".to_string()), + org_id: Some("org_fake".to_string()), + user_name: None, + user_email: None, + api_key_hint: Some("sk-****abcde".to_string()), + status: "ok".to_string(), + error: None, + }, + ]; + + reconcile_verified_auth_slots(&mut store, &verifications) + .expect("reconcile verified slots"); + + let oauth = store + .profiles + .get(&oauth_slot_key("org_fake", "user@example.test")) + .expect("canonical OAuth slot"); + assert_eq!(oauth.legacy_secret_key.as_deref(), Some("legacy-oauth")); + let api_key = store + .profiles + .get(&api_key_slot_key(&hash, "org_fake")) + .expect("canonical API-key slot"); + assert_eq!(api_key.legacy_secret_key.as_deref(), Some("legacy-api-key")); + } + #[test] fn migrate_auth_store_dedupes_oauth_slots_by_latest_expiry() { let mut store = AuthStore::default(); @@ -4641,6 +5142,7 @@ mod tests { fn format_verification_line_ok_with_identity() { let v = ProfileVerification { name: "work".into(), + slot_hash: None, auth: "oauth".into(), org: Some("acme".into()), org_id: None, @@ -4660,6 +5162,7 @@ mod tests { fn format_verification_line_ok_with_api_key_hint() { let v = ProfileVerification { name: "work".into(), + slot_hash: Some(api_key_hash("test-api-key")), auth: "api_key".into(), org: Some("acme".into()), org_id: None, @@ -4679,6 +5182,7 @@ mod tests { fn format_verification_line_expired() { let v = ProfileVerification { name: "old".into(), + slot_hash: None, auth: "oauth".into(), org: None, org_id: None, @@ -4698,6 +5202,7 @@ mod tests { fn format_verification_line_error() { let v = ProfileVerification { name: "bad".into(), + slot_hash: None, auth: "api_key".into(), org: Some("corp".into()), org_id: None, diff --git a/src/config/mod.rs b/src/config/mod.rs index b6dc85fb..26493ada 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -522,6 +522,21 @@ mod tests { assert!(config.extra.contains_key("another")); } + #[test] + fn legacy_profile_key_is_ignored_and_not_persisted() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("config.json"); + fs::write(&path, r#"{"org":"test-org","profile":"legacy-login"}"#).unwrap(); + + let config = load_file(&path); + assert_eq!(config.org.as_deref(), Some("test-org")); + assert!(!config.extra.contains_key("profile")); + + save_file(&path, &config).unwrap(); + let persisted = fs::read_to_string(&path).unwrap(); + assert!(!persisted.contains("profile")); + } + #[test] fn unknown_keys_roundtrip_through_save() { let tmp = TempDir::new().unwrap(); diff --git a/src/init.rs b/src/init.rs index f9e1e03c..c03594a6 100644 --- a/src/init.rs +++ b/src/init.rs @@ -64,6 +64,9 @@ pub async fn run(base: BaseArgs, _args: InitArgs) -> Result<()> { bail!("--org and --project required in non-interactive mode"); } else { let mut login_base = base.clone(); + if login_base.org_name.is_none() { + login_base.org_name = config::load().ok().and_then(|cfg| cfg.org); + } if login_base.org_name.is_none() { login_base.org_name = select_saved_auth_org_for_init()?; } diff --git a/src/switch.rs b/src/switch.rs index 021ccc16..40fed377 100644 --- a/src/switch.rs +++ b/src/switch.rs @@ -57,11 +57,10 @@ pub async fn run(base: BaseArgs, args: SwitchArgs) -> Result<()> { let mut login_base = base.clone(); if login_base.org_name.is_none() { - let saved_org = select_saved_auth_org_for_switch()?; - login_base.org_name = resolved_org - .clone() - .or_else(|| current_cfg.org.clone()) - .or(saved_org); + login_base.org_name = resolved_org.clone().or_else(|| current_cfg.org.clone()); + if login_base.org_name.is_none() { + login_base.org_name = select_saved_auth_org_for_switch()?; + } } let ctx = login(&login_base).await?; diff --git a/tests/functions.rs b/tests/functions.rs index f91c25d2..84a9973f 100644 --- a/tests/functions.rs +++ b/tests/functions.rs @@ -793,6 +793,25 @@ fn auth_logout_json_with_no_profiles_emits_empty_status() { assert_eq!(stdout, r#"{"status":"empty"}"#); } +#[test] +fn auth_logout_requires_force_without_an_interactive_terminal() { + let cwd = tempdir().expect("create temp cwd"); + let config_dir = tempdir().expect("create temp config dir"); + seed_api_key_profile(config_dir.path()); + + let output = auth_sub_command( + cwd.path(), + config_dir.path(), + &["logout", "--org", "test-org", "--no-input"], + ) + .output() + .expect("run non-interactive auth logout"); + + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("rerun with --force")); + assert!(config_dir.path().join("bt").join("auth.json").exists()); +} + #[test] fn auth_refresh_errors_when_no_oauth_login_exists_even_with_json() { // --json must not swallow a real error: refresh only applies to OAuth From fd70cdf352d6b255cb0fe6f97b40b420d3dc4198 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Thu, 16 Jul 2026 15:27:28 -0700 Subject: [PATCH 4/8] chore: remove dead code --- src/auth.rs | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 9833bfad..b7f9b3be 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -363,7 +363,6 @@ pub async fn login_read_only(base: &BaseArgs) -> Result { /// Build login context from stored auth without forcing a login validation request. /// Use for read-oriented flows where downstream API calls can surface auth errors. pub async fn fast_login(base: &BaseArgs) -> Result { - maybe_warn_api_key_override(base); let auth = resolve_auth(base).await?; let api_key = auth.api_key.clone().ok_or_else(|| { anyhow::anyhow!( @@ -397,7 +396,6 @@ pub async fn fast_login(base: &BaseArgs) -> Result { } pub async fn login(base: &BaseArgs) -> Result { - maybe_warn_api_key_override(base); let auth = resolve_auth(base).await?; let api_key = auth.api_key.clone().ok_or_else(|| { anyhow::anyhow!( @@ -732,8 +730,6 @@ fn is_unauthorized_auth_error(err: &anyhow::Error) -> bool { }) } -fn maybe_warn_api_key_override(_base: &BaseArgs) {} - fn resolve_cli_api_key_override(base: &BaseArgs) -> Option { if matches!( base.api_key_source, @@ -3623,13 +3619,6 @@ fn save_profile_oauth_refresh_token(profile_name: &str, refresh_token: &str) -> save_profile_secret(&key, refresh_token) } -#[cfg(test)] -#[allow(dead_code)] -fn load_profile_oauth_refresh_token(profile_name: &str) -> Result> { - let key = oauth_refresh_secret_key(profile_name); - load_profile_secret(&key) -} - fn load_profile_oauth_refresh_token_for_profile( profile_name: &str, profile: &AuthProfile, @@ -3652,13 +3641,6 @@ fn save_profile_oauth_access_token(profile_name: &str, access_token: &str) -> Re save_profile_secret(&key, access_token) } -#[cfg(test)] -#[allow(dead_code)] -fn load_profile_oauth_access_token(profile_name: &str) -> Result> { - let key = oauth_access_secret_key(profile_name); - load_profile_secret(&key) -} - fn load_profile_oauth_access_token_for_profile( profile_name: &str, profile: &AuthProfile, @@ -3789,12 +3771,6 @@ fn api_key_slot_key(api_key_hash: &str, org_id: &str) -> String { format!("{api_key_hash}::{org_id}") } -#[cfg(test)] -#[allow(dead_code)] -fn split_slot_key(slot_key: &str) -> Option<(&str, &str)> { - slot_key.split_once("::") -} - fn current_unix_timestamp() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) From b18e27ea8a8a94315f7aed89383b71161265fc0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Thu, 16 Jul 2026 16:06:17 -0700 Subject: [PATCH 5/8] chore: shared helper for bt status/login to prevent drift remove dead code --- src/auth.rs | 357 +++++++++++++++++++++++++++++++--------------------- 1 file changed, 216 insertions(+), 141 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index b7f9b3be..7055a346 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -776,101 +776,111 @@ fn effective_org_name<'a>(base: &'a BaseArgs, cfg_org: &'a Option) -> Op .or_else(|| crate::config::trimmed_option(cfg_org.as_deref())) } -pub async fn resolve_auth(base: &BaseArgs) -> Result { - let mut store = load_auth_store()?; - let cfg_org = config_auth_context(base); - - if let Some(api_key) = resolve_cli_api_key_override(base) { - return Ok(ResolvedAuth { - api_key: Some(api_key), - api_url: base.api_url.clone(), - app_url: base.app_url.clone(), - org_name: effective_org_name(base, &cfg_org).map(str::to_string), - is_oauth: false, - slot_key: None, - }); - } - - if base.prefer_api_key { - if let Some(auth) = resolve_preferred_api_key_auth(base, &mut store, &cfg_org)? { - return Ok(auth); +/// The auth source selected by the precedence ladder, before any live +/// credential is fetched. `resolve_auth` turns this into a `ResolvedAuth` +/// (fetching/refreshing tokens); `active_auth_info` turns it into a +/// `ProfileInfo` for display. Both share [`resolve_auth_source`] so the +/// precedence order documented in the README lives in exactly one place. +#[derive(Debug, Clone, PartialEq, Eq)] +enum AuthSource { + CliApiKey(String), + EnvApiKey(String), + Oauth(String), + ApiKey(String), + None, +} + +/// Pure auth-source precedence ladder (README "Auth resolution order"): +/// 1. explicit `--api-key` +/// 2. `--prefer-api-key`: `BRAINTRUST_API_KEY` → stored API key → OAuth fallback +/// 3. stored OAuth login for the selected org +/// 4. stored API key login for the selected org +/// 5. `BRAINTRUST_API_KEY` +/// +/// The slot selectors return `Ok(None)` when no candidate matches (the ladder +/// continues) and may return `Err` for an ambiguous selection that neither +/// caller can resolve without prompting (the ladder stops). +fn resolve_auth_source( + prefer_api_key: bool, + cli_api_key: Option, + env_api_key: impl Fn() -> Option, + select_oauth: impl Fn() -> Result>, + select_api_key: impl Fn() -> Result>, +) -> Result { + if let Some(api_key) = cli_api_key { + return Ok(AuthSource::CliApiKey(api_key)); + } + + if prefer_api_key { + if let Some(api_key) = env_api_key() { + return Ok(AuthSource::EnvApiKey(api_key)); } - if let Some(profile_name) = - select_oauth_profile_for_auth(base, &store, &cfg_org, ui::can_prompt())? - { - return resolve_oauth_profile_auth(base, &mut store, &cfg_org, &profile_name).await; + if let Some(slot) = select_api_key()? { + return Ok(AuthSource::ApiKey(slot)); } - bail!("--prefer-api-key requires an API key or OAuth login for the selected org"); + if let Some(slot) = select_oauth()? { + return Ok(AuthSource::Oauth(slot)); + } + return Ok(AuthSource::None); } - if let Some(profile_name) = - select_oauth_profile_for_auth(base, &store, &cfg_org, ui::can_prompt())? - { - return resolve_oauth_profile_auth(base, &mut store, &cfg_org, &profile_name).await; + if let Some(slot) = select_oauth()? { + return Ok(AuthSource::Oauth(slot)); } - - if let Some(profile_name) = - select_api_key_profile_for_auth(base, &store, &cfg_org, ui::can_prompt())? - { - return resolve_api_key_profile_auth(base, &mut store, &cfg_org, &profile_name); + if let Some(slot) = select_api_key()? { + return Ok(AuthSource::ApiKey(slot)); } - - if let Some(api_key) = resolve_env_api_key(base) { - return Ok(ResolvedAuth { - api_key: Some(api_key), - api_url: base.api_url.clone(), - app_url: base.app_url.clone(), - org_name: effective_org_name(base, &cfg_org).map(str::to_string), - is_oauth: false, - slot_key: None, - }); + if let Some(api_key) = env_api_key() { + return Ok(AuthSource::EnvApiKey(api_key)); } - - if effective_org_name(base, &cfg_org).is_none() { - if let Some(err) = missing_org_for_stored_logins_error(&store) { - return Err(err); - } - } - - Ok(ResolvedAuth { - api_key: None, - api_url: base.api_url.clone(), - app_url: base.app_url.clone(), - org_name: effective_org_name(base, &cfg_org).map(str::to_string), - is_oauth: false, - slot_key: None, - }) + Ok(AuthSource::None) } -fn resolve_preferred_api_key_auth( - base: &BaseArgs, - store: &mut AuthStore, - cfg_org: &Option, -) -> Result> { - let Some(org) = effective_org_name(base, cfg_org) else { - bail!( - "--prefer-api-key requires an org; pass --org or run `bt switch /`" - ); - }; +pub async fn resolve_auth(base: &BaseArgs) -> Result { + let mut store = load_auth_store()?; + let cfg_org = config_auth_context(base); + let can_prompt = ui::can_prompt(); + + let source = resolve_auth_source( + base.prefer_api_key, + resolve_cli_api_key_override(base), + || resolve_env_api_key(base), + || select_oauth_profile_for_auth(base, &store, &cfg_org, can_prompt), + || select_api_key_profile_for_auth(base, &store, &cfg_org, can_prompt), + )?; - if let Some(api_key) = resolve_env_api_key(base) { - return Ok(Some(ResolvedAuth { + match source { + AuthSource::CliApiKey(api_key) | AuthSource::EnvApiKey(api_key) => Ok(ResolvedAuth { api_key: Some(api_key), api_url: base.api_url.clone(), app_url: base.app_url.clone(), - org_name: Some(org.to_string()), + org_name: effective_org_name(base, &cfg_org).map(str::to_string), is_oauth: false, slot_key: None, - })); - } - - if let Some(profile_name) = - select_api_key_profile_for_auth(base, store, cfg_org, ui::can_prompt())? - { - return resolve_api_key_profile_auth(base, store, cfg_org, &profile_name).map(Some); + }), + AuthSource::Oauth(slot) => { + resolve_oauth_profile_auth(base, &mut store, &cfg_org, &slot).await + } + AuthSource::ApiKey(slot) => resolve_api_key_profile_auth(base, &mut store, &cfg_org, &slot), + AuthSource::None => { + if base.prefer_api_key { + bail!("--prefer-api-key requires an API key or OAuth login for the selected org"); + } + if effective_org_name(base, &cfg_org).is_none() { + if let Some(err) = missing_org_for_stored_logins_error(&store) { + return Err(err); + } + } + Ok(ResolvedAuth { + api_key: None, + api_url: base.api_url.clone(), + app_url: base.app_url.clone(), + org_name: effective_org_name(base, &cfg_org).map(str::to_string), + is_oauth: false, + slot_key: None, + }) + } } - - Ok(None) } fn resolve_api_key_profile_auth( @@ -893,7 +903,7 @@ fn resolve_api_key_profile_auth( RecoverableAuthErrorKind::StoredCredential, format!( "no keychain credential found for auth login '{}'; re-run `bt auth login --org --api-key `", - auth_slot_label(profile_name, &profile) + auth_slot_label(&profile) ), ) })?; @@ -1089,7 +1099,7 @@ async fn resolve_oauth_profile_auth( RecoverableAuthErrorKind::OauthClientId, format!( "oauth login for '{}' is missing client_id; re-run `bt auth login --oauth --org `", - auth_slot_label(profile_name, &profile) + auth_slot_label(&profile) ), ) })?; @@ -1127,11 +1137,11 @@ async fn resolve_oauth_profile_auth( RecoverableAuthErrorKind::OauthRefreshToken, format!( "oauth refresh token missing for '{}'; re-run `bt auth login --oauth --org `", - auth_slot_label(profile_name, &profile) + auth_slot_label(&profile) ), ) })?; - let login_label = auth_slot_label(profile_name, &profile); + let login_label = auth_slot_label(&profile); let refreshed = refresh_oauth_access_token(&api_url, &refresh_token, client_id, &login_label).await?; save_profile_oauth_access_token(profile_name, &refreshed.access_token)?; @@ -1220,7 +1230,7 @@ fn profile_identity_label(profile: &AuthProfile) -> Option { } } -fn auth_slot_label(_name: &str, profile: &AuthProfile) -> String { +fn auth_slot_label(profile: &AuthProfile) -> String { let mut parts = vec![profile_org_label(profile)]; parts.push(auth_kind_label(profile.auth_kind).to_string()); if let Some(identity) = profile_identity_label(profile) { @@ -1284,48 +1294,35 @@ fn ad_hoc_api_key_profile(org: Option<&str>, api_key: &str) -> ProfileInfo { } pub(crate) fn active_auth_info(base: &BaseArgs, org: Option<&str>) -> Option { - if let Some(api_key) = resolve_cli_api_key_override(base) { - return Some(ad_hoc_api_key_profile(org, &api_key)); - } - let store = load_auth_store().unwrap_or_default(); - if base.prefer_api_key { - let org = org?; - if let Some(api_key) = resolve_env_api_key(base) { - return Some(ad_hoc_api_key_profile(Some(org), &api_key)); - } - - let api_key_candidates = auth_profile_names_by_kind(&store, Some(org), AuthKind::ApiKey); - match api_key_candidates.as_slice() { - [name] => return profile_info_for_candidate(&store, name), - [] => {} - _ => return None, - } - - let oauth_candidates = auth_profile_names_by_kind(&store, Some(org), AuthKind::Oauth); - return match oauth_candidates.as_slice() { - [name] => profile_info_for_candidate(&store, name), - [] => None, - _ => None, - }; - } + // Display-side mirror of the interactive selectors: a lone candidate is + // chosen, no candidates continue the ladder, and an ambiguous set stops it + // (a real command would prompt or error, so status shows nothing here). + let select = |kind| match auth_profile_names_by_kind(&store, org, kind).as_slice() { + [] => Ok(None), + [name] => Ok(Some((*name).to_string())), + _ => bail!("multiple {kind:?} logins"), + }; - let oauth_candidates = auth_profile_names_by_kind(&store, org, AuthKind::Oauth); - match oauth_candidates.as_slice() { - [name] => return profile_info_for_candidate(&store, name), - [] => {} - _ => return None, - } + let source = resolve_auth_source( + base.prefer_api_key, + resolve_cli_api_key_override(base), + || resolve_env_api_key(base), + || select(AuthKind::Oauth), + || select(AuthKind::ApiKey), + ) + .ok()?; - let api_key_candidates = auth_profile_names_by_kind(&store, org, AuthKind::ApiKey); - match api_key_candidates.as_slice() { - [name] => return profile_info_for_candidate(&store, name), - [] => {} - _ => return None, + match source { + AuthSource::CliApiKey(api_key) | AuthSource::EnvApiKey(api_key) => { + Some(ad_hoc_api_key_profile(org, &api_key)) + } + AuthSource::Oauth(slot) | AuthSource::ApiKey(slot) => { + profile_info_for_candidate(&store, &slot) + } + AuthSource::None => None, } - - resolve_env_api_key(base).map(|api_key| ad_hoc_api_key_profile(org, &api_key)) } fn missing_org_for_stored_logins_error(store: &AuthStore) -> Option { @@ -1350,7 +1347,7 @@ fn missing_org_for_stored_logins_error(store: &AuthStore) -> Option>() .join(", "); let all_api_key = candidates @@ -1376,7 +1373,7 @@ fn profile_label_from_store(name: &str, store: &AuthStore) -> String { store .profiles .get(name) - .map(|profile| auth_slot_label(name, profile)) + .map(auth_slot_label) .unwrap_or_else(|| "saved auth login".to_string()) } @@ -1415,7 +1412,7 @@ fn candidate_identities<'a>(names: &[&'a str], store: &'a AuthStore) -> Vec Result<()> { let selected_api_url = resolve_profile_api_url(base.api_url.clone(), Some(&selected_org), &login_orgs)?; - let _slot_key = commit_api_key_profile( + commit_api_key_profile( &api_key, selected_api_url.clone(), base.app_url.clone(), @@ -1648,7 +1645,7 @@ async fn run_login_oauth(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> { let selected_api_url = resolve_profile_api_url(base.api_url.clone(), selected_org.as_ref(), &login_orgs)?; - let _slot_key = commit_oauth_profile( + commit_oauth_profile( &oauth_tokens, selected_api_url.clone(), app_url.clone(), @@ -1696,7 +1693,7 @@ pub(crate) fn commit_api_key_profile( app_url: Option, org_id: String, org_name: String, -) -> Result { +) -> Result<()> { let hash = api_key_hash(api_key); let slot_key = api_key_slot_key(&hash, &org_id); save_profile_secret(&slot_key, api_key)?; @@ -1706,7 +1703,7 @@ pub(crate) fn commit_api_key_profile( delete_legacy_profile_secrets(old_profile); } store.profiles.insert( - slot_key.clone(), + slot_key, AuthProfile { auth_kind: AuthKind::ApiKey, api_url: Some(api_url), @@ -1722,8 +1719,7 @@ pub(crate) fn commit_api_key_profile( legacy_secret_key: None, }, ); - save_auth_store(&store)?; - Ok(slot_key) + save_auth_store(&store) } fn commit_oauth_profile( @@ -1732,7 +1728,7 @@ fn commit_oauth_profile( app_url: String, client_id: String, selected_org: Option<&LoginOrgInfo>, -) -> Result { +) -> Result<()> { let refresh_token = tokens.refresh_token.as_ref().ok_or_else(|| { anyhow::anyhow!( "oauth token response did not include a refresh_token; cannot create persistent oauth login" @@ -1762,7 +1758,7 @@ fn commit_oauth_profile( delete_legacy_profile_secrets(old_profile); } store.profiles.insert( - slot_key.clone(), + slot_key, AuthProfile { auth_kind: AuthKind::Oauth, api_url: Some(api_url), @@ -1778,8 +1774,7 @@ fn commit_oauth_profile( legacy_secret_key: None, }, ); - save_auth_store(&store)?; - Ok(slot_key) + save_auth_store(&store) } async fn run_login_refresh(base: &BaseArgs) -> Result<()> { @@ -1806,7 +1801,7 @@ async fn run_login_refresh(base: &BaseArgs) -> Result<()> { let client_id = profile.oauth_client_id.clone().ok_or_else(|| { anyhow::anyhow!( "OAuth login for '{}' is missing client_id; re-run `bt auth login --oauth --org `", - auth_slot_label(&profile_name, &profile) + auth_slot_label(&profile) ) })?; let previous_expires_at = profile.oauth_access_expires_at; @@ -1815,14 +1810,14 @@ async fn run_login_refresh(base: &BaseArgs) -> Result<()> { || { anyhow::anyhow!( "OAuth refresh token missing for '{}'; re-run `bt auth login --oauth --org `", - auth_slot_label(&profile_name, &profile) + auth_slot_label(&profile) ) }, )?; eprintln!( "Refreshing OAuth token for {} (api_url: {api_url})", - auth_slot_label(&profile_name, &profile) + auth_slot_label(&profile) ); if let Some(expires_at) = previous_expires_at { let now = current_unix_timestamp(); @@ -1834,7 +1829,7 @@ async fn run_login_refresh(base: &BaseArgs) -> Result<()> { eprintln!("Cached access token expiry before refresh: unknown"); } - let login_label = auth_slot_label(&profile_name, &profile); + let login_label = auth_slot_label(&profile); let refreshed = refresh_oauth_access_token(&api_url, &refresh_token, &client_id, &login_label).await?; save_profile_oauth_access_token(profile_name.as_str(), &refreshed.access_token)?; @@ -2062,7 +2057,7 @@ fn run_login_delete(profile_name: &str, force: bool, base_json: bool) -> Result< let profile = store.profiles.get(profile_name).cloned().ok_or_else(|| { anyhow::anyhow!("auth login not found; run `bt auth profiles` to see available logins") })?; - let label = auth_slot_label(profile_name, &profile); + let label = auth_slot_label(&profile); if !force { let term = ui::prompt_term().ok_or_else(|| { @@ -2439,8 +2434,8 @@ fn print_saved_profiles(store: &AuthStore, json: bool) -> Result<()> { .collect(); println!("{}", serde_json::to_string(&output)?); } else { - for (name, profile) in &store.profiles { - println!(" {}", auth_slot_label(name, profile)); + for profile in store.profiles.values() { + println!(" {}", auth_slot_label(profile)); } } Ok(()) @@ -4952,6 +4947,86 @@ mod tests { } } + fn auth_source( + prefer_api_key: bool, + cli: Option<&str>, + env: Option<&str>, + oauth: Option<&str>, + api_key: Option<&str>, + ) -> AuthSource { + resolve_auth_source( + prefer_api_key, + cli.map(str::to_string), + || env.map(str::to_string), + || Ok(oauth.map(str::to_string)), + || Ok(api_key.map(str::to_string)), + ) + .expect("resolve auth source") + } + + #[test] + fn auth_source_cli_api_key_wins_over_everything() { + assert_eq!( + auth_source(false, Some("cli"), Some("env"), Some("oauth"), Some("ak")), + AuthSource::CliApiKey("cli".into()) + ); + assert_eq!( + auth_source(true, Some("cli"), Some("env"), Some("oauth"), Some("ak")), + AuthSource::CliApiKey("cli".into()) + ); + } + + #[test] + fn auth_source_default_order_is_oauth_then_api_key_then_env() { + assert_eq!( + auth_source(false, None, Some("env"), Some("oauth"), Some("ak")), + AuthSource::Oauth("oauth".into()) + ); + assert_eq!( + auth_source(false, None, Some("env"), None, Some("ak")), + AuthSource::ApiKey("ak".into()) + ); + assert_eq!( + auth_source(false, None, Some("env"), None, None), + AuthSource::EnvApiKey("env".into()) + ); + assert_eq!( + auth_source(false, None, None, None, None), + AuthSource::None + ); + } + + #[test] + fn auth_source_prefer_api_key_order_is_env_then_api_key_then_oauth() { + assert_eq!( + auth_source(true, None, Some("env"), Some("oauth"), Some("ak")), + AuthSource::EnvApiKey("env".into()) + ); + assert_eq!( + auth_source(true, None, None, Some("oauth"), Some("ak")), + AuthSource::ApiKey("ak".into()) + ); + // No env/stored API key, but OAuth for the org is available: fall back to it. + assert_eq!( + auth_source(true, None, None, Some("oauth"), None), + AuthSource::Oauth("oauth".into()) + ); + assert_eq!(auth_source(true, None, None, None, None), AuthSource::None); + } + + #[test] + fn auth_source_ambiguous_selection_stops_the_ladder() { + let err = resolve_auth_source( + false, + None, + || None, + || bail!("multiple oauth logins"), + || Ok(Some("ak".to_string())), + ) + .expect_err("ambiguous oauth should stop the ladder"); + assert!(err.to_string().contains("multiple oauth logins")); + } + #[tokio::test] async fn persist_post_login_context_clears_stale_project_for_org_only_login() { let _env = TestEnv::new(None, None).await; From fb1ac8c7912729c7d2073f2b6fb8ca087acf3f54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Fri, 17 Jul 2026 11:26:00 -0700 Subject: [PATCH 6/8] chore(auth): remove client-id flag The field is needed by OAuth but it's unused both bt and the backend Therefore exposing it is useless Now bt_cli is hardcoded as its value --- src/auth.rs | 95 +++++++++------------------------------------------- tests/cli.rs | 9 +++++ 2 files changed, 24 insertions(+), 80 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 7055a346..a5ee496d 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -37,6 +37,7 @@ use crate::{ }; const KEYCHAIN_SERVICE: &str = "com.braintrust.bt.cli"; +const OAUTH_CLIENT_ID: &str = "bt_cli"; const OAUTH_SCOPE: &str = "mcp"; const OAUTH_CALLBACK_TIMEOUT: Duration = Duration::from_secs(300); const OAUTH_REFRESH_SAFETY_WINDOW_SECONDS: u64 = 60; @@ -79,7 +80,6 @@ pub struct AvailableOrg { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RecoverableAuthErrorKind { - OauthClientId, OauthRefreshToken, StoredCredential, } @@ -109,8 +109,7 @@ pub fn is_missing_credential_error(err: &anyhow::Error) -> bool { .is_some_and(|err| { matches!( err.kind, - RecoverableAuthErrorKind::OauthClientId - | RecoverableAuthErrorKind::OauthRefreshToken + RecoverableAuthErrorKind::OauthRefreshToken | RecoverableAuthErrorKind::StoredCredential ) }) @@ -205,8 +204,6 @@ struct AuthProfile { #[serde(default)] org_name: Option, #[serde(default)] - oauth_client_id: Option, - #[serde(default)] oauth_access_expires_at: Option, #[serde(default)] user_name: Option, @@ -313,10 +310,6 @@ struct AuthLoginArgs { #[arg(long)] oauth: bool, - /// OAuth client id (defaults to bt_cli) - #[arg(long, value_name = "CLIENT_ID")] - client_id: Option, - /// Do not try to open a browser automatically #[arg(long)] no_browser: bool, @@ -1094,15 +1087,6 @@ async fn resolve_oauth_profile_auth( store.profiles.get(profile_name).cloned().ok_or_else(|| { anyhow::anyhow!("saved OAuth login not found; run `bt auth profiles`") })?; - let client_id = profile.oauth_client_id.as_deref().ok_or_else(|| { - recoverable_auth_error( - RecoverableAuthErrorKind::OauthClientId, - format!( - "oauth login for '{}' is missing client_id; re-run `bt auth login --oauth --org `", - auth_slot_label(&profile) - ), - ) - })?; let api_url = base .api_url .clone() @@ -1142,8 +1126,7 @@ async fn resolve_oauth_profile_auth( ) })?; let login_label = auth_slot_label(&profile); - let refreshed = - refresh_oauth_access_token(&api_url, &refresh_token, client_id, &login_label).await?; + let refreshed = refresh_oauth_access_token(&api_url, &refresh_token, &login_label).await?; save_profile_oauth_access_token(profile_name, &refreshed.access_token)?; let mut refresh_rotated = false; if let Some(next_refresh_token) = refreshed.refresh_token.as_ref() { @@ -1411,8 +1394,7 @@ fn candidate_identities<'a>(names: &[&'a str], store: &'a AuthStore) -> Vec Result<()> { .app_url .clone() .unwrap_or_else(|| DEFAULT_APP_URL.to_string()); - let client_id = args - .client_id - .clone() - .unwrap_or_else(default_oauth_client_id); - let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); let state = generate_random_token(32)?; let callback_server = bind_oauth_callback_server()?; let redirect_uri = callback_server.redirect_uri(); - let oauth_client = build_oauth_client(&api_url, &client_id, Some(&redirect_uri))?; + let oauth_client = build_oauth_client(&api_url, Some(&redirect_uri))?; let (authorize_url, _) = oauth_client .authorize_url(|| CsrfToken::new(state.clone())) .add_scope(Scope::new(OAUTH_SCOPE.to_string())) @@ -1624,14 +1601,9 @@ async fn run_login_oauth(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> { bail!("oauth state mismatch; please try again"); } - let oauth_tokens = exchange_oauth_authorization_code( - &api_url, - &client_id, - &redirect_uri, - &auth_code, - pkce_verifier, - ) - .await?; + let oauth_tokens = + exchange_oauth_authorization_code(&api_url, &redirect_uri, &auth_code, pkce_verifier) + .await?; let login_orgs = fetch_login_orgs(&oauth_tokens.access_token, &app_url).await?; let selected_org = select_login_org( login_orgs.clone(), @@ -1649,7 +1621,6 @@ async fn run_login_oauth(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> { &oauth_tokens, selected_api_url.clone(), app_url.clone(), - client_id.clone(), selected_org.as_ref(), )?; let context_update = persist_post_login_context( @@ -1710,7 +1681,6 @@ pub(crate) fn commit_api_key_profile( app_url, org_id: Some(org_id), org_name: Some(org_name), - oauth_client_id: None, oauth_access_expires_at: None, user_name: None, email: None, @@ -1726,7 +1696,6 @@ fn commit_oauth_profile( tokens: &OAuthTokenResponse, api_url: String, app_url: String, - client_id: String, selected_org: Option<&LoginOrgInfo>, ) -> Result<()> { let refresh_token = tokens.refresh_token.as_ref().ok_or_else(|| { @@ -1765,7 +1734,6 @@ fn commit_oauth_profile( app_url: Some(app_url), org_id: Some(org_id), org_name: selected_org.map(|org| org.name.clone()), - oauth_client_id: Some(client_id), oauth_access_expires_at, user_name: jwt_id.name, email: jwt_id.email, @@ -1798,12 +1766,6 @@ async fn run_login_refresh(base: &BaseArgs) -> Result<()> { .api_url .clone() .unwrap_or_else(|| DEFAULT_API_URL.to_string()); - let client_id = profile.oauth_client_id.clone().ok_or_else(|| { - anyhow::anyhow!( - "OAuth login for '{}' is missing client_id; re-run `bt auth login --oauth --org `", - auth_slot_label(&profile) - ) - })?; let previous_expires_at = profile.oauth_access_expires_at; let refresh_token = load_profile_oauth_refresh_token_for_profile(profile_name.as_str(), &profile)?.ok_or_else( @@ -1830,8 +1792,7 @@ async fn run_login_refresh(base: &BaseArgs) -> Result<()> { } let login_label = auth_slot_label(&profile); - let refreshed = - refresh_oauth_access_token(&api_url, &refresh_token, &client_id, &login_label).await?; + let refreshed = refresh_oauth_access_token(&api_url, &refresh_token, &login_label).await?; save_profile_oauth_access_token(profile_name.as_str(), &refreshed.access_token)?; let mut refresh_rotated = false; if let Some(next_refresh_token) = refreshed.refresh_token.as_ref() { @@ -2664,10 +2625,6 @@ fn resolve_profile_api_url( ) } -fn default_oauth_client_id() -> String { - "bt_cli".to_string() -} - fn generate_random_token(num_bytes: usize) -> Result { let mut bytes = vec![0u8; num_bytes]; getrandom::fill(&mut bytes) @@ -3024,7 +2981,6 @@ fn is_ssh_session() -> bool { async fn exchange_oauth_authorization_code( api_url: &str, - client_id: &str, redirect_uri: &str, code: &str, code_verifier: PkceCodeVerifier, @@ -3040,7 +2996,7 @@ async fn exchange_oauth_authorization_code( api_url, &[ ("grant_type", "authorization_code"), - ("client_id", client_id), + ("client_id", OAUTH_CLIENT_ID), ("code", code), ("redirect_uri", redirect_uri), ("code_verifier", code_verifier.secret()), @@ -3077,7 +3033,6 @@ fn map_refresh_oauth_error( async fn refresh_oauth_access_token( api_url: &str, refresh_token: &str, - client_id: &str, auth_login: &str, ) -> Result { let http_client = build_http_client_from_builder( @@ -3091,7 +3046,7 @@ async fn refresh_oauth_access_token( .post(&token_url) .form(&[ ("grant_type", "refresh_token"), - ("client_id", client_id), + ("client_id", OAUTH_CLIENT_ID), ("refresh_token", refresh_token), ]) .send() @@ -3133,18 +3088,14 @@ async fn request_oauth_token( .context("failed to parse oauth token response") } -fn build_oauth_client( - api_url: &str, - client_id: &str, - redirect_uri: Option<&str>, -) -> Result { +fn build_oauth_client(api_url: &str, redirect_uri: Option<&str>) -> Result { let api_url = api_url.trim_end_matches('/'); let auth_url = AuthUrl::new(format!("{api_url}/oauth/authorize")) .context("failed to construct oauth authorize URL")?; let token_url = TokenUrl::new(format!("{api_url}/oauth/token")) .context("failed to construct oauth token URL")?; let client = BasicClient::new( - ClientId::new(client_id.to_string()), + ClientId::new(OAUTH_CLIENT_ID.to_string()), None, auth_url, Some(token_url), @@ -4282,7 +4233,6 @@ mod tests { app_url: Some((*app_url).to_string()), org_id: None, org_name: Some((*org_name).to_string()), - oauth_client_id: None, oauth_access_expires_at: None, user_name: None, email: None, @@ -4486,7 +4436,6 @@ mod tests { app_url: Some("https://www.example.test".to_string()), org_id: Some(org_id.to_string()), org_name: Some(org_name.to_string()), - oauth_client_id: Some("bt_cli".to_string()), oauth_access_expires_at: Some(current_unix_timestamp() + 3600), user_name: Some("Test User".to_string()), email: Some("user@example.test".to_string()), @@ -4656,7 +4605,6 @@ mod tests { api_url: Some("https://api.example.com".to_string()), app_url: Some("https://www.example.com".to_string()), org_name: Some("Example Org".to_string()), - oauth_client_id: None, oauth_access_expires_at: None, ..Default::default() }, @@ -4680,7 +4628,6 @@ mod tests { org_id: Some("org_fake".to_string()), org_name: Some("test-org".to_string()), email: Some("user@example.test".to_string()), - oauth_client_id: Some("bt_cli_work".to_string()), ..Default::default() }, ); @@ -4690,7 +4637,6 @@ mod tests { let profile = migrated.profiles.get(&key).expect("migrated profile"); assert_eq!(profile.legacy_secret_key.as_deref(), Some("work")); - assert_eq!(profile.oauth_client_id.as_deref(), Some("bt_cli_work")); } #[test] @@ -4702,7 +4648,6 @@ mod tests { auth_kind: AuthKind::Oauth, org_name: None, email: Some("user@example.test".to_string()), - oauth_client_id: Some("bt_cli_legacy".to_string()), ..Default::default() }, ); @@ -4718,7 +4663,6 @@ mod tests { profile.legacy_secret_key.as_deref(), Some("legacy-cross-org") ); - assert_eq!(profile.oauth_client_id.as_deref(), Some("bt_cli_legacy")); } #[test] @@ -4763,7 +4707,6 @@ mod tests { org_id: Some("org_fake".to_string()), org_name: Some("test-org".to_string()), email: Some("user@example.test".to_string()), - oauth_client_id: Some("bt_cli_legacy".to_string()), ..Default::default() }, ); @@ -4781,7 +4724,6 @@ mod tests { .get(&slot_key) .expect("canonical OAuth slot"); assert_eq!(profile.legacy_secret_key.as_deref(), Some("legacy-login")); - assert_eq!(profile.oauth_client_id.as_deref(), Some("bt_cli_legacy")); } let _ = fs::remove_dir_all(&dir); @@ -4796,7 +4738,6 @@ mod tests { AuthProfile { auth_kind: AuthKind::Oauth, org_name: Some("test-org".to_string()), - oauth_client_id: Some("bt_cli_legacy".to_string()), ..Default::default() }, ); @@ -4855,8 +4796,7 @@ mod tests { #[test] fn migrate_auth_store_dedupes_oauth_slots_by_latest_expiry() { let mut store = AuthStore::default(); - for (name, expires_at, client_id) in [("old", 10, "bt_cli_old"), ("new", 20, "bt_cli_new")] - { + for (name, expires_at) in [("old", 10), ("new", 20)] { store.profiles.insert( name.to_string(), AuthProfile { @@ -4864,7 +4804,6 @@ mod tests { org_id: Some("org_fake".to_string()), org_name: Some("test-org".to_string()), email: Some("user@example.test".to_string()), - oauth_client_id: Some(client_id.to_string()), oauth_access_expires_at: Some(expires_at), ..Default::default() }, @@ -4875,7 +4814,6 @@ mod tests { let key = oauth_slot_key("org_fake", "user@example.test"); assert_eq!(migrated.profiles.len(), 1); let profile = migrated.profiles.get(&key).expect("migrated profile"); - assert_eq!(profile.oauth_client_id.as_deref(), Some("bt_cli_new")); assert_eq!(profile.legacy_secret_key.as_deref(), Some("new")); } @@ -4990,10 +4928,7 @@ mod tests { auth_source(false, None, Some("env"), None, None), AuthSource::EnvApiKey("env".into()) ); - assert_eq!( - auth_source(false, None, None, None, None), - AuthSource::None - ); + assert_eq!(auth_source(false, None, None, None, None), AuthSource::None); } #[test] diff --git a/tests/cli.rs b/tests/cli.rs index f40c0682..084add7c 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -49,6 +49,15 @@ fn write_auth_store(config_home: &Path, profiles: &[(&str, &str)]) { fs::write(auth_dir.join("auth.json"), body).expect("write auth store"); } +#[test] +fn auth_login_does_not_expose_client_id() { + bt_command() + .args(["auth", "login", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--client-id").not()); +} + #[test] fn global_quiet_flag_still_parses_for_other_commands() { bt_command().args(["status", "--quiet"]).assert().success(); From dedf063518f1c6ca070908e8ed7c5b9a6597663b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Fri, 17 Jul 2026 12:15:15 -0700 Subject: [PATCH 7/8] chore: rename bt auth profiles to bt auth logins --- README.md | 2 +- src/auth.rs | 31 ++++++++++++++++--------------- src/main.rs | 2 +- tests/functions.rs | 24 ++++++++++++------------ 4 files changed, 30 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 237e27d8..172785ea 100644 --- a/README.md +++ b/README.md @@ -323,7 +323,7 @@ Local version and pagination-key conversion helpers: - You can pass `--no-browser` to print the URL without auto-opening. - On remote/SSH hosts, paste the final callback URL from your local browser if localhost callback cannot be delivered. - List saved auth logins: - - `bt auth profiles` + - `bt auth logins` - Log out: - `bt auth logout --org myorg` - `bt auth logout --org myorg --api-key-hint sk-****abcde` diff --git a/src/auth.rs b/src/auth.rs index a5ee496d..c0dce814 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -280,7 +280,7 @@ struct OAuthErrorResponse { #[command(after_help = "\ Examples: bt auth login - bt auth profiles + bt auth logins bt auth refresh --org acme bt auth logout --org acme ")] @@ -296,13 +296,13 @@ enum AuthCommand { /// Force-refresh OAuth access token for the selected org Refresh, /// List saved auth logins and check connection status - Profiles(AuthProfilesArgs), + Logins(AuthLoginsArgs), /// Log out by removing a saved auth login Logout(AuthLogoutArgs), } #[derive(Debug, Clone, Args)] -struct AuthProfilesArgs {} +struct AuthLoginsArgs {} #[derive(Debug, Clone, Args)] struct AuthLoginArgs { @@ -335,7 +335,7 @@ pub async fn run(base: BaseArgs, args: AuthArgs) -> Result<()> { match args.command { AuthCommand::Login(login_args) => run_login_set(&base, login_args).await, AuthCommand::Refresh => run_login_refresh(&base).await, - AuthCommand::Profiles(profile_args) => run_profiles(&base, profile_args).await, + AuthCommand::Logins(logins_args) => run_logins(&base, logins_args).await, AuthCommand::Logout(logout_args) => run_login_logout(base, logout_args), } } @@ -886,7 +886,7 @@ fn resolve_api_key_profile_auth( .profiles .get(profile_name) .cloned() - .ok_or_else(|| anyhow::anyhow!("saved auth login not found; run `bt auth profiles`"))?; + .ok_or_else(|| anyhow::anyhow!("saved auth login not found; run `bt auth logins`"))?; let api_key = load_profile_secret_with_legacy( profile_name, profile.legacy_secret_key.as_deref(), @@ -1043,7 +1043,7 @@ fn reconcile_resolved_auth_slot(auth: &ResolvedAuth, login: &LoginState) -> Resu .is_none_or(|org| org.trim().is_empty()); if login_org_id.trim().is_empty() && !is_cross_org { // The SDK's OAuth compatibility path does not always return org - // metadata. `bt auth profiles` performs the same reconciliation after + // metadata. `bt auth logins` performs the same reconciliation after // its explicit credential verification request. return Ok(()); } @@ -1083,10 +1083,11 @@ async fn resolve_oauth_profile_auth( cfg_org: &Option, profile_name: &str, ) -> Result { - let profile = - store.profiles.get(profile_name).cloned().ok_or_else(|| { - anyhow::anyhow!("saved OAuth login not found; run `bt auth profiles`") - })?; + let profile = store + .profiles + .get(profile_name) + .cloned() + .ok_or_else(|| anyhow::anyhow!("saved OAuth login not found; run `bt auth logins`"))?; let api_url = base .api_url .clone() @@ -1751,7 +1752,7 @@ async fn run_login_refresh(base: &BaseArgs) -> Result<()> { let profile_name = select_oauth_profile_for_auth(base, &store, &cfg_org, ui::can_prompt())? .ok_or_else(|| { anyhow::anyhow!( - "no OAuth login selected; pass --org or run `bt auth profiles` to see available logins" + "no OAuth login selected; pass --org or run `bt auth logins` to see available logins" ) })?; let profile = store @@ -1759,7 +1760,7 @@ async fn run_login_refresh(base: &BaseArgs) -> Result<()> { .get(profile_name.as_str()) .cloned() .ok_or_else(|| { - anyhow::anyhow!("OAuth login not found; run `bt auth profiles` to see available logins") + anyhow::anyhow!("OAuth login not found; run `bt auth logins` to see available logins") })?; let api_url = profile @@ -1954,7 +1955,7 @@ fn emit_result(json: bool, payload: serde_json::Value, human: impl FnOnce()) -> Ok(()) } -async fn run_profiles(base: &BaseArgs, _args: AuthProfilesArgs) -> Result<()> { +async fn run_logins(base: &BaseArgs, _args: AuthLoginsArgs) -> Result<()> { let mut store = load_auth_store()?; if store.profiles.is_empty() { return emit_result(base.json, serde_json::json!([]), || { @@ -2016,7 +2017,7 @@ fn run_login_delete(profile_name: &str, force: bool, base_json: bool) -> Result< let mut store = load_auth_store()?; let profile = store.profiles.get(profile_name).cloned().ok_or_else(|| { - anyhow::anyhow!("auth login not found; run `bt auth profiles` to see available logins") + anyhow::anyhow!("auth login not found; run `bt auth logins` to see available logins") })?; let label = auth_slot_label(&profile); @@ -2095,7 +2096,7 @@ fn run_login_logout(base: BaseArgs, args: AuthLogoutArgs) -> Result<()> { } let profile_name = match candidates.len() { - 0 => bail!("no matching auth login found; run `bt auth profiles` to see available logins"), + 0 => bail!("no matching auth login found; run `bt auth logins` to see available logins"), 1 => candidates[0].to_string(), _ if ui::can_prompt() => { select_profile_from_store("Select auth login to log out", &candidates, org, &store)? diff --git a/src/main.rs b/src/main.rs index 009cc335..7d0082a5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -501,7 +501,7 @@ fn looks_like_user_error(err: &anyhow::Error) -> bool { fn print_error(err: &anyhow::Error, code: ExitCode, missing_credential: bool) { eprintln!("error: {err}"); if code == ExitCode::Auth && !missing_credential { - eprintln!("Your credentials may be expired or invalid. For OAuth login, try `bt auth refresh --org `; if refresh fails, re-run `bt auth login --oauth --org `. Run `bt auth profiles` and `bt status` to inspect auth status."); + eprintln!("Your credentials may be expired or invalid. For OAuth login, try `bt auth refresh --org `; if refresh fails, re-run `bt auth login --oauth --org `. Run `bt auth logins` and `bt status` to inspect auth status."); } if code == ExitCode::Error { eprintln!("If this seems like a bug, file an issue at https://github.com/braintrustdata/bt/issues/new and include `bt --version`, `bt status --json`, and the command you ran."); diff --git a/tests/functions.rs b/tests/functions.rs index 84a9973f..e1dd66cc 100644 --- a/tests/functions.rs +++ b/tests/functions.rs @@ -190,8 +190,8 @@ fn sanitized_env_keys() -> &'static [&'static str] { ] } -fn auth_profiles_command(cwd: &Path, config_dir: &Path) -> Command { - auth_sub_command(cwd, config_dir, &["profiles"]) +fn auth_logins_command(cwd: &Path, config_dir: &Path) -> Command { + auth_sub_command(cwd, config_dir, &["logins"]) } #[derive(Debug, Clone)] @@ -704,14 +704,14 @@ fn functions_help_lists_push_and_pull() { } #[test] -fn auth_profiles_ignores_api_key_env_override() { +fn auth_logins_ignores_api_key_env_override() { let cwd = tempdir().expect("create temp cwd"); let config_dir = tempdir().expect("create temp config dir"); - let output = auth_profiles_command(cwd.path(), config_dir.path()) + let output = auth_logins_command(cwd.path(), config_dir.path()) .env("BRAINTRUST_API_KEY", "test-key") .output() - .expect("run bt auth profiles with api key env"); + .expect("run bt auth logins with api key env"); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); @@ -720,15 +720,15 @@ fn auth_profiles_ignores_api_key_env_override() { } #[test] -fn auth_profiles_ignores_api_key_from_dotenv() { +fn auth_logins_ignores_api_key_from_dotenv() { let cwd = tempdir().expect("create temp cwd"); let config_dir = tempdir().expect("create temp config dir"); fs::write(cwd.path().join(".env"), "BRAINTRUST_API_KEY=test-key\n").expect("write .env"); - let output = auth_profiles_command(cwd.path(), config_dir.path()) + let output = auth_logins_command(cwd.path(), config_dir.path()) .env_remove("BRAINTRUST_API_KEY") .output() - .expect("run bt auth profiles with dotenv api key"); + .expect("run bt auth logins with dotenv api key"); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); @@ -737,14 +737,14 @@ fn auth_profiles_ignores_api_key_from_dotenv() { } #[test] -fn auth_profiles_json_with_no_profiles_emits_empty_array() { +fn auth_logins_json_with_no_profiles_emits_empty_array() { let cwd = tempdir().expect("create temp cwd"); let config_dir = tempdir().expect("create temp config dir"); - let output = auth_profiles_command(cwd.path(), config_dir.path()) + let output = auth_logins_command(cwd.path(), config_dir.path()) .arg("--json") .output() - .expect("run bt auth profiles --json"); + .expect("run bt auth logins --json"); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); @@ -764,7 +764,7 @@ fn seed_api_key_profile(config_dir: &Path) { fn auth_sub_command(cwd: &Path, config_dir: &Path, sub: &[&str]) -> Command { // Shared builder for `bt auth ` so each auth subcommand inherits the - // same isolated config dir / env scrubbing as `auth profiles`. + // same isolated config dir / env scrubbing as `auth logins`. let mut cmd = Command::new(bt_binary_path()); cmd.arg("auth") .args(sub) From 6d7854db0afd08784b068e98049f3dcdd62b7850 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Tue, 21 Jul 2026 16:09:11 -0700 Subject: [PATCH 8/8] chore(update)!: remove `bt self update` `bt self update` had already been deprecated by `bt update` it was hidden since `bt update`'s introduction `bt update` does exactly the same things, it's only a name change --- src/main.rs | 20 +++----------------- src/{self_update.rs => update.rs} | 20 +++----------------- tests/cli.rs | 12 +----------- 3 files changed, 7 insertions(+), 45 deletions(-) rename src/{self_update.rs => update.rs} (97%) diff --git a/src/main.rs b/src/main.rs index 7d0082a5..65223171 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,7 +21,6 @@ mod prompts; mod python_runner; mod runner_sse; mod scorers; -mod self_update; mod setup; mod source_language; mod sql; @@ -32,6 +31,7 @@ mod tools; mod topics; mod traces; mod ui; +mod update; mod util_cmd; mod utils; @@ -145,10 +145,7 @@ enum Commands { /// Manage prompts Prompts(CLIArgs), /// Update bt in-place - Update(CLIArgs), - #[command(name = "self", hide = true)] - /// Self-management commands - SelfCommand(CLIArgs), + Update(CLIArgs), /// Manage tools Tools(CLIArgs), /// Manage scorers @@ -185,7 +182,6 @@ impl Commands { Commands::Datasets(cmd) => &cmd.base, Commands::Prompts(cmd) => &cmd.base, Commands::Update(cmd) => &cmd.base, - Commands::SelfCommand(cmd) => &cmd.base, Commands::Tools(cmd) => &cmd.base, Commands::Scorers(cmd) => &cmd.base, Commands::Functions(cmd) => &cmd.base, @@ -212,7 +208,6 @@ impl Commands { Commands::Topics(cmd) => &mut cmd.base, Commands::Prompts(cmd) => &mut cmd.base, Commands::Update(cmd) => &mut cmd.base, - Commands::SelfCommand(cmd) => &mut cmd.base, Commands::Tools(cmd) => &mut cmd.base, Commands::Scorers(cmd) => &mut cmd.base, Commands::Functions(cmd) => &mut cmd.base, @@ -318,22 +313,13 @@ fn try_main() -> Result<()> { Commands::Datasets(cmd) => datasets::run(cmd.base, cmd.args).await?, Commands::Topics(cmd) => topics::run(cmd.base, cmd.args).await?, Commands::Prompts(cmd) => prompts::run(cmd.base, cmd.args).await?, - Commands::Update(cmd) => { - self_update::run( - cmd.base, - self_update::SelfArgs { - command: self_update::SelfSubcommand::Update(cmd.args), - }, - ) - .await? - } + Commands::Update(cmd) => update::run(cmd.base, cmd.args).await?, Commands::Tools(cmd) => tools::run(cmd.base, cmd.args).await?, Commands::Scorers(cmd) => scorers::run(cmd.base, cmd.args).await?, Commands::Functions(cmd) => functions::run(cmd.base, cmd.args).await?, Commands::Experiments(cmd) => experiments::run(cmd.base, cmd.args).await?, Commands::Sync(cmd) => sync::run(cmd.base, cmd.args).await?, Commands::Util(cmd) => util_cmd::run(cmd.base, cmd.args).await?, - Commands::SelfCommand(cmd) => self_update::run(cmd.base, cmd.args).await?, Commands::Switch(cmd) => switch::run(cmd.base, cmd.args).await?, Commands::Status(cmd) => status::run(cmd.base, cmd.args).await?, } diff --git a/src/self_update.rs b/src/update.rs similarity index 97% rename from src/self_update.rs rename to src/update.rs index e7c3a5ee..1b672c96 100644 --- a/src/self_update.rs +++ b/src/update.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{Context, Result}; -use clap::{Args, Subcommand, ValueEnum}; +use clap::{Args, ValueEnum}; use reqwest::Client; use serde::Deserialize; @@ -17,18 +17,6 @@ Examples: bt update --check bt update --channel canary ")] -pub struct SelfArgs { - #[command(subcommand)] - pub command: SelfSubcommand, -} - -#[derive(Debug, Clone, Subcommand)] -pub enum SelfSubcommand { - /// Update bt in-place (installer-managed installs only) - Update(UpdateArgs), -} - -#[derive(Debug, Clone, Args)] pub struct UpdateArgs { /// Check for updates without installing #[arg(long)] @@ -85,10 +73,8 @@ struct GitHubRelease { target_commitish: Option, } -pub async fn run(base: BaseArgs, args: SelfArgs) -> Result<()> { - match args.command { - SelfSubcommand::Update(args) => run_update(&base, args).await, - } +pub async fn run(base: BaseArgs, args: UpdateArgs) -> Result<()> { + run_update(&base, args).await } async fn run_update(base: &BaseArgs, args: UpdateArgs) -> Result<()> { diff --git a/tests/cli.rs b/tests/cli.rs index 084add7c..6c3bec12 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -88,7 +88,7 @@ fn setup_verbose_is_accepted_after_subcommand() { } #[test] -fn update_help_exposes_self_update_flags() { +fn update_help_exposes_update_flags() { bt_command() .args(["update", "--help"]) .assert() @@ -97,16 +97,6 @@ fn update_help_exposes_self_update_flags() { .stdout(predicate::str::contains("--channel")); } -#[test] -fn self_update_remains_as_hidden_compatibility_path() { - bt_command() - .args(["self", "update", "--help"]) - .assert() - .success() - .stdout(predicate::str::contains("--check")) - .stdout(predicate::str::contains("--channel")); -} - #[test] fn top_level_help_shows_update_not_self() { bt_command()