diff --git a/README.md b/README.md index cfa19e70..081b4e83 100644 --- a/README.md +++ b/README.md @@ -139,8 +139,8 @@ Remove-Item -Recurse -Force (Join-Path $env:APPDATA "bt") -ErrorAction SilentlyC | ------------- | ------------------------------------------------------------------ | | `bt init` | Initialize `.bt/` config directory and link to a project | | `bt auth` | Authenticate with Braintrust | -| `bt switch` | Switch org and project context | -| `bt status` | Show current org and project context | +| `bt switch` | Switch instance, org, and project context | +| `bt status` | Show saved logins and current org/project context | | `bt datasets` | Manage datasets and dataset pipelines | | `bt eval` | Run eval files (Unix only) | | `bt sql` | Run SQL queries against Braintrust | @@ -312,54 +312,71 @@ Local version and pagination-key conversion helpers: ## `bt auth` -- Authenticate interactively (prompts for auth method, profile name defaults to org name): +- Authenticate interactively: - `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. - - `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` - - 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: - - `bt auth profiles` -- Log out (remove a saved profile): - - `bt auth logout` - - `bt auth logout --force` (skip confirmation) -- Show current auth source/profile: - - `bt auth status` -- Force-refresh OAuth access token for debugging: - - `bt auth refresh --profile work` + - First choose `OAuth (browser)` (default) or `API key`, then choose an organization. + - OAuth is stored once per Braintrust instance, identified by app URL, and can authenticate every organization available to that user in the instance. + - API-key logins remain organization-scoped; multiple keys for one organization remain distinct. + - Login only saves credentials; use `bt init` or `bt switch` to configure the active org and project. +- Login with OAuth: + - `bt auth login --oauth --org test-org` + - You can pass `--no-browser` to print the URL without opening it automatically. + - On remote/SSH hosts, paste the final callback URL if the localhost callback cannot be delivered. +- Inspect saved auth logins and the active context with `bt status`. +- Log out: + - `bt auth logout` — choose from all saved logins interactively + - `bt auth logout --app-url https://www.example.test --oauth` + - `bt auth logout --org test-org --api-key-hint sk-****abcde` + - `bt auth logout --force` — skip confirmation +- Force-refresh the OAuth login for the selected instance: + - `bt auth refresh --app-url https://www.example.test` 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` (`BRAINTRUST_API_KEY`, then a matching stored API key, then matching OAuth) +3. OAuth for the selected Braintrust instance when it can access the selected organization +4. `BRAINTRUST_API_KEY` +5. A matching stored 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. +OAuth credentials are matched by app URL. API-key credentials are matched by app URL, API URL, and organization. Explicit flags override environment variables, which override local config, global config, and finally the built-in Braintrust URLs. + +On Linux, secure storage uses `secret-tool` (libsecret) with a running Secret Service daemon. On macOS, it uses the `security` keychain utility. If secure storage is unavailable, `bt` falls back to a plaintext secrets file with `0600` permissions. + +## `bt init` + +`bt init` creates a project-local `.bt/config.json`. It walks upward to the first `.bt`, `.git`, home, or filesystem-root boundary. A git marker (directory or file) selects that repository root; reaching home/root or an existing `.bt` is an error. + +- `bt init --org test-org --project test-project` — initialize the containing repository +- `bt init --here` — create in the current directory without walking (including at home or `/`) +- `bt init --force` — overwrite an existing discovered `.bt/config.json`; it does not change discovery + +The saved context includes the Braintrust instance URLs, organization name and ID, and project name and ID. ## `bt switch` -Interactively switch org and project context: +`bt switch` changes context without selecting a credential. It chooses a Braintrust instance, discovers the organizations available through that instance's credentials, and then chooses a project. -- `bt switch` — interactive picker for org and project -- `bt switch myproject` — switch to a project by name -- `bt switch myorg/myproject` — switch to a specific org and project +- `bt switch` +- `bt switch test-project` +- `bt switch test-org/test-project` - `bt switch --global` — persist to global config (`~/.config/bt/config.json`) -- `bt switch --local` — persist to local config (`.bt/config.json`) +- `bt switch --local` — update an existing local config (`.bt/config.json`); it never creates one + +A sole instance, organization, or project is selected automatically. With an existing local config and no scope flag, interactive mode asks for global/local (default: local); non-interactive mode requires `--global` or `--local`. + +## Config context merging + +Global config is `~/.config/bt/config.json`; local config is the first discovered `.bt/config.json`. Both use the fields `org`, `org_id`, `project`, `project_id`, `app_url`, and `api_url`. Local values win. Organization IDs stay coupled to organization names, and organization/project context is inherited only within the same app URL. Legacy `profile` fields and obsolete empty cross-org contexts are ignored; unknown extra keys are preserved during updates. ## `bt status` -Show current org and project context: +Show saved auth logins and the current org/project context: -- `bt status` — display current org, project, and config source -- `bt status --verbose` — show detailed config resolution -- `bt status -j` — JSON output +- `bt status` — check saved login status, then display the active org, project, auth method, URLs, credential path, and config source +- `bt status --api-key sk-...` — identify the key's org using the active app/API URLs; config org/project values are ignored +- `bt status --quiet` — show compact context output +- `bt status --json` — emit the saved logins and active context as JSON ## `bt setup` and `bt docs` diff --git a/src/args.rs b/src/args.rs index c15c6478..cfe4e4ca 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; @@ -11,7 +10,7 @@ pub enum ArgValueSource { EnvVariable, } -#[derive(Debug, Clone, Args)] +#[derive(Debug, Clone, Default, Args)] pub struct BaseArgs { /// Output as JSON #[arg(long, global = true)] @@ -39,17 +38,17 @@ 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)] - 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)] + #[arg(short = 'o', long = "org", env = "BRAINTRUST_ORG_NAME", global = true, value_parser = parse_org_name)] pub org_name: Option, + #[arg(skip)] + pub org_name_source: Option, + + /// Stable org ID resolved from config or internal context selection. + #[arg(skip)] + pub org_id: Option, + /// Override active project #[arg( short = 'p', @@ -60,6 +59,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, @@ -67,9 +69,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( @@ -80,6 +82,9 @@ pub struct BaseArgs { )] pub api_url: Option, + #[arg(skip)] + pub api_url_source: Option, + /// Override app URL (or via BRAINTRUST_APP_URL) #[arg( long, @@ -89,6 +94,9 @@ pub struct BaseArgs { )] pub app_url: Option, + #[arg(skip)] + pub app_url_source: Option, + /// Path to a PEM-encoded CA bundle used for HTTPS requests. #[arg( long = "ca-cert", @@ -117,6 +125,23 @@ pub struct CLIArgs { pub base: BaseArgs, } +fn parse_org_name(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err("organization cannot be empty".to_string()); + } + Ok(value.to_string()) +} + +pub(crate) fn custom_api_without_app_url(api_url: Option<&str>, app_url: Option<&str>) -> bool { + app_url.is_none_or(|url| url.trim().is_empty()) + && api_url.is_some_and(|url| { + !url.trim() + .trim_end_matches('/') + .eq_ignore_ascii_case(DEFAULT_API_URL.trim_end_matches('/')) + }) +} + impl BaseArgs { pub fn ca_cert(&self) -> Option<&Path> { self.ca_cert.as_deref() @@ -127,63 +152,28 @@ impl BaseArgs { } } -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)); - } + use super::{custom_api_without_app_url, parse_org_name, DEFAULT_API_URL}; #[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)); + fn org_normalization() { + for (input, expected) in [ + ("cross-org", "cross-org"), + (" test-org ", "test-org"), + (" org_test_123 ", "org_test_123"), + ] { + assert_eq!(parse_org_name(input).unwrap(), expected); + } + assert!(parse_org_name(" ").is_err()); + assert!(custom_api_without_app_url( + Some("https://api.example.test"), + None + )); + assert!(!custom_api_without_app_url(Some(DEFAULT_API_URL), None)); + assert!(!custom_api_without_app_url( + Some("https://api.example.test"), + Some("https://app.example.test") + )); } } diff --git a/src/auth.rs b/src/auth.rs index a915a636..467b00e5 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,17 +25,19 @@ use oauth2::{ }; use serde::{Deserialize, Serialize}; use serde_json::json; +use sha2::{Digest, Sha256}; use tokio::sync::oneshot; use crate::{ args::{BaseArgs, DEFAULT_API_URL, DEFAULT_APP_URL}, config, - http::{build_http_client, build_http_client_from_builder, ApiClient}, - projects::api, - switch, ui, + http::{build_http_client, build_http_client_from_builder}, + ui, + utils::shell_quote_arg, }; 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; @@ -56,23 +58,23 @@ pub struct ResolvedAuth { pub api_url: Option, pub app_url: Option, pub org_name: Option, + pub org_id: Option, pub is_oauth: bool, + slot_key: Option, } #[derive(Debug, Clone)] pub struct ProfileInfo { - pub name: String, + pub auth_method: String, pub org_name: Option, pub user_name: Option, pub email: Option, pub api_key_hint: Option, } -#[derive(Debug, Clone)] -pub(crate) struct StoredProfileInfo { - pub name: String, - pub is_oauth: bool, - pub org_name: Option, +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AvailableInstance { + pub app_url: String, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -84,9 +86,8 @@ pub struct AvailableOrg { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RecoverableAuthErrorKind { - OauthProfileSelection, - OauthClientId, OauthRefreshToken, + OauthOrgAccess, StoredCredential, } @@ -108,15 +109,12 @@ fn recoverable_auth_error(kind: RecoverableAuthErrorKind, message: String) -> an anyhow::Error::new(RecoverableAuthError { kind, message }) } -fn shell_quote_arg(value: &str) -> String { - if value - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/' | ':' | '=')) - { - value.to_string() - } else { - format!("'{}'", value.replace('\'', "'\\''")) - } +fn is_oauth_org_access_error(err: &anyhow::Error) -> bool { + err.chain().any(|source| { + source + .downcast_ref::() + .is_some_and(|err| err.kind == RecoverableAuthErrorKind::OauthOrgAccess) + }) } pub fn is_missing_credential_error(err: &anyhow::Error) -> bool { @@ -126,9 +124,7 @@ pub fn is_missing_credential_error(err: &anyhow::Error) -> bool { .is_some_and(|err| { matches!( err.kind, - RecoverableAuthErrorKind::OauthProfileSelection - | RecoverableAuthErrorKind::OauthClientId - | RecoverableAuthErrorKind::OauthRefreshToken + RecoverableAuthErrorKind::OauthRefreshToken | RecoverableAuthErrorKind::StoredCredential ) }) @@ -139,108 +135,17 @@ pub fn list_profiles() -> Result> { let store = load_auth_store()?; 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(), - }) + .values() + .map(profile_info_from_store_entry) .collect()) } -pub(crate) fn list_stored_profiles() -> Result> { +pub(crate) fn has_oauth_login_for_instance(base: &BaseArgs) -> Result { let store = load_auth_store()?; Ok(store .profiles - .iter() - .map(|(name, profile)| StoredProfileInfo { - name: name.clone(), - is_oauth: profile.auth_kind == AuthKind::Oauth, - org_name: profile.org_name.clone(), - }) - .collect()) -} - -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 profiles for org '{identifier}': {}. Use --profile 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()) - } - } -} - -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."); - } - 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 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 org", &labels, default)?; - Ok(Some(profiles[idx].name.clone())) + .values() + .any(|profile| profile.auth_kind == AuthKind::Oauth && profile_matches_urls(base, profile))) } pub async fn list_available_orgs(base: &BaseArgs) -> Result> { @@ -257,14 +162,19 @@ pub async fn list_available_orgs(base: &BaseArgs) -> Result> { .context("login state missing API key")?, }; - let mut orgs = fetch_login_orgs(&api_key, &app_url).await?; - orgs.sort_by(|a, b| { - a.name - .to_ascii_lowercase() - .cmp(&b.name.to_ascii_lowercase()) - .then_with(|| a.name.cmp(&b.name)) - }); + available_orgs(&api_key, &app_url).await +} + +pub(crate) async fn list_available_orgs_for_api_key( + api_key: &str, + app_url: &str, +) -> Result> { + available_orgs(api_key, app_url).await +} +async fn available_orgs(api_key: &str, app_url: &str) -> Result> { + let mut orgs = fetch_login_orgs(api_key, app_url).await?; + sort_login_orgs(&mut orgs); Ok(orgs .into_iter() .map(|org| AvailableOrg { @@ -275,29 +185,134 @@ pub async fn list_available_orgs(base: &BaseArgs) -> Result> { .collect()) } -pub(crate) async fn list_available_orgs_for_api_key( - api_key: &str, +pub(crate) fn available_instances(base: &BaseArgs) -> Result> { + let store = load_auth_store()?; + let constrain_app = matches!( + base.app_url_source, + Some(crate::args::ArgValueSource::CommandLine | crate::args::ArgValueSource::EnvVariable) + ); + let requested_app = constrain_app + .then_some(base.app_url.as_deref()) + .flatten() + .map(canonical_url); + let mut apps = store + .profiles + .values() + .map(profile_app_url) + .filter(|app| requested_app.is_none_or(|requested| canonical_url(app) == requested)) + .map(|app| canonical_url(app).to_string()) + .collect::>(); + + if base + .api_key + .as_deref() + .is_some_and(|key| !key.trim().is_empty()) + { + let app = base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + if requested_app.is_none_or(|requested| canonical_url(app) == requested) { + apps.insert(canonical_url(app).to_string()); + } + } + + if apps.is_empty() && constrain_app { + bail!( + "no credentials found for app URL '{}'; run `bt auth login --app-url {}`", + base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL), + shell_quote_arg(base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL)) + ); + } + Ok(apps + .into_iter() + .map(|app_url| AvailableInstance { app_url }) + .collect()) +} + +pub(crate) async fn available_orgs_for_instance( + base: &BaseArgs, app_url: &str, ) -> Result> { - let mut orgs = fetch_login_orgs(api_key, app_url).await?; + let mut store = load_auth_store()?; + let explicit_api = matches!( + base.api_url_source, + Some(crate::args::ArgValueSource::CommandLine | crate::args::ArgValueSource::EnvVariable) + ) + .then(|| base.api_url.as_deref()) + .flatten(); + let mut orgs = BTreeMap::::new(); + let matching = store + .profiles + .iter() + .filter(|(_, profile)| canonical_url(profile_app_url(profile)) == canonical_url(app_url)) + .filter(|(_, profile)| { + profile.auth_kind == AuthKind::Oauth + || explicit_api + .is_none_or(|url| canonical_url(url) == canonical_url(profile_api_url(profile))) + }) + .map(|(slot, profile)| (slot.clone(), profile.clone())) + .collect::>(); + + for (slot, profile) in matching { + match profile.auth_kind { + AuthKind::Oauth => { + let mut oauth_base = base.clone(); + oauth_base.app_url = Some(app_url.to_string()); + if oauth_base.api_url_source.is_none() { + oauth_base.api_url = profile.api_url.clone(); + } + let token = load_oauth_access_token(&oauth_base, &mut store, &slot).await?; + for org in fetch_login_orgs(&token, app_url).await? { + orgs.insert( + org.id.clone(), + AvailableOrg { + id: org.id, + name: org.name, + api_url: org.api_url, + }, + ); + } + } + AuthKind::ApiKey => { + if let (Some(id), Some(name)) = (profile.org_id, profile.org_name) { + orgs.entry(id.clone()).or_insert(AvailableOrg { + id, + name, + api_url: profile.api_url, + }); + } + } + } + } + + if let Some(api_key) = base.api_key.as_deref().filter(|key| !key.trim().is_empty()) { + let base_app = base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + if canonical_url(base_app) == canonical_url(app_url) { + for org in fetch_login_orgs(api_key, app_url).await? { + orgs.insert( + org.id.clone(), + AvailableOrg { + id: org.id, + name: org.name, + api_url: org.api_url, + }, + ); + } + } + } + + let mut orgs = orgs.into_values().collect::>(); orgs.sort_by(|a, b| { a.name .to_ascii_lowercase() .cmp(&b.name.to_ascii_lowercase()) .then_with(|| a.name.cmp(&b.name)) }); - - Ok(orgs - .into_iter() - .map(|org| AvailableOrg { - id: org.id, - name: org.name, - api_url: org.api_url, - }) - .collect()) + if orgs.is_empty() { + bail!("no organizations are available for Braintrust instance '{app_url}'"); + } + Ok(orgs) } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] struct AuthStore { #[serde(default)] profiles: BTreeMap, @@ -309,7 +324,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, @@ -318,9 +333,9 @@ struct AuthProfile { #[serde(default)] app_url: Option, #[serde(default)] - org_name: Option, + org_id: Option, #[serde(default)] - oauth_client_id: Option, + org_name: Option, #[serde(default)] oauth_access_expires_at: Option, #[serde(default)] @@ -328,10 +343,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] @@ -339,6 +358,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, @@ -387,9 +413,10 @@ struct OAuthErrorResponse { #[command(after_help = "\ Examples: bt auth login - bt auth profiles - bt auth refresh - bt auth logout --profile work + bt auth login --oauth --org test-org + bt auth refresh --org test-org + bt auth logout + bt auth logout --org test-org --oauth ")] pub struct AuthArgs { #[command(subcommand)] @@ -400,58 +427,46 @@ pub struct AuthArgs { enum AuthCommand { /// Authenticate with Braintrust (OAuth or API key) Login(AuthLoginArgs), - /// Force-refresh OAuth access token for a profile + /// Force-refresh the OAuth access token for the selected instance Refresh, - /// List auth profiles 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, -} - #[derive(Debug, Clone, Args)] struct AuthLoginArgs { /// Use OAuth login instead of API key login #[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, } #[derive(Debug, Clone, Args)] +#[command(after_help = "\ +To choose an OAuth login to remove without using the interactive picker, use `--oauth --app-url ...` or `--org org-name` if the org name is unique accross logins +")] struct AuthLogoutArgs { - /// Profile name to log out of (interactive picker if omitted) - #[arg(long)] - profile: Option, + /// Only consider OAuth logins + #[arg(long, conflicts_with = "api_key_hint")] + oauth: bool, + + /// 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')] force: bool, } -struct PostLoginContextUpdate { - display: String, - path: PathBuf, -} - 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::Logout(logout_args) => run_login_logout(base, logout_args), + AuthCommand::Logout(logout_args) => run_login_logout(base, logout_args).await, } } @@ -471,7 +486,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!( @@ -491,7 +505,7 @@ pub async fn fast_login(base: &BaseArgs) -> Result { let login = LoginState::new(); login.set( api_key, - String::new(), + auth.org_id.clone().unwrap_or_default(), org_name, api_url.clone(), app_url.clone(), @@ -505,7 +519,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!( @@ -534,16 +547,22 @@ 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?, - 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}"))?; + 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(); login.set( api_key.clone(), - String::new(), + auth.org_id.clone().unwrap_or_default(), org_name, auth.api_url .clone() @@ -554,9 +573,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()) @@ -808,42 +835,43 @@ 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; - } - - 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 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 has_explicit_profile_selection(base: &BaseArgs) -> bool { - base.profile_explicit - && base - .profile - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) +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 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(); @@ -853,125 +881,530 @@ fn resolve_api_key_override(base: &BaseArgs) -> Option { Some(value.to_string()) } -fn config_auth_context(base: &BaseArgs) -> (Option, Option) { +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 - }; +fn configured_org_for_app_url(app_url: &str) -> Option { + let cfg = crate::config::load().ok()?; + let config_app = cfg.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + crate::config::urls_equal(app_url, config_app) + .then_some(cfg.org) + .flatten() +} - let org = if crate::config::trimmed_option(base.org_name.as_deref()).is_none() { - crate::config::trimmed_option(cfg.org.as_deref()).map(str::to_string) +fn config_auth_context_from_config(base: &BaseArgs, cfg: &crate::config::Config) -> Option { + let base_app = base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + let config_app = cfg.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + if crate::config::org_option(base.org_name.as_deref()).is_none() + && crate::config::urls_equal(base_app, config_app) + { + crate::config::org_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::org_option(base.org_name.as_deref()) + .or_else(|| crate::config::org_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); +/// 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. `BRAINTRUST_API_KEY` +/// 5. stored API key login for the selected org +/// +/// 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(slot) = select_api_key()? { + return Ok(AuthSource::ApiKey(slot)); + } + if let Some(slot) = select_oauth()? { + return Ok(AuthSource::Oauth(slot)); + } + return Ok(AuthSource::None); } - if let Some(profile_name) = - maybe_select_profile_for_auth(&auth_base, &store, &cfg_org, ui::can_prompt())? - { - auth_base.profile = Some(profile_name); + if let Some(slot) = select_oauth()? { + return Ok(AuthSource::Oauth(slot)); + } + if let Some(api_key) = env_api_key() { + return Ok(AuthSource::EnvApiKey(api_key)); + } + if let Some(slot) = select_api_key()? { + return Ok(AuthSource::ApiKey(slot)); } + Ok(AuthSource::None) +} - let mut auth = resolve_auth_from_store_with_secret_lookup( - &auth_base, - &store, - load_profile_secret, - &cfg_org, +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 effective_org = effective_org_name(base, &cfg_org); + + let source = resolve_auth_source( + base.prefer_api_key, + resolve_cli_api_key_override(base), + || resolve_env_api_key(base), + || select_profile_for_auth(base, &store, &cfg_org, AuthKind::Oauth, can_prompt), + || select_profile_for_auth(base, &store, &cfg_org, AuthKind::ApiKey, can_prompt), )?; - if !auth.is_oauth { - return Ok(auth); + + match source { + AuthSource::CliApiKey(api_key) | AuthSource::EnvApiKey(api_key) => { + resolve_ad_hoc_api_key_auth(base, &cfg_org, api_key).await + } + AuthSource::Oauth(slot) => { + match resolve_saved_auth_slot(base, &mut store, &cfg_org, &slot).await { + Ok(auth) => Ok(auth), + Err(err) if is_oauth_org_access_error(&err) && !base.prefer_api_key => { + if let Some(api_key) = resolve_env_api_key(base) { + return resolve_ad_hoc_api_key_auth(base, &cfg_org, api_key).await; + } + if let Some(api_key_slot) = select_profile_for_auth( + base, + &store, + &cfg_org, + AuthKind::ApiKey, + can_prompt, + )? { + return resolve_saved_auth_slot(base, &mut store, &cfg_org, &api_key_slot) + .await; + } + Err(err) + } + Err(err) => Err(err), + } + } + AuthSource::ApiKey(slot) => { + resolve_saved_auth_slot(base, &mut store, &cfg_org, &slot).await + } + AuthSource::None => { + if base.prefer_api_key { + bail!("--prefer-api-key requires an API key or OAuth login for the selected org"); + } + if !store.profiles.is_empty() + && !store + .profiles + .values() + .any(|profile| profile_matches_urls(base, profile)) + { + let app = base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + let api = base.api_url.as_deref().unwrap_or(DEFAULT_API_URL); + bail!( + "no credentials match app URL '{}' and API URL '{}'; run `bt auth login` with these URLs", + app, + api + ); + } + if effective_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.map(str::to_string), + org_id: base.org_id.clone(), + is_oauth: false, + slot_key: None, + }) + } } +} - let effective_org = auth_base.org_name.as_deref().or(cfg_org.as_deref()); - let profile_name = auth_base - .profile - .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(), +async fn resolve_ad_hoc_api_key_auth( + base: &BaseArgs, + cfg_org: &Option, + api_key: String, +) -> Result { + let requested_org = effective_org_name(base, cfg_org); + if requested_org == Some("") { + bail!("API keys require a concrete org; rerun with --org "); + } + + let mut resolved_org = requested_org.map(str::to_string); + let mut resolved_org_id = base.org_id.clone(); + let mut resolved_api_url = base.api_url.clone(); + if let Some(requested_org) = requested_org { + if crate::args::custom_api_without_app_url(base.api_url.as_deref(), base.app_url.as_deref()) + { + bail!("API key organization validation with a custom API URL requires --app-url or BRAINTRUST_APP_URL"); + } + let app_url = base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + let orgs = fetch_login_orgs(&api_key, app_url).await.map_err(|err| { + if is_unauthorized_auth_error(&err) { + anyhow::anyhow!("API key is not valid") + } else { + err.context("failed to validate API key organization membership") + } + })?; + let selected_org = find_login_org(&orgs, requested_org).ok_or_else(|| { + let available = login_org_names(&orgs); + anyhow::anyhow!( + "API key does not belong to requested org '{requested_org}'. Available orgs for this key: {available}" ) - })? - .to_string(); + })?; + resolved_org = Some(selected_org.name.clone()); + resolved_org_id = Some(selected_org.id.clone()); + resolved_api_url = resolved_api_url.or_else(|| selected_org.api_url.clone()); + } + + Ok(ResolvedAuth { + api_key: Some(api_key), + api_url: resolved_api_url, + app_url: base.app_url.clone(), + org_name: resolved_org, + org_id: resolved_org_id, + is_oauth: false, + slot_key: None, + }) +} + +async fn resolve_saved_auth_slot( + base: &BaseArgs, + store: &mut AuthStore, + cfg_org: &Option, + slot: &str, +) -> Result { + let kind = store + .profiles + .get(slot) + .map(|profile| profile.auth_kind) + .ok_or_else(|| { + anyhow::anyhow!("saved auth login not found; run `bt status` to see available logins") + })?; + match kind { + AuthKind::ApiKey => resolve_api_key_profile_auth(base, store, cfg_org, slot), + AuthKind::Oauth => resolve_oauth_profile_auth(base, store, cfg_org, slot).await, + } +} + +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.as_str()) - .ok_or_else(|| anyhow::anyhow!("profile '{profile_name}' not found"))?; - let client_id = profile.oauth_client_id.as_deref().ok_or_else(|| { + .get(profile_name) + .cloned() + .ok_or_else(|| anyhow::anyhow!("saved auth login not found; run `bt status`"))?; + if let Some(requested_org) = effective_org_name(base, cfg_org) { + if !profile_matches_org_identifier(&profile, requested_org) { + bail!( + "stored API key for '{}' does not belong to requested org '{requested_org}'", + profile_org_label(&profile) + ); + } + } + + let api_key = load_profile_secret_with_legacy( + profile_name, + profile.legacy_secret_key.as_deref(), + )? + .ok_or_else(|| { recoverable_auth_error( - RecoverableAuthErrorKind::OauthClientId, + RecoverableAuthErrorKind::StoredCredential, format!( - "oauth profile '{profile_name}' is missing client_id; re-run `bt auth login --oauth --profile {}`", - shell_quote_arg(&profile_name) + "no keychain credential found for auth login '{}'; re-run `bt auth login --org --api-key `", + auth_slot_label(&profile) ), ) })?; - let cached_expires_at = profile.oauth_access_expires_at; - let api_url = auth - .api_url - .clone() - .unwrap_or_else(|| DEFAULT_API_URL.to_string()); - if let Some(cached_access_token) = - load_valid_cached_oauth_access_token(&profile_name, cached_expires_at)? + 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()), + org_id: profile.org_id.clone().or_else(|| base.org_id.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 { + // Two entries collapse onto the same slot (same OAuth org+email, or the + // same API key+org). Keep the usable one and delete the loser's secrets + // so we never orphan a credential in the keychain, and never drop the + // entry that still holds a working refresh token. + if let Some(existing) = store.profiles.get(&canonical_key).cloned() { + if should_replace_canonical_profile(&canonical_key, &existing, &profile) { + delete_all_profile_secrets(&canonical_key, &existing); + } else { + delete_all_profile_secrets(current_key, &profile); + store.profiles.remove(current_key); + return true; + } + } + store.profiles.remove(current_key); + } + 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(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) { - auth.api_key = Some(cached_access_token); - return Ok(auth); + return Ok(()); } - 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 {}`", - shell_quote_arg(&profile_name) - ), - ) - })?; - 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)?; + profile.api_key_hash = Some(api_key_hash(api_key)); + + // If the secret still lives only in the lazy legacy keychain slot, relocate + // it to the canonical slot now (mirroring the OAuth refresh path) so future + // resolves stop paying a permanent miss+fallback and deleting the old-named + // keychain item can't orphan the login. + if profile.legacy_secret_key.is_some() { + let canonical_key = canonical_profile_key(profile_name, &profile); + save_profile_secret(&canonical_key, api_key)?; + delete_legacy_profile_secrets(&profile); + profile.legacy_secret_key = None; + } + + if replace_with_canonical_auth_profile(store, profile_name, 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(()); + }; + + if profile.auth_kind == AuthKind::Oauth { + return Ok(()); + } + let login_org_id = login.org_id().unwrap_or_default(); + if login_org_id.trim().is_empty() { + return Ok(()); + } + + profile.org_id = Some(login_org_id); + profile.org_name = login + .org_name() + .filter(|org| !org.trim().is_empty()) + .or_else(|| auth.org_name.clone()); + 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)); + } + + if replace_with_canonical_auth_profile(&mut store, slot_key, profile) { + save_auth_store(&store)?; + } + Ok(()) +} + +async fn load_oauth_access_token( + base: &BaseArgs, + store: &mut AuthStore, + profile_name: &str, +) -> Result { + let profile = store + .profiles + .get(profile_name) + .cloned() + .ok_or_else(|| anyhow::anyhow!("saved OAuth login not found; run `bt status`"))?; + if let Some(cached) = load_valid_cached_oauth_access_token( + profile_name, + &profile, + profile.oauth_access_expires_at, + )? { + return Ok(cached); + } + + 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 `{}`", + auth_slot_label(&profile), + oauth_reauth_command(&profile) + ), + ) + })?; + let api_url = base + .api_url + .as_deref() + .unwrap_or_else(|| profile_api_url(&profile)); + let refreshed = refresh_oauth_access_token(api_url, &refresh_token, &profile).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)?; + Ok(refreshed.access_token) +} + +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) + .cloned() + .ok_or_else(|| anyhow::anyhow!("saved OAuth login not found; run `bt status`"))?; + let access_token = load_oauth_access_token(base, store, profile_name).await?; + let auth = ResolvedAuth { + api_key: Some(access_token), + api_url: Some( + base.api_url + .clone() + .or_else(|| profile.api_url.clone()) + .unwrap_or_else(|| DEFAULT_API_URL.to_string()), + ), + app_url: Some( + base.app_url + .clone() + .or_else(|| profile.app_url.clone()) + .unwrap_or_else(|| DEFAULT_APP_URL.to_string()), + ), + org_name: effective_org_name(base, cfg_org).map(str::to_string), + org_id: base.org_id.clone(), + is_oauth: true, + slot_key: Some(profile_name.to_string()), + }; + resolve_oauth_org_context(auth).await +} + +async fn resolve_oauth_org_context(mut auth: ResolvedAuth) -> Result { + let requested_org = auth.org_name.as_deref().ok_or_else(|| { + recoverable_auth_error( + RecoverableAuthErrorKind::OauthOrgAccess, + "an active organization is required; run `bt switch` or pass --org ".to_string(), + ) + })?; + let credential = auth + .api_key + .as_deref() + .context("OAuth access token is missing")?; + let app_url = auth.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + let orgs = fetch_login_orgs(credential, app_url).await?; + let selected = find_login_org(&orgs, requested_org).ok_or_else(|| { + recoverable_auth_error( + RecoverableAuthErrorKind::OauthOrgAccess, + format!( + "OAuth login for '{}' cannot access organization '{requested_org}'", + canonical_url(app_url) + ), + ) + })?; + auth.org_name = Some(selected.name.clone()); + auth.org_id = Some(selected.id.clone()); + if auth.api_url.is_none() { + auth.api_url = selected.api_url.clone(); } - save_auth_store(&store)?; - auth.api_key = Some(refreshed.access_token); Ok(auth) } -pub async fn resolved_auth_env(base: &BaseArgs) -> Result> { - let auth = resolve_auth(base).await?; +fn auth_env(auth: ResolvedAuth) -> Vec<(String, String)> { let mut envs = Vec::new(); - if let Some(api_key) = auth.api_key { envs.push(("BRAINTRUST_API_KEY".to_string(), api_key)); } @@ -984,65 +1417,233 @@ pub async fn resolved_auth_env(base: &BaseArgs) -> Result> if let Some(org_name) = auth.org_name { envs.push(("BRAINTRUST_ORG_NAME".to_string(), org_name)); } - Ok(envs) + envs } pub async fn resolved_runner_env(base: &BaseArgs) -> Result> { - let mut envs = resolved_auth_env(base).await?; + let auth = resolve_auth(base).await?; + let resolved_org = auth.org_name.clone(); + let mut envs = auth_env(auth); let project = base .project .clone() - .or_else(|| crate::config::load().ok().and_then(|c| c.project)); + .or_else(|| crate::config::configured_project_for_context(base, resolved_org.as_deref())); if let Some(project) = project { envs.push(("BRAINTRUST_DEFAULT_PROJECT".to_string(), project)); } Ok(envs) } -fn resolve_profile_for_org<'a>(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 canonical_url(url: &str) -> &str { + url.trim().trim_end_matches('/') +} + +fn profile_app_url(profile: &AuthProfile) -> &str { + profile.app_url.as_deref().unwrap_or(DEFAULT_APP_URL) +} + +fn profile_api_url(profile: &AuthProfile) -> &str { + profile.api_url.as_deref().unwrap_or(DEFAULT_API_URL) +} + +fn profile_matches_urls(base: &BaseArgs, profile: &AuthProfile) -> bool { + let app_url = base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + if canonical_url(app_url) != canonical_url(profile_app_url(profile)) { + return false; } + profile.auth_kind == AuthKind::Oauth + || canonical_url(base.api_url.as_deref().unwrap_or(DEFAULT_API_URL)) + == canonical_url(profile_api_url(profile)) +} - let matches: Vec<&str> = store - .profiles - .iter() - .filter(|(_, p)| p.org_name.as_deref() == Some(org)) - .map(|(name, _)| name.as_str()) - .collect(); +/// Match only URL filters the caller actually supplied. Listing and logout use +/// this variant so an absent filter means "all instances", while command auth +/// uses [`profile_matches_urls`] and therefore honors the built-in URL defaults. +fn profile_matches_url_filters(base: &BaseArgs, profile: &AuthProfile) -> bool { + let app_matches = base + .app_url + .as_deref() + .is_none_or(|url| canonical_url(url) == canonical_url(profile_app_url(profile))); + app_matches + && (profile.auth_kind == AuthKind::Oauth + || base + .api_url + .as_deref() + .is_none_or(|url| canonical_url(url) == canonical_url(profile_api_url(profile)))) +} + +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(profile: &AuthProfile) -> &str { + profile + .org_name + .as_deref() + .filter(|org| !org.trim().is_empty()) + .or(profile + .org_id + .as_deref() + .filter(|org| !org.trim().is_empty())) + .unwrap_or("") +} + +fn profile_org_label(profile: &AuthProfile) -> String { + profile_org(profile).to_string() +} + +fn oauth_reauth_command(profile: &AuthProfile) -> String { + format!( + "bt auth login --oauth --app-url {}", + shell_quote_arg(profile_app_url(profile)) + ) +} + +pub(crate) fn identity_label( + name: Option<&str>, + email: Option<&str>, + fallback: Option<&str>, +) -> Option { + match (name, email) { + (Some(name), Some(email)) => Some(format!("{name} ({email})")), + (Some(name), None) => Some(name.to_string()), + (None, Some(email)) => Some(email.to_string()), + (None, None) => fallback.map(str::to_string), + } +} + +fn profile_identity_label(profile: &AuthProfile) -> Option { + let fallback = (profile.auth_kind == AuthKind::ApiKey) + .then_some(profile.api_key_hint.as_deref()) + .flatten(); + identity_label( + profile.user_name.as_deref(), + profile.email.as_deref(), + fallback, + ) +} - match matches.len() { - 0 => None, - 1 => Some(matches[0]), - _ => None, +fn auth_slot_label(profile: &AuthProfile) -> String { + let mut parts = match profile.auth_kind { + AuthKind::Oauth => vec![profile_app_url(profile).to_string(), "oauth".to_string()], + AuthKind::ApiKey => vec![profile_org_label(profile), "api_key".to_string()], + }; + if let Some(identity) = profile_identity_label(profile) { + parts.push(identity); } + parts.join(" — ") } -fn profile_names_for_org<'a>(org: &str, store: &'a AuthStore) -> Vec<&'a str> { +fn auth_profile_names_by_kind<'a>( + base: &BaseArgs, + store: &'a AuthStore, + org: Option<&str>, + kind: AuthKind, +) -> Vec<&'a str> { store .profiles .iter() - .filter(|(_, profile)| profile.org_name.as_deref() == Some(org)) + .filter(|(_, profile)| profile.auth_kind == kind) + .filter(|(_, profile)| profile_matches_urls(base, profile)) + .filter(|(_, profile)| { + kind == AuthKind::Oauth + || org.is_some_and(|org| profile_matches_org_identifier(profile, org)) + }) .map(|(name, _)| name.as_str()) .collect() } +fn profile_info_from_store_entry(profile: &AuthProfile) -> ProfileInfo { + ProfileInfo { + 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).map(profile_info_from_store_entry) +} + +fn ad_hoc_api_key_profile(org: Option<&str>, api_key: &str) -> ProfileInfo { + ProfileInfo { + 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>) -> Result> { + let store = load_auth_store().unwrap_or_default(); + + let select = |kind| match auth_profile_names_by_kind(base, &store, org, kind).as_slice() { + [] => Ok(None), + [name] => Ok(Some((*name).to_string())), + _ => bail!("multiple {kind:?} logins"), + }; + + let source = match 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(source) => source, + Err(_) => return Ok(None), + }; + + Ok(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, + }) +} + +fn missing_org_for_stored_logins_error(store: &AuthStore) -> Option { + let candidates = store.profiles.iter().collect::>(); + if candidates.is_empty() { + return None; + } + + let labels = candidates + .iter() + .map(|(_, profile)| auth_slot_label(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 { - match store + 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(auth_slot_label) + .unwrap_or_else(|| "saved auth login".to_string()) } fn select_profile_from_store( @@ -1051,6 +1652,7 @@ fn select_profile_from_store( current: Option<&str>, store: &AuthStore, ) -> Result { + let names: Vec<&str> = names.to_vec(); let labels: Vec = names .iter() .map(|name| profile_label_from_store(name, store)) @@ -1062,161 +1664,98 @@ 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 maybe_select_profile_for_auth( +fn candidate_identities<'a>(names: &[&'a str], store: &'a AuthStore) -> Vec { + names + .iter() + .map(|name| { + store + .profiles + .get(*name) + .map(|profile| { + profile_identity_label(profile).unwrap_or_else(|| auth_slot_label(profile)) + }) + .unwrap_or_else(|| "saved auth login".to_string()) + }) + .collect() +} + +fn select_profile_for_auth( base: &BaseArgs, store: &AuthStore, cfg_org: &Option, + kind: AuthKind, 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 org = effective_org_name(base, cfg_org); + let candidates = auth_profile_names_by_kind(base, store, org, kind); + let label = match kind { + AuthKind::Oauth => "OAuth login", + AuthKind::ApiKey => "API key", + }; + select_auth_profile_candidate( + label, + org, + &candidates, + store, + can_prompt && kind == AuthKind::ApiKey, + ) +} - let matching_profiles = profile_names_for_org(org, store); - if matching_profiles.is_empty() { - return Ok(None); +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) } - - if !can_prompt { + _ => { + let identities = candidate_identities(candidates, store).join(", "); + if kind_label == "OAuth login" { + bail!( + "multiple Braintrust OAuth instances are available: {identities}. Run `bt switch` or pass --app-url ." + ); + } + 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 profiles for org '{org}': {}. Use --profile to disambiguate.", - matching_profiles.join(", ") + "multiple {kind_label} logins available: {identities}. Pass --app-url , rerun interactively, or remove one with `bt auth logout`." ); } - - 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 profiles available: {}. Pass --profile , set BRAINTRUST_PROFILE, or configure an org.", - names.join(", ") - ); } - - select_profile_from_store("Select org", &names, None, store).map(Some) -} - -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!( - "profile '{profile_name}' not found; run `bt auth profiles` or `bt auth login --profile {}`", - shell_quote_arg(profile_name) - ) - })?; - 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 profile '{profile_name}'; re-run `bt auth login --profile {}`", - shell_quote_arg(profile_name) - ), - ) - })?) - }; - - 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; } + if base + .org_name + .as_deref() + .is_some_and(|org| org.trim().is_empty()) + { + bail!("API-key login requires a non-empty organization"); + } let has_explicit_api_key = base.api_key.as_ref().is_some_and(|k| !k.trim().is_empty()); if !has_explicit_api_key && ui::can_prompt() { @@ -1235,12 +1774,20 @@ async fn run_login_set(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> { None => prompt_api_key()?, }; + if matches!( + base.api_key_source, + Some(crate::args::ArgValueSource::EnvVariable) + ) { + eprintln!( + "Using BRAINTRUST_API_KEY to log in\nUse `bt auth login --oauth` to log in with OAuth in a web browser" + ); + } + let login_app_url = base .app_url .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(), @@ -1250,8 +1797,7 @@ 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 configured_org = configured_org_for_app_url(&login_app_url); let selected_org = select_login_org( login_orgs.clone(), match requested_org_resolution { @@ -1261,62 +1807,42 @@ async fn run_login_set(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> { } RequestedOrgResolution::SwitchToOauth => unreachable!("handled above"), }, - default_org_name.as_deref(), + configured_org.as_deref(), interactive, base.verbose, - true, explicitly_quiet(base), )?; - 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, + 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(), + Some(&selected_org), + &login_orgs, + ui::can_prompt(), )?; - if should_confirm_overwrite { - confirm_profile_overwrite(&profile_name)?; - } commit_api_key_profile( - &profile_name, &api_key, selected_api_url.clone(), - base.app_url.clone(), - selected_org.as_ref().map(|org| org.name.clone()), + Some(login_app_url.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(), - ) - .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", }), || { ui::print_command_status(ui::CommandStatus::Success, &human); - ui::print_command_status( - ui::CommandStatus::Success, - &format!("Switched to {}", context_update.display), - ); - if base.verbose { - eprintln!("Wrote to {}", context_update.path.display()); - } }, ) } @@ -1330,23 +1856,12 @@ 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)); - 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())) @@ -1381,147 +1896,130 @@ 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 store = load_auth_store()?; - let default_org_name = - default_login_org_name(&store, base.profile.as_deref(), base.org_name.as_deref()); + let configured_org = configured_org_for_app_url(&app_url); let selected_org = select_login_org( login_orgs.clone(), base.org_name.as_deref(), - default_org_name.as_deref(), + configured_org.as_deref(), ui::can_prompt(), base.verbose, - true, explicitly_quiet(base), )?; - 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, - &oauth_tokens, - selected_api_url.clone(), - app_url.clone(), - client_id.clone(), - selected_org.as_ref().map(|org| org.name.clone()), + let selected_org = selected_org.ok_or_else(|| { + anyhow::anyhow!( + "OAuth login requires an organization; pass --org or rerun interactively" + ) + })?; + let selected_api_url = resolve_profile_api_url( + base.api_url.clone(), + Some(&selected_org), + &login_orgs, + ui::can_prompt(), )?; - let context_update = persist_post_login_context( - base, - &profile_name, - &oauth_tokens.access_token, - &selected_api_url, - &app_url, - selected_org.as_ref(), - ) - .await - .context("login succeeded, but failed to update active context")?; - let human = format_login_success(&selected_org, &profile_name, &selected_api_url); + commit_oauth_profile(&oauth_tokens, api_url.clone(), app_url.clone())?; + let human = format_login_success(Some(&selected_org), &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": selected_org.name, + "org_id": selected_org.id, "api_url": selected_api_url, "app_url": app_url, "status": "ok", }), || { ui::print_command_status(ui::CommandStatus::Success, &human); - ui::print_command_status( - ui::CommandStatus::Success, - &format!("Switched to {}", context_update.display), - ); - if base.verbose { - eprintln!("Wrote to {}", context_update.path.display()); - } }, ) } pub(crate) fn commit_api_key_profile( - profile_name: &str, api_key: &str, api_url: String, app_url: Option, - org_name: Option, + org_id: String, + org_name: String, ) -> Result<()> { - save_profile_secret(profile_name, api_key)?; - let _ = delete_profile_oauth_refresh_token(profile_name); - let _ = delete_profile_oauth_access_token(profile_name); + 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, AuthProfile { auth_kind: AuthKind::ApiKey, api_url: Some(api_url), app_url, - org_name, - oauth_client_id: None, + org_id: Some(org_id), + org_name: Some(org_name), 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) } fn commit_oauth_profile( - profile_name: &str, tokens: &OAuthTokenResponse, api_url: String, app_url: String, - client_id: String, - org_name: Option, ) -> 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 app_url = canonical_url(&app_url).to_string(); + let slot_key = oauth_slot_key(&app_url); let mut store = load_auth_store()?; + if let Some(old_profile) = store.profiles.get(&slot_key) { + delete_all_profile_secrets(&slot_key, old_profile); + } + 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); + store.profiles.insert( - profile_name.to_string(), + slot_key, AuthProfile { auth_kind: AuthKind::Oauth, api_url: Some(api_url), app_url: Some(app_url), - org_name, - oauth_client_id: Some(client_id), + org_id: None, + org_name: None, 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) @@ -1529,37 +2027,47 @@ fn commit_oauth_profile( 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_profile_for_auth( + base, + &store, + &cfg_org, + AuthKind::Oauth, + ui::can_prompt(), + )? + .ok_or_else(|| { + anyhow::anyhow!( + "no OAuth login selected; pass --app-url or run `bt status` 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 status` to see available logins") + })?; - let api_url = profile + let api_url = base .api_url .clone() + .or_else(|| profile.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 profile '{profile_name}' is missing client_id; re-run `bt auth login --oauth --profile {}`", - shell_quote_arg(&profile_name) - ) - })?; 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 {}`", - shell_quote_arg(&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 `{}`", + auth_slot_label(&profile), + oauth_reauth_command(&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) ); if let Some(expires_at) = previous_expires_at { let now = current_unix_timestamp(); @@ -1571,9 +2079,7 @@ async fn run_login_refresh(base: &BaseArgs) -> Result<()> { eprintln!("Cached access token expiry before refresh: unknown"); } - let refreshed = - refresh_oauth_access_token(&api_url, &refresh_token, &client_id, profile_name.as_str()) - .await?; + let refreshed = refresh_oauth_access_token(&api_url, &refresh_token, &profile).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() { @@ -1582,13 +2088,19 @@ 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)?; - if let Some(expires_at) = new_expires_at { let now = current_unix_timestamp(); let remaining = expires_at.saturating_sub(now); @@ -1605,8 +2117,9 @@ async fn run_login_refresh(base: &BaseArgs) -> Result<()> { emit_result( base.json, serde_json::json!({ - "name": profile_name, "auth": "oauth", + "app_url": profile.app_url, + "user_email": profile.email, "access_expires_at": new_expires_at, "refresh_token_rotated": refresh_rotated, "status": "ok", @@ -1615,485 +2128,251 @@ async fn run_login_refresh(base: &BaseArgs) -> Result<()> { ) } -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(), "--profile/BRAINTRUST_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 profile selected; pass --profile , set BRAINTRUST_PROFILE, or configure an org") -} - -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()); - } - - Ok(suggested_org_name - .map(str::trim) - .filter(|name| !name.is_empty()) - .unwrap_or("profile") - .to_string()) -} - -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; - } - - 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()) -} - -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() +fn format_login_success(selected_org: Option<&LoginOrgInfo>, api_url: &str) -> String { + selected_org + .map(|org| format!("Logged in as {} (api: {api_url})", org.name)) + .unwrap_or_else(|| format!("Logged in (api: {api_url})")) } -fn next_available_profile_name(base_name: &str, store: &AuthStore) -> String { - if !store.profiles.contains_key(base_name) { - return base_name.to_string(); +/// 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<()> { + if json { + println!("{}", serde_json::to_string(&payload)?); + } else { + human(); } - - (2u32..) - .map(|idx| format!("{base_name}-{idx}")) - .find(|candidate| !store.profiles.contains_key(candidate)) - .expect("profile name sequence is infinite") + Ok(()) } -fn resolve_api_key_login_profile_name( - explicit_profile: Option<&str>, - suggested_org_name: Option<&str>, - selected_api_url: &str, +fn filter_auth_store( + base: &BaseArgs, 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 + kind: Option, + api_key_hint: Option<&str>, +) -> AuthStore { + let mut filtered = store.clone(); + filtered.profiles.retain(|_, profile| { + profile_matches_url_filters(base, profile) + && kind.is_none_or(|kind| profile.auth_kind == kind) + && api_key_hint.is_none_or(|hint| { + profile.auth_kind == AuthKind::ApiKey + && profile.api_key_hint.as_deref() == Some(hint.trim()) + }) }); - - if has_matching_api_key_profile { - return Ok((next_available_profile_name(&default_name, store), false)); - } - - Ok(( - default_name.clone(), - store.profiles.contains_key(&default_name), - )) -} - -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), - )) -} - -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 + filtered } -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 -} - -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(()); +async fn filter_auth_store_for_org( + base: &BaseArgs, + store: &mut AuthStore, + candidates: AuthStore, + org: Option<&str>, +) -> Result { + let Some(org) = org else { + return Ok(candidates); }; - let confirmed = Confirm::new() - .with_prompt(format!( - "Profile '{profile_name}' already exists. Overwrite?" - )) - .default(false) - .interact_on(&term)?; - if !confirmed { - bail!("login cancelled"); + let mut filtered = AuthStore::default(); + for (slot, profile) in candidates.profiles { + let matches = match profile.auth_kind { + AuthKind::ApiKey => profile_matches_org_identifier(&profile, org), + AuthKind::Oauth => { + let mut oauth_base = base.clone(); + oauth_base.app_url = Some(profile_app_url(&profile).to_string()); + if oauth_base.api_url_source.is_none() { + oauth_base.api_url = profile.api_url.clone(); + } + let token = load_oauth_access_token(&oauth_base, store, &slot).await?; + let orgs = fetch_login_orgs(&token, profile_app_url(&profile)).await?; + find_login_org(&orgs, org).is_some() + } + }; + if matches { + filtered.profiles.insert(slot, profile); + } } - Ok(()) + Ok(filtered) } -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})"), - } +pub(crate) struct SavedLoginStatus { + pub logins: Vec, + pub credentials_path: PathBuf, } -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(), +/// Load and verify the saved logins shown at the top of `bt status`. +pub(crate) async fn saved_login_status() -> Result { + let mut store = load_auth_store()?; + let credentials_path = auth_store_path()?; + if store.profiles.is_empty() { + return Ok(SavedLoginStatus { + logins: Vec::new(), + credentials_path, + }); } -} -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(), + let mut logins = verify_all_profiles_from_store(&store).await; + reconcile_verified_auth_slots(&mut store, &logins)?; + if logins + .iter() + .all(|login| login.status == "error" && login.error.as_deref() != Some("invalid API key")) + { + for login in &mut logins { + login.status = "unchecked".to_string(); + login.error = None; + } } -} - -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) -} - -async fn persist_post_login_context( - base: &BaseArgs, - profile_name: &str, - credential: &str, - api_url: &str, - app_url: &str, - selected_org: Option<&LoginOrgInfo>, -) -> Result { - let project = - resolve_post_login_project(base, credential, api_url, app_url, selected_org).await?; - let path = if ui::can_prompt() && config::local_path().is_some() { - switch::select_scope()?.0 - } else { - config::global_path()? - }; - - let mut cfg = config::load_file(&path); - switch::apply_switch_config( - &mut cfg, - Some(profile_name), - selected_org.map(|org| org.name.as_str()), - project.as_ref(), - ); - config::save_file(&path, &cfg) - .context(format!("Could not save config to {}", path.display()))?; - Ok(PostLoginContextUpdate { - display: format_post_login_context(selected_org, project.as_ref()), - path, + Ok(SavedLoginStatus { + logins, + credentials_path, }) } -/// 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<()> { - if json { - println!("{}", serde_json::to_string(&payload)?); +pub(crate) fn print_saved_login_status(base: &BaseArgs, status: &SavedLoginStatus) { + if status.logins.is_empty() { + println!("No saved auth logins. Run `bt auth login` to create one."); + } else if status + .logins + .iter() + .all(|login| login.status == "unchecked") + { + eprintln!("Could not reach Braintrust API. Showing saved auth logins:"); + for login in &status.logins { + eprintln!(" {}", format_verification_line(login)); + } } else { - human(); - } - Ok(()) -} - -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 + for login in &status.logins { + let cmd_status = match login.status.as_str() { + "ok" => crate::ui::CommandStatus::Success, + "expired" => crate::ui::CommandStatus::Warning, + _ => crate::ui::CommandStatus::Error, + }; + crate::ui::print_command_status(cmd_status, &format_verification_line(login)); } - None => store, - }; - - if filtered_store.profiles.is_empty() { - return emit_result(base.json, serde_json::json!([]), || { - println!("No saved profiles. Run `bt auth login` to create one.") - }); - } - - let verifications = verify_all_profiles_from_store(&filtered_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)?; - return Ok(()); - } - - if base.json { - println!("{}", serde_json::to_string(&verifications)?); - return Ok(()); - } - - for v in &verifications { - let cmd_status = match v.status.as_str() { - "ok" => crate::ui::CommandStatus::Success, - "expired" => crate::ui::CommandStatus::Warning, - _ => crate::ui::CommandStatus::Error, - }; - crate::ui::print_command_status(cmd_status, &format_verification_line(v)); } if base.verbose { - if let Ok(path) = auth_store_path() { - eprintln!("\nCredentials: {}", path.display()); - } + eprintln!("\nCredentials: {}\n", status.credentials_path.display()); } +} - 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, + "app_url": profile.app_url, + "api_url": profile.api_url, + "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 status` to see available logins") + })?; + let label = auth_slot_label(&profile); if !force { - if let Some(term) = ui::prompt_term() { - let confirmed = Confirm::new() - .with_prompt(format!("Delete profile '{profile_name}'?")) - .default(false) - .interact_on(&term)?; - if !confirmed { - return emit_result( - base_json, - serde_json::json!({ "name": profile_name, "status": "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") + }); } } 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<()> { +async 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)); - } - 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() + let requested_org = if matches!( + base.org_name_source, + Some(crate::args::ArgValueSource::CommandLine | crate::args::ArgValueSource::EnvVariable) + ) { + config::org_option(base.org_name.as_deref()) } else { - bail!("multiple profiles exist. Use --profile to specify which one."); + None + }; + let mut filter_base = base.clone(); + if filter_base.app_url_source.is_none() { + filter_base.app_url = None; + } + if filter_base.api_url_source.is_none() { + filter_base.api_url = None; + } + let candidates = filter_auth_store( + &filter_base, + &store, + args.oauth.then_some(AuthKind::Oauth), + args.api_key_hint.as_deref(), + ); + let mut mutable_store = store.clone(); + let filtered = + filter_auth_store_for_org(&filter_base, &mut mutable_store, candidates, requested_org) + .await?; + let candidates = filtered + .profiles + .keys() + .map(String::as_str) + .collect::>(); + + let cfg_org = config_auth_context(&base); + let current_org = effective_org_name(&base, &cfg_org); + let profile_name = match candidates.len() { + 0 => bail!("no matching auth login found; run `bt status` to see available logins"), + 1 => candidates[0].to_string(), + _ if ui::can_prompt() => select_profile_from_store( + "Select auth login to log out", + &candidates, + current_org, + &filtered, + )?, + _ => { + let labels = candidate_identities(&candidates, &filtered).join(", "); + bail!( + "multiple auth logins match: {labels}. Rerun interactively, use --app-url with --oauth, or use --org with --api-key-hint ." + ); + } }; run_login_delete(&profile_name, args.force, base.json) @@ -2115,18 +2394,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()), @@ -2137,16 +2418,25 @@ fn load_credential_for_profile(name: &str, profile: &AuthProfile) -> CredentialL #[derive(Debug, Clone, Serialize)] 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, #[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, #[serde(skip_serializing_if = "Option::is_none")] pub api_key_hint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub app_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub api_url: Option, pub status: String, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, @@ -2154,8 +2444,7 @@ pub struct ProfileVerification { fn build_verification( name: &str, - auth_kind: &str, - org: Option, + profile: &AuthProfile, jwt_id: Option, api_key_hint: Option, status: ProfileStatus, @@ -2168,11 +2457,24 @@ fn build_verification( }; ProfileVerification { name: name.to_string(), - auth: auth_kind.to_string(), - org, - user_name: jwt_id.as_ref().and_then(|j| j.name.clone()), - user_email: jwt_id.as_ref().and_then(|j| j.email.clone()), + slot_hash: None, + auth: auth_kind_label(profile.auth_kind).to_string(), + org: profile.org_name.clone(), + org_id: profile + .org_id + .clone() + .filter(|org_id| !org_id.trim().is_empty()), + user_name: jwt_id + .as_ref() + .and_then(|j| j.name.clone()) + .or_else(|| profile.user_name.clone()), + user_email: jwt_id + .as_ref() + .and_then(|j| j.email.clone()) + .or_else(|| profile.email.clone()), api_key_hint, + app_url: profile.app_url.clone(), + api_url: profile.api_url.clone(), status: status_str.to_string(), error, } @@ -2180,26 +2482,21 @@ 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 mk = |status, jwt_id: Option, hint: Option| { - build_verification( - name, - auth_kind, - profile.org_name.clone(), - jwt_id, - hint, - status, - ) + build_verification(name, profile, jwt_id, hint, status) }; 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 { @@ -2208,19 +2505,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 profile.auth_kind == AuthKind::ApiKey { + 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()); + } + 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, None) + mk(status, jwt_id, hint) } } } @@ -2239,28 +2555,108 @@ 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}")); +fn sort_profile_verifications(verifications: &mut [ProfileVerification]) { + verifications.sort_by(|a, b| { + a.org + .as_deref() + .unwrap_or("") + .cmp(b.org.as_deref().unwrap_or("")) + .then_with(|| a.name.cmp(&b.name)) + }); +} + +fn reconcile_verified_auth_slots( + store: &mut AuthStore, + verifications: &[ProfileVerification], +) -> 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 profile.auth_kind == AuthKind::ApiKey { + 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 { + profile.org_id = None; + 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 subject = if v.auth == "oauth" { + v.app_url + .clone() + .unwrap_or_else(|| DEFAULT_APP_URL.to_string()) + } else { + v.org.clone().unwrap_or_else(|| "(unknown org)".to_string()) + }; + let mut parts = vec![subject, v.auth.clone()]; match v.status.as_str() { "ok" => { - let id = match (&v.user_name, &v.user_email) { - (Some(name), Some(email)) => Some(format!("{name} ({email})")), - (None, Some(email)) => Some(email.clone()), - _ => v.api_key_hint.clone(), - }; - if let Some(id) = id { + if let Some(id) = identity_label( + v.user_name.as_deref(), + v.user_email.as_deref(), + v.api_key_hint.as_deref(), + ) { parts.push(id); } } "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()), + }, + "unchecked" => { + if let Some(id) = identity_label( + v.user_name.as_deref(), + v.user_email.as_deref(), + v.api_key_hint.as_deref(), + ) { + parts.push(id); + } + } _ => { if let Some(ref e) = v.error { parts.push(e.clone()); @@ -2270,50 +2666,6 @@ fn format_verification_line(v: &ProfileVerification) -> String { parts.join(" — ") } -fn print_saved_profiles(store: &AuthStore, json: bool) -> Result<()> { - if json { - let output: Vec = store - .profiles - .iter() - .map(|(name, p)| { - serde_json::json!({ - "name": name, - "auth": match p.auth_kind { AuthKind::ApiKey => "api_key", AuthKind::Oauth => "oauth" }, - "org": p.org_name, - "user_name": p.user_name, - "user_email": p.email, - "api_key_hint": p.api_key_hint, - "status": "unchecked" - }) - }) - .collect(); - 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}"); - } - } - Ok(()) -} - async fn fetch_login_orgs(api_key: &str, app_url: &str) -> Result> { let login_url = format!("{}/api/apikey/login", app_url.trim_end_matches('/')); let client = build_http_client(crate::http::DEFAULT_HTTP_TIMEOUT) @@ -2349,18 +2701,12 @@ fn select_login_org( default_org_name: Option<&str>, interactive: bool, verbose: bool, - allow_cross_org: bool, quiet_requested: bool, ) -> Result> { if orgs.is_empty() { bail!("no organizations found for this credential"); } - orgs.sort_by(|a, b| { - a.name - .to_ascii_lowercase() - .cmp(&b.name.to_ascii_lowercase()) - .then_with(|| a.name.cmp(&b.name)) - }); + sort_login_orgs(&mut orgs); if let Some(name) = requested_org_name { return find_login_org(&orgs, name) @@ -2377,40 +2723,40 @@ fn select_login_org( return Ok(None); } - let default_org_matched = move_default_login_org_first(&mut orgs, default_org_name); - let offset = if allow_cross_org { 1 } else { 0 }; - let mut labels: Vec = Vec::new(); - if allow_cross_org { - labels.push( - "No default org (cross-org mode; pass --org or BRAINTRUST_ORG_NAME when needed)" - .to_string(), - ); - } - labels.extend(orgs.iter().map(|org| { - if verbose { - let api_url = org.api_url.as_deref().unwrap_or(DEFAULT_API_URL); - format!("{} [{}] ({})", org.name, org.id, api_url) - } else { - org.name.clone() - } - })); + move_default_login_org_first(&mut orgs, default_org_name); + let labels: Vec = orgs + .iter() + .map(|org| { + if verbose { + let api_url = org.api_url.as_deref().unwrap_or(DEFAULT_API_URL); + format!("{} [{}] ({})", org.name, org.id, api_url) + } else { + org.name.clone() + } + }) + .collect(); let label_refs: Vec<&str> = labels.iter().map(String::as_str).collect(); if !quiet_requested { eprintln!("\n\nA Braintrust organization is usually a team or a company."); } - let default = if default_org_matched { offset } else { 0 }; - let selection = ui::fuzzy_select("Select organization", &label_refs, default)?; - if allow_cross_org && selection == 0 { - return Ok(None); - } + let selection = ui::fuzzy_select("Select organization", &label_refs, 0)?; Ok(Some( orgs.into_iter() - .nth(selection - offset) + .nth(selection) .expect("selected index should be in range"), )) } +fn sort_login_orgs(orgs: &mut [LoginOrgInfo]) { + orgs.sort_by(|a, b| { + a.name + .to_ascii_lowercase() + .cmp(&b.name.to_ascii_lowercase()) + .then_with(|| a.name.cmp(&b.name)) + }); +} + fn move_default_login_org_first( orgs: &mut Vec, default_org_name: Option<&str>, @@ -2434,7 +2780,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() @@ -2442,13 +2788,18 @@ fn find_login_org_index(orgs: &[LoginOrgInfo], requested_org_name: &str) -> Opti }) } -fn missing_requested_org_error(orgs: &[LoginOrgInfo], requested_org_name: &str) -> anyhow::Error { - let available = orgs - .iter() +fn login_org_names(orgs: &[LoginOrgInfo]) -> String { + orgs.iter() .map(|org| org.name.as_str()) .collect::>() - .join(", "); - anyhow::anyhow!("org '{requested_org_name}' not found. Available: {available}") + .join(", ") +} + +fn missing_requested_org_error(orgs: &[LoginOrgInfo], requested_org_name: &str) -> anyhow::Error { + anyhow::anyhow!( + "org '{requested_org_name}' not found. Available: {}", + login_org_names(orgs) + ) } fn resolve_requested_org_for_api_key_login( @@ -2505,12 +2856,16 @@ fn resolve_profile_api_url( explicit_api_url: Option, selected_org: Option<&LoginOrgInfo>, orgs: &[LoginOrgInfo], + can_prompt: bool, ) -> Result { if let Some(api_url) = explicit_api_url { return Ok(api_url); } - if let Some(api_url) = selected_org.and_then(|org| org.api_url.clone()) { - return Ok(api_url); + if let Some(selected_org) = selected_org { + return Ok(selected_org + .api_url + .clone() + .unwrap_or_else(|| DEFAULT_API_URL.to_string())); } let mut api_urls = orgs @@ -2527,30 +2882,19 @@ fn resolve_profile_api_url( .unwrap_or_else(|| DEFAULT_API_URL.to_string())); } + if can_prompt { + let idx = ui::fuzzy_select("Select API URL", &api_urls, 0)?; + return Ok(api_urls + .into_iter() + .nth(idx) + .expect("selected API URL should be in range")); + } + bail!( - "multiple organizations expose different API URLs; choose an organization or pass --api-url explicitly" + "multiple organizations expose different API URLs; pass --org to pick one, or --api-url explicitly" ) } -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 generate_random_token(num_bytes: usize) -> Result { let mut bytes = vec![0u8; num_bytes]; getrandom::fill(&mut bytes) @@ -2907,7 +3251,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, @@ -2923,7 +3266,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()), @@ -2934,21 +3277,20 @@ async fn exchange_oauth_authorization_code( fn map_refresh_oauth_error( api_url: &str, - profile_name: &str, + profile: &AuthProfile, 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_slot_label(profile) + ); 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 {}`", - shell_quote_arg(profile_name) - )); + message.push_str(&format!("; re-run `{}`", oauth_reauth_command(profile))); return recoverable_auth_error(RecoverableAuthErrorKind::OauthRefreshToken, message); } } @@ -2962,8 +3304,7 @@ fn map_refresh_oauth_error( async fn refresh_oauth_access_token( api_url: &str, refresh_token: &str, - client_id: &str, - profile_name: &str, + profile: &AuthProfile, ) -> Result { let http_client = build_http_client_from_builder( reqwest::Client::builder() @@ -2976,7 +3317,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() @@ -2985,12 +3326,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, profile, status, &body)); } response @@ -3023,18 +3359,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), @@ -3111,6 +3443,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(); @@ -3174,40 +3547,26 @@ fn load_secret_store() -> Result { fn save_secret_store(store: &SecretStore) -> Result<()> { let path = secret_store_path()?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create directory {}", parent.display()))?; - } + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent) + .with_context(|| format!("failed to create directory {}", parent.display()))?; let data = serde_json::to_string_pretty(store).context("failed to serialize secret store")?; - let temp_path = path.with_extension("tmp"); - let mut file = fs::File::create(&temp_path) - .with_context(|| format!("failed to write temp secret store {}", temp_path.display()))?; + // A uniquely-named temp file (created `0600` by `tempfile`) prevents two + // concurrent `bt` writers from sharing one `.tmp` inode and renaming + // interleaved bytes over the store, and closes the umask window that a + // truncate-then-chmod on a fixed name would leave open. + let mut file = tempfile::NamedTempFile::new_in(parent) + .with_context(|| format!("failed to create temp secret store in {}", parent.display()))?; file.write_all(data.as_bytes()) - .with_context(|| format!("failed to write temp secret store {}", temp_path.display()))?; + .context("failed to write temp secret store")?; file.write_all(b"\n") - .with_context(|| format!("failed to write temp secret store {}", temp_path.display()))?; - file.sync_all() - .with_context(|| format!("failed to flush temp secret store {}", temp_path.display()))?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600)).with_context(|| { - format!( - "failed to set permissions on temp secret store {}", - temp_path.display() - ) - })?; - } - - fs::rename(&temp_path, &path).with_context(|| { - format!( - "failed to move temp secret store {} to {}", - temp_path.display(), - path.display() - ) - })?; + .context("failed to write temp secret store")?; + file.as_file() + .sync_all() + .context("failed to flush temp secret store")?; + file.persist(&path) + .with_context(|| format!("failed to move temp secret store to {}", path.display()))?; #[cfg(unix)] { @@ -3463,9 +3822,16 @@ fn save_profile_oauth_refresh_token(profile_name: &str, refresh_token: &str) -> save_profile_secret(&key, refresh_token) } -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<()> { @@ -3478,9 +3844,16 @@ fn save_profile_oauth_access_token(profile_name: &str, access_token: &str) -> Re save_profile_secret(&key, access_token) } -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<()> { @@ -3488,8 +3861,40 @@ fn delete_profile_oauth_access_token(profile_name: &str) -> Result<()> { 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); + } + } +} + +/// Delete every secret a profile could reference: those stored under its own +/// slot key and those under its lazy legacy fallback key. Used when a duplicate +/// login is discarded during canonicalization so nothing is orphaned. +fn delete_all_profile_secrets(slot_key: &str, profile: &AuthProfile) { + match profile.auth_kind { + AuthKind::ApiKey => { + let _ = delete_profile_secret(slot_key); + } + AuthKind::Oauth => { + let _ = delete_profile_oauth_refresh_token(slot_key); + let _ = delete_profile_oauth_access_token(slot_key); + } + } + delete_legacy_profile_secrets(profile); +} + fn load_valid_cached_oauth_access_token( profile_name: &str, + profile: &AuthProfile, expires_at: Option, ) -> Result> { let Some(expires_at) = expires_at else { @@ -3498,7 +3903,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 { @@ -3556,32 +3961,239 @@ pub fn obscure_api_key(key: &str) -> String { if !key.is_ascii() || key.len() <= 8 { return "****".to_string(); } - let prefix_end = key.find('-').map(|i| i + 1).unwrap_or(0); let suffix_start = key.len().saturating_sub(5); + let prefix_end = key.find('-').map(|i| i + 1).unwrap_or(0); + // A late first dash can push the prefix up to (or past) the suffix, leaving + // no masked middle and revealing the whole key. Fully mask instead. + if prefix_end >= suffix_start { + return "****".to_string(); + } format!("{}****{}", &key[..prefix_end], &key[suffix_start..]) } -fn current_unix_timestamp() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or(0) +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(app_url: &str) -> String { + format!("oauth::{}", sha256_hex(canonical_url(app_url))) +} + +fn api_key_slot_key(api_key_hash: &str, org_id: &str) -> String { + format!("{api_key_hash}::{org_id}") +} + +fn current_unix_timestamp() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +fn load_auth_store() -> Result { + let path = auth_store_path()?; + load_auth_store_from_path(&path) +} + +fn load_auth_store_from_path(path: &Path) -> Result { + if !path.exists() { + return Ok(AuthStore::default()); + } + + let data = fs::read_to_string(path) + .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()))?; + let migrated = migrate_auth_store(store.clone()); + if migrated != store { + // The migrated store is already usable in memory, so a failed write-back + // must not break read-only commands such as `bt status`. Warn + // and proceed; the next writable run retries the migration. + match save_auth_store_to_path(path, &migrated) { + // Only prune once the collapsed store is durably on disk; otherwise + // the on-disk file still references the dropped duplicate and the + // next load must be able to retry the migration. + Ok(()) => prune_orphaned_migration_secrets(&store, &migrated), + Err(err) => eprintln!( + "warning: Migrating {} to use the new format failed. Please delete this file and login again. ({err})", + path.display() + ), + } + } + Ok(migrated) +} + +fn migrate_auth_store(store: AuthStore) -> AuthStore { + let mut migrated = AuthStore::default(); + let mut oauth_refresh_usable = BTreeMap::::new(); + for (old_key, mut profile) in store.profiles { + normalize_profile_cached_fields_from_key(&old_key, &mut profile); + let refresh_usable = profile.auth_kind == AuthKind::Oauth + && matches!( + load_profile_oauth_refresh_token_for_profile(&old_key, &profile), + Ok(Some(_)) + ); + if profile.auth_kind == AuthKind::Oauth { + profile.org_id = None; + profile.org_name = None; + profile.app_url = Some(canonical_url(profile_app_url(&profile)).to_string()); + } + 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 let Some(existing) = migrated.profiles.get(&new_key) { + let existing_usable = oauth_refresh_usable.get(&new_key).copied().unwrap_or(false); + let replace = match (existing.auth_kind, profile.auth_kind) { + (AuthKind::Oauth, AuthKind::Oauth) => { + (refresh_usable && !existing_usable) + || (refresh_usable == existing_usable + && should_replace_migrated_profile(existing, &profile)) + } + _ => false, + }; + if !replace { + continue; + } + } + oauth_refresh_usable.insert(new_key.clone(), refresh_usable); + migrated.profiles.insert(new_key, profile); + } + migrated +} + +/// Secret slots left dangling after migration collapsed duplicate logins onto a +/// shared canonical key. A surviving login keeps its secret under its +/// `legacy_secret_key` (until it is lazily relocated) or, absent one, under its +/// own slot key; any pre-migration key outside that referenced set belonged to a +/// dropped duplicate and can be deleted. Pure so it stays unit-testable; the +/// caller performs the keychain I/O. +fn orphaned_migration_secret_keys<'a>( + before: &'a AuthStore, + after: &AuthStore, +) -> Vec<(&'a str, AuthKind)> { + let referenced: BTreeSet<&str> = after + .profiles + .iter() + .map(|(slot, profile)| { + profile + .legacy_secret_key + .as_deref() + .unwrap_or(slot.as_str()) + }) + .collect(); + before + .profiles + .iter() + .filter(|(key, _)| !referenced.contains(key.as_str())) + .map(|(key, profile)| (key.as_str(), profile.auth_kind)) + .collect() +} + +fn prune_orphaned_migration_secrets(before: &AuthStore, after: &AuthStore) { + for (key, auth_kind) in orphaned_migration_secret_keys(before, after) { + match auth_kind { + AuthKind::ApiKey => { + let _ = delete_profile_secret(key); + } + AuthKind::Oauth => { + let _ = delete_profile_oauth_refresh_token(key); + let _ = delete_profile_oauth_access_token(key); + } + } + } +} + +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, + } +} + +/// Runtime variant of [`should_replace_migrated_profile`] that can read the +/// keychain: when two OAuth logins collapse onto the same slot, keep whichever +/// still has a loadable refresh token (cached access-token expiry is unrelated +/// to which refresh token is live). Falls back to the pure expiry heuristic +/// when both or neither can refresh. +fn should_replace_canonical_profile( + slot_key: &str, + existing: &AuthProfile, + candidate: &AuthProfile, +) -> bool { + if let (AuthKind::Oauth, AuthKind::Oauth) = (existing.auth_kind, candidate.auth_kind) { + let has_refresh = |profile: &AuthProfile| { + matches!( + load_profile_oauth_refresh_token_for_profile(slot_key, profile), + Ok(Some(_)) + ) + }; + match (has_refresh(existing), has_refresh(candidate)) { + (false, true) => return true, + (true, false) => return false, + _ => {} + } + } + should_replace_migrated_profile(existing, candidate) +} + +fn looks_like_sha256_hex(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) } -fn load_auth_store() -> Result { - let path = auth_store_path()?; - load_auth_store_from_path(&path) -} +fn normalize_profile_cached_fields_from_key(current_key: &str, profile: &mut AuthProfile) { + let Some((left, right)) = current_key.split_once("::") else { + return; + }; -fn load_auth_store_from_path(path: &Path) -> Result { - if !path.exists() { - return Ok(AuthStore::default()); + 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('@')) { + 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() { + profile.api_key_hash = Some(left.to_string()); + profile.org_id = Some(right.to_string()); + } + } } +} - 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())) +fn canonical_profile_key(current_key: &str, profile: &AuthProfile) -> String { + match profile.auth_kind { + AuthKind::Oauth => oauth_slot_key(profile_app_url(profile)), + 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<()> { @@ -3590,40 +4202,24 @@ fn save_auth_store(store: &AuthStore) -> Result<()> { } fn save_auth_store_to_path(path: &Path, store: &AuthStore) -> Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create directory {}", parent.display()))?; - } + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent) + .with_context(|| format!("failed to create directory {}", parent.display()))?; let data = serde_json::to_string_pretty(store).context("failed to serialize auth config")?; - let temp_path = path.with_extension("tmp"); - let mut file = fs::File::create(&temp_path) - .with_context(|| format!("failed to write temp auth config {}", temp_path.display()))?; + // Unique temp name (see `save_secret_store`): keeps concurrent writers from + // colliding on a shared `.tmp` inode and publishing a corrupt store. + let mut file = tempfile::NamedTempFile::new_in(parent) + .with_context(|| format!("failed to create temp auth config in {}", parent.display()))?; file.write_all(data.as_bytes()) - .with_context(|| format!("failed to write temp auth config {}", temp_path.display()))?; + .context("failed to write temp auth config")?; file.write_all(b"\n") - .with_context(|| format!("failed to write temp auth config {}", temp_path.display()))?; - file.sync_all() - .with_context(|| format!("failed to flush temp auth config {}", temp_path.display()))?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600)).with_context(|| { - format!( - "failed to set permissions on temp auth config {}", - temp_path.display() - ) - })?; - } - - fs::rename(&temp_path, path).with_context(|| { - format!( - "failed to move temp auth config {} to {}", - temp_path.display(), - path.display() - ) - })?; + .context("failed to write temp auth config")?; + file.as_file() + .sync_all() + .context("failed to flush temp auth config")?; + file.persist(path) + .with_context(|| format!("failed to move temp auth config to {}", path.display()))?; #[cfg(unix)] { @@ -3678,31 +4274,11 @@ mod tests { }; fn make_base() -> BaseArgs { - BaseArgs { - json: false, - verbose: false, - verbose_source: None, - quiet: false, - quiet_source: None, - 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, - api_url: None, - app_url: None, - ca_cert: None, - env_file: None, - } + BaseArgs::default() } - 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() } @@ -3959,6 +4535,7 @@ mod tests { fn setup_global_config(project_id: Option<&str>, org: Option<&str>) { let cfg = crate::config::Config { org: org.map(str::to_string), + project: project_id.map(|_| "test-project".to_string()), project_id: project_id.map(str::to_string), ..crate::config::Config::default() }; @@ -3966,21 +4543,32 @@ mod tests { crate::config::save_global(&cfg).expect("save global config"); } + fn set_global_config_urls(app_url: &str, api_url: Option<&str>) { + let mut cfg = crate::config::load_global().expect("load global config"); + cfg.app_url = Some(app_url.to_string()); + cfg.api_url = api_url.map(str::to_string); + crate::config::save_global(&cfg).expect("save global config URLs"); + } + + fn org_profile(kind: AuthKind, org_id: &str, org_name: &str) -> AuthProfile { + AuthProfile { + auth_kind: kind, + org_id: Some(org_id.into()), + org_name: Some(org_name.into()), + ..Default::default() + } + } + fn setup_auth_store_profiles(profiles: &[(&str, &str, &str, &str)]) { let mut store = AuthStore::default(); for (profile_name, org_name, api_url, app_url) in profiles { store.profiles.insert( (*profile_name).to_string(), AuthProfile { - auth_kind: AuthKind::ApiKey, api_url: Some((*api_url).to_string()), app_url: Some((*app_url).to_string()), org_name: Some((*org_name).to_string()), - oauth_client_id: None, - oauth_access_expires_at: None, - user_name: None, - email: None, - api_key_hint: None, + ..Default::default() }, ); } @@ -4035,30 +4623,29 @@ mod tests { #[test] fn invalid_grant_refresh_error_is_treated_as_recoverable() { + let profile = org_profile(AuthKind::Oauth, "org_test", "BT Staging"); + let command = oauth_reauth_command(&profile); + assert_eq!( + command, + "bt auth login --oauth --app-url https://www.braintrust.dev" + ); let err = map_refresh_oauth_error( "https://api.example.com", - "test profile", + &profile, reqwest::StatusCode::BAD_REQUEST, r#"{"error":"invalid_grant","error_description":"refresh token expired"}"#, ); assert!(is_missing_credential_error(&err)); assert!(err.to_string().contains("refresh token expired")); - assert!(err - .to_string() - .contains("re-run `bt auth login --oauth --profile 'test profile'`")); - } - - #[test] - fn shell_quote_arg_escapes_single_quotes() { - assert_eq!(shell_quote_arg("test profile's"), "'test profile'\\''s'"); + assert!(err.to_string().contains(&format!("re-run `{command}`"))); } #[test] fn nonrecoverable_refresh_errors_remain_nonrecoverable() { let err = map_refresh_oauth_error( "https://api.example.com", - "work", + &org_profile(AuthKind::Oauth, "org_test", "test-org"), reqwest::StatusCode::BAD_REQUEST, "unexpected response", ); @@ -4124,8 +4711,11 @@ mod tests { } async fn login_read_only_probe(&self, org_name: Option<&str>) -> Result { - self.login_read_only_with_base(base_args_for_path_probe(org_name)) - .await + let mut base = base_args_for_path_probe(org_name); + if let Some(org) = org_name.filter(|org| !org.trim().is_empty()) { + base.app_url = Some(spawn_api_key_login_server(org)); + } + self.login_read_only_with_base(base).await } } @@ -4137,817 +4727,643 @@ mod tests { } } - #[test] - fn default_app_url_is_www() { - assert_eq!(DEFAULT_APP_URL, "https://www.braintrust.dev"); - } - - #[test] - fn save_and_load_auth_store_round_trip() { - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock") - .as_nanos(); - let dir = std::env::temp_dir().join(format!("bt-auth-store-test-{unique}")); - fs::create_dir_all(&dir).expect("create dir"); - let path = dir.join("auth.json"); - - 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() - }, - ); - - save_auth_store_to_path(&path, &store).expect("save"); - let loaded = load_auth_store_from_path(&path).expect("load"); - - assert!(loaded.profiles.contains_key("work")); - - let _ = fs::remove_dir_all(&dir); - } - - #[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, + fn spawn_login_response_server(status: &str, body: String) -> String { + use std::io::{Read as _, Write as _}; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind login server"); + let address = listener.local_addr().expect("login server address"); + let status = status.to_string(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept login request"); + let mut request = [0u8; 4096]; + let _ = stream.read(&mut request); + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .expect("write login response"); + }); + format!("http://{address}") + } + + fn spawn_api_key_login_server(org_name: &str) -> String { + spawn_login_response_server( + "200 OK", + serde_json::json!({ + "org_info": [{ + "id": "org_test", + "name": org_name, + "api_url": "https://api.example.test" + }] + }) + .to_string(), ) - .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_profile_and_org_independently() { - let base = make_base(); - let cfg = auth_config(Some("default-profile"), Some("local-org")); - - let (profile, 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() { - 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); - - assert_eq!(profile, None); - assert_eq!(org.as_deref(), Some("local-org")); + fn default_app_url_is_www() { + assert_eq!(DEFAULT_APP_URL, "https://www.braintrust.dev"); } - #[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(); + fn save_cached_oauth_login(store: &mut AuthStore, app_url: &str) -> String { + let slot_key = oauth_slot_key(app_url); store.profiles.insert( - "work".to_string(), + slot_key.clone(), AuthProfile { - auth_kind: AuthKind::ApiKey, - api_url: Some("https://api.example.com".to_string()), - app_url: None, + api_url: Some("https://api.example.test".to_string()), + app_url: Some(app_url.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()), + auth_kind: AuthKind::Oauth, + org_id: 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, + save_profile_secret_plaintext( + &oauth_access_secret_key(&slot_key), + "cached-oauth-access-token", ) - .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); + .expect("save cached OAuth token"); + slot_key } - #[test] - fn resolve_auth_prefer_profile_ignores_api_key_override() { - let mut base = make_base(); - base.api_key = Some("explicit-key".to_string()); - base.prefer_profile = true; - base.profile = Some("work".to_string()); - + #[tokio::test] + async fn auth_precedence_keeps_env_api_key_below_oauth() { + let _env = TestEnv::new(None, None).await; 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("profile-key")); - assert_eq!(resolved.org_name.as_deref(), Some("Example Org")); - } - - #[test] - fn resolve_auth_prefers_cli_api_key_even_with_prefer_profile() { + let app_url = spawn_api_key_login_server("test-org"); + save_cached_oauth_login(&mut store, &app_url); + save_auth_store(&store).expect("save auth store"); 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.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() - }, - ); + base.org_name = Some("test-org".to_string()); + base.app_url = Some(app_url); + base.api_key = Some("environment-api-key".to_string()); + base.api_key_source = Some(crate::args::ArgValueSource::EnvVariable); - 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_explicit_profile_ignores_env_api_key_override() { - let mut base = make_base(); - base.api_key = Some("explicit-key".to_string()); - base.profile = Some("work".to_string()); - base.profile_explicit = true; + let resolved = resolve_auth(&base).await.expect("resolve auth"); - 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() - }, + assert!(resolved.is_oauth); + assert_eq!( + resolved.api_key.as_deref(), + Some("cached-oauth-access-token") ); - - 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.org_name.as_deref(), Some("Example Org")); } - #[test] - fn resolve_auth_marks_oauth_profiles() { - let mut base = make_base(); - base.profile = Some("work".to_string()); - + #[tokio::test] + async fn auth_precedence_cli_api_key_overrides_oauth() { + let _env = TestEnv::new(None, None).await; 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 app_url = spawn_api_key_login_server("test-org"); + save_cached_oauth_login(&mut store, &app_url); + save_auth_store(&store).expect("save auth store"); 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, "--profile/BRAINTRUST_PROFILE"); - } + 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); + base.app_url = Some(app_url); - #[test] - fn parse_oauth_callback_input_accepts_json_payload() { - let parsed = - parse_oauth_callback_input(r#"{"code":"abc123","state":"state123","error":null}"#) - .expect("parse"); - assert_eq!(parsed.code.as_deref(), Some("abc123")); - assert_eq!(parsed.state.as_deref(), Some("state123")); - assert_eq!(parsed.error, None); - } + let resolved = resolve_auth(&base).await.expect("resolve auth"); - #[test] - fn parse_oauth_callback_input_accepts_fragment_payload() { - let parsed = parse_oauth_callback_input("#code=abc123&state=state123").expect("parse"); - assert_eq!(parsed.code.as_deref(), Some("abc123")); - assert_eq!(parsed.state.as_deref(), Some("state123")); - assert_eq!(parsed.error, None); + assert!(!resolved.is_oauth); + assert_eq!(resolved.api_key.as_deref(), Some("command-line-api-key")); } - #[test] - fn parse_oauth_callback_input_requires_code_or_error() { - let err = parse_oauth_callback_input("https://localhost/callback?state=only-state") - .expect_err("should fail"); - assert!( - err.to_string().contains("did not include code or error"), - "unexpected error: {err}" - ); + #[tokio::test] + async fn ad_hoc_api_key_validation_errors() { + for (app_url, expected) in [ + ( + spawn_api_key_login_server("different-org"), + "does not belong", + ), + ( + spawn_login_response_server("401 Unauthorized", "{}".into()), + "not valid", + ), + ] { + let mut base = make_base(); + base.org_name = Some("requested-org".into()); + base.app_url = Some(app_url); + assert!( + resolve_ad_hoc_api_key_auth(&base, &None, "selected-key".into()) + .await + .unwrap_err() + .to_string() + .contains(expected) + ); + } } #[test] - fn resolve_profile_for_org_exact_profile_name() { + fn selected_stored_api_key_wrong_org_fails_before_secret_lookup() { let mut store = AuthStore::default(); store.profiles.insert( - "acme".into(), - AuthProfile { - org_name: Some("acme-corp".into()), - ..Default::default() - }, + "stored-slot".into(), + org_profile(AuthKind::ApiKey, "org_actual", "actual-org"), ); - assert_eq!(resolve_profile_for_org("acme", &store), Some("acme")); + let mut base = make_base(); + base.org_name = Some("requested-org".into()); + + let err = resolve_api_key_profile_auth(&base, &mut store, &None, "stored-slot") + .expect_err("wrong-org stored key must fail locally"); + assert!(err.to_string().contains("does not belong")); } - #[test] - fn resolve_profile_for_org_by_org_name() { + #[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(); - 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")); + let app_url = spawn_api_key_login_server("test-org"); + save_cached_oauth_login(&mut store, &app_url); + 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; + base.app_url = Some(app_url); + + let resolved = resolve_auth(&base).await.expect("resolve auth"); + + assert!(!resolved.is_oauth); + assert_eq!(resolved.api_key.as_deref(), Some("environment-api-key")); } - #[test] - fn resolve_profile_for_org_no_match() { + #[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(); - store.profiles.insert( - "work".into(), - AuthProfile { - org_name: Some("acme-corp".into()), - ..Default::default() - }, + let app_url = spawn_api_key_login_server("test-org"); + save_cached_oauth_login(&mut store, &app_url); + save_auth_store(&store).expect("save auth store"); + let mut base = make_base(); + base.org_name = Some("test-org".to_string()); + base.app_url = Some(app_url); + 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") ); - assert_eq!(resolve_profile_for_org("unknown", &store), None); } - #[test] - fn resolve_profile_for_org_multiple_returns_none() { + #[tokio::test] + async fn active_auth_info_hides_ambiguous_api_keys_instead_of_failing_status() { + let _env = TestEnv::new(None, None).await; + let mut store = AuthStore::default(); + for (slot, hint) in [("key-a", "sk-****aaaaa"), ("key-b", "sk-****bbbbb")] { + store.profiles.insert( + slot.into(), + AuthProfile { + api_key_hint: Some(hint.into()), + ..org_profile(AuthKind::ApiKey, "org_test", "test-org") + }, + ); + } + save_auth_store(&store).expect("save auth store"); + let mut base = make_base(); + base.org_name = Some("test-org".into()); + + assert!(active_auth_info(&base, Some("test-org")) + .expect("status auth lookup") + .is_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( - "work-1".into(), + oauth_slot_key(DEFAULT_APP_URL), AuthProfile { - org_name: Some("acme".into()), + auth_kind: AuthKind::Oauth, + app_url: Some(DEFAULT_APP_URL.to_string()), + user_name: Some("Test User".to_string()), + email: Some("user@example.test".to_string()), ..Default::default() }, ); store.profiles.insert( - "work-2".into(), + api_key_slot_key(&api_key_hash("test-api-key"), "org_fake"), AuthProfile { - org_name: Some("acme".into()), - ..Default::default() + api_key_hint: Some("sk-****abcde".to_string()), + ..org_profile(AuthKind::ApiKey, "org_fake", "test-org") }, ); - assert_eq!(resolve_profile_for_org("acme", &store), None); + 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("resolve active auth") + .expect("active auth info"); + + assert_eq!(info.auth_method, "api_key"); + assert_eq!(info.api_key_hint.as_deref(), Some("sk-****abcde")); } - #[test] - fn profile_selection_requires_choice_when_multiple_profiles_without_prompt() { - let base = make_base(); + #[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( - "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() - }, + "work".to_string(), + org_profile(AuthKind::ApiKey, "org_fake", "test-org"), ); - let err = maybe_select_profile_for_auth(&base, &store, &None, false) - .expect_err("selection should be required"); + maybe_rekey_api_key_profile_after_secret_load(&mut store, "work", "test-api-key") + .expect("rekey api key profile"); - assert!(err.to_string().contains("multiple auth profiles available")); - assert!(err.to_string().contains("alpha")); - assert!(err.to_string().contains("beta")); - assert!(err.to_string().contains("--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 profile_selection_requires_choice_for_ambiguous_org_without_prompt() { - let mut base = make_base(); - base.org_name = Some("acme".into()); + fn save_and_load_auth_store_round_trip() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let dir = std::env::temp_dir().join(format!("bt-auth-store-test-{unique}")); + fs::create_dir_all(&dir).expect("create dir"); + let path = dir.join("auth.json"); 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(), + "work".to_string(), AuthProfile { - org_name: Some("acme".into()), + 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_access_expires_at: None, ..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 profiles 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()); + save_auth_store_to_path(&path, &store).expect("save"); + let loaded = load_auth_store_from_path(&path).expect("load"); - let selection = maybe_select_profile_for_auth(&base, &store, &None, false) - .expect("api key override should skip profile selection"); + assert!(loaded.profiles.contains_key("work")); - assert_eq!(selection, None); + let _ = fs::remove_dir_all(&dir); } #[test] - fn resolve_auth_uses_org_to_find_profile() { - let mut base = make_base(); - base.org_name = Some("acme-corp".into()); - + fn migrate_auth_store_rekeys_oauth_slots_and_preserves_legacy_secret_key() { let mut store = AuthStore::default(); store.profiles.insert( - "work".into(), + "work".to_string(), AuthProfile { - org_name: Some("acme-corp".into()), - api_url: Some("https://api.acme.com".into()), - ..Default::default() + email: Some("user@example.test".to_string()), + ..org_profile(AuthKind::Oauth, "org_fake", "test-org") }, ); - 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")); + let migrated = migrate_auth_store(store); + let key = oauth_slot_key(DEFAULT_APP_URL); + let profile = migrated.profiles.get(&key).expect("migrated profile"); + + assert_eq!(profile.legacy_secret_key.as_deref(), Some("work")); + assert_eq!(profile.org_id, None); + assert_eq!(profile.org_name, None); } #[test] - fn resolve_auth_uses_config_org_to_find_profile() { - let base = make_base(); - + fn migrate_auth_store_purges_old_oauth_org_scope() { let mut store = AuthStore::default(); store.profiles.insert( - "work".into(), + "legacy-login".to_string(), AuthProfile { - org_name: Some("acme-corp".into()), - api_url: Some("https://api.acme.com".into()), + auth_kind: AuthKind::Oauth, + org_id: Some("org_old".to_string()), + org_name: Some("old-org".to_string()), + email: Some("user@example.test".to_string()), ..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")); + let migrated = migrate_auth_store(store); + let profile = migrated + .profiles + .get(&oauth_slot_key(DEFAULT_APP_URL)) + .expect("instance OAuth slot"); + + assert_eq!(profile.org_id, None); + assert_eq!(profile.org_name, None); + assert_eq!(profile.legacy_secret_key.as_deref(), Some("legacy-login")); } #[test] - fn resolve_auth_config_org_overrides_profile_org() { - let mut base = make_base(); - base.profile = Some("default-profile".to_string()); - + 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( - "default-profile".into(), + "work".to_string(), AuthProfile { - org_name: Some("profile-org".into()), + 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 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"); + 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!(resolved.api_key.as_deref(), Some("explicit-key")); - assert_eq!(resolved.org_name.as_deref(), Some("local-org")); + assert_eq!(profile.legacy_secret_key.as_deref(), Some("work")); + assert_eq!(profile.api_key_hint.as_deref(), Some("test-****i-key")); } #[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()); + 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( - "work".into(), - AuthProfile { - org_name: Some("acme-corp".into()), - ..Default::default() - }, - ); - store.profiles.insert( - "other".into(), + "legacy-login".to_string(), AuthProfile { - org_name: Some("other-org".into()), - api_url: Some("https://api.other.com".into()), + 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()), ..Default::default() }, ); + save_auth_store_to_path(&path, &store).expect("save legacy store"); - 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")); + 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(DEFAULT_APP_URL); + + 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")); + } + + let _ = fs::remove_dir_all(&dir); } - #[test] - fn resolve_api_key_login_profile_name_creates_new_profile_for_matching_org() { + #[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( - "acme".into(), + "legacy-oauth".to_string(), AuthProfile { - auth_kind: AuthKind::ApiKey, - api_url: Some("https://api.acme.example".into()), - org_name: Some("acme".into()), + auth_kind: AuthKind::Oauth, + org_name: Some("test-org".to_string()), ..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(), + "legacy-api-key".to_string(), AuthProfile { auth_kind: AuthKind::ApiKey, - api_url: Some("https://api.test.example".into()), - org_name: Some("test-org".into()), + 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, + app_url: Some(DEFAULT_APP_URL.to_string()), + api_url: 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()), + app_url: None, + api_url: None, + status: "ok".to_string(), + error: None, + }, + ]; - let (profile_name, should_confirm) = resolve_api_key_login_profile_name( - Some("work"), - Some("test-org"), - "https://api.test.example", - &store, - ) - .expect("resolve"); + reconcile_verified_auth_slots(&mut store, &verifications) + .expect("reconcile verified slots"); - assert_eq!(profile_name, "work"); - assert!(!should_confirm); + let oauth = store + .profiles + .get(&oauth_slot_key(DEFAULT_APP_URL)) + .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 resolve_api_key_login_profile_name_confirms_explicit_different_target() { + fn migrate_auth_store_dedupes_oauth_slots_by_latest_expiry() { 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"); + for (name, expires_at) in [("old", 10), ("new", 20)] { + 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_access_expires_at: Some(expires_at), + ..Default::default() + }, + ); + } - assert_eq!(profile_name, "work"); - assert!(should_confirm); + let migrated = migrate_auth_store(store); + let key = oauth_slot_key(DEFAULT_APP_URL); + assert_eq!(migrated.profiles.len(), 1); + let profile = migrated.profiles.get(&key).expect("migrated profile"); + assert_eq!(profile.legacy_secret_key.as_deref(), Some("new")); } - #[test] - fn default_login_org_name_uses_profile_org_when_org_not_requested() { + #[tokio::test] + async fn migrate_auth_store_prefers_loadable_refresh_token_before_expiry() { + let _env = TestEnv::new(None, None).await; let mut store = AuthStore::default(); - store.profiles.insert( - "work".into(), - AuthProfile { - org_name: Some("acme".into()), - ..Default::default() - }, - ); + for (name, expires_at) in [("usable-old", 10), ("missing-new", 20)] { + store.profiles.insert( + name.to_string(), + AuthProfile { + auth_kind: AuthKind::Oauth, + oauth_access_expires_at: Some(expires_at), + ..Default::default() + }, + ); + } + save_profile_secret_plaintext( + &oauth_refresh_secret_key("usable-old"), + "test-refresh-token", + ) + .expect("save refresh token"); - assert_eq!( - default_login_org_name(&store, Some(" work "), None).as_deref(), - Some("acme") - ); + let migrated = migrate_auth_store(store); + let profile = migrated + .profiles + .get(&oauth_slot_key(DEFAULT_APP_URL)) + .expect("migrated OAuth profile"); + assert_eq!(profile.legacy_secret_key.as_deref(), Some("usable-old")); } #[test] - fn default_login_org_name_falls_back_to_profile_name() { - let store = AuthStore::default(); + fn migration_reports_dropped_duplicate_secret_as_orphan() { + // Two legacy OAuth entries for the same org+email collapse onto one + // canonical slot. The survivor's secret stays reachable (via its + // legacy_secret_key), while the dropped duplicate's key must be reported + // as an orphan so its keychain secret can be deleted. + let mut store = AuthStore::default(); + for (name, expires_at) in [("old", 10), ("new", 20)] { + 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_access_expires_at: Some(expires_at), + ..Default::default() + }, + ); + } - assert_eq!( - default_login_org_name(&store, Some(" acme "), None).as_deref(), - Some("acme") - ); + let migrated = migrate_auth_store(store.clone()); + let orphans = orphaned_migration_secret_keys(&store, &migrated); + + assert_eq!(orphans, vec![("old", AuthKind::Oauth)]); + // The survivor "new" is referenced through the canonical slot's + // legacy_secret_key and must never be pruned. + assert!(!orphans.iter().any(|(key, _)| *key == "new")); } #[test] - fn default_login_org_name_ignores_profile_when_org_requested() { + fn migration_without_collapse_reports_no_orphans() { + // A single entry that merely gets rekeyed keeps its secret under the + // legacy key, so nothing is orphaned. let mut store = AuthStore::default(); store.profiles.insert( - "work".into(), + "test-org".to_string(), AuthProfile { - org_name: Some("acme".into()), + 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()), ..Default::default() }, ); - assert_eq!( - default_login_org_name(&store, Some("work"), Some("other")), - None - ); + let migrated = migrate_auth_store(store.clone()); + assert!(orphaned_migration_secret_keys(&store, &migrated).is_empty()); } #[test] - fn move_default_login_org_first_moves_matching_org() { - let mut orgs = vec![ - login_org("org_1", "acme"), - login_org("org_2", "beta"), - login_org("org_3", "gamma"), - ]; + fn config_auth_context_returns_config_org() { + let base = make_base(); + let cfg = auth_config(Some("local-org")); - assert!(move_default_login_org_first(&mut orgs, Some("beta"))); - assert_eq!(orgs[0].name, "beta"); - assert_eq!(orgs[1].name, "acme"); - } + let org = config_auth_context_from_config(&base, &cfg); - #[test] - fn move_default_login_org_first_keeps_order_without_match() { - let mut orgs = vec![login_org("org_1", "acme"), login_org("org_2", "beta")]; + assert_eq!(org.as_deref(), Some("local-org")); - assert!(!move_default_login_org_first(&mut orgs, Some("missing"))); - assert_eq!(orgs[0].name, "acme"); - assert_eq!(orgs[1].name, "beta"); + let other_instance = BaseArgs { + app_url: Some("https://other.example.test".into()), + ..base + }; + assert_eq!(config_auth_context_from_config(&other_instance, &cfg), None); } #[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"); + fn parse_oauth_callback_input_accepts_json_payload() { + let parsed = + parse_oauth_callback_input(r#"{"code":"abc123","state":"state123","error":null}"#) + .expect("parse"); + assert_eq!(parsed.code.as_deref(), Some("abc123")); + assert_eq!(parsed.state.as_deref(), Some("state123")); + assert_eq!(parsed.error, None); + } - assert_eq!(profile_name, "newer"); - assert!(!should_confirm); + #[test] + fn parse_oauth_callback_input_accepts_fragment_payload() { + let parsed = parse_oauth_callback_input("#code=abc123&state=state123").expect("parse"); + assert_eq!(parsed.code.as_deref(), Some("abc123")); + assert_eq!(parsed.state.as_deref(), Some("state123")); + assert_eq!(parsed.error, None); } #[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() - }, + fn parse_oauth_callback_input_requires_code_or_error() { + let err = parse_oauth_callback_input("https://localhost/callback?state=only-state") + .expect_err("should fail"); + assert!( + err.to_string().contains("did not include code or error"), + "unexpected error: {err}" ); - 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"); + #[test] + fn move_default_login_org_first_moves_matching_org() { + let mut orgs = vec![ + login_org("org_1", "acme"), + login_org("org_2", "beta"), + login_org("org_3", "gamma"), + ]; - assert_eq!(profile_name, "work"); - assert!(!should_confirm); + assert!(move_default_login_org_first(&mut orgs, Some("beta"))); + assert_eq!(orgs[0].name, "beta"); + assert_eq!(orgs[1].name, "acme"); } #[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"); + fn move_default_login_org_first_keeps_order_without_match() { + let mut orgs = vec![login_org("org_1", "acme"), login_org("org_2", "beta")]; - assert_eq!(profile_name, "work"); - assert!(should_confirm); + assert!(!move_default_login_org_first(&mut orgs, Some("missing"))); + assert_eq!(orgs[0].name, "acme"); + assert_eq!(orgs[1].name, "beta"); } fn login_org(id: &str, name: &str) -> LoginOrgInfo { @@ -4958,238 +5374,410 @@ mod tests { } } - #[tokio::test] - 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()), - ..Default::default() - }) - .expect("save initial config"); - - let update = persist_post_login_context( - &make_base(), - "work", - "test-api-key", - "https://api.example.test", - "https://www.example.test", - Some(&login_org("org_123", "acme")), + 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)), ) - .await - .expect("persist context"); - 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.org.as_deref(), Some("acme")); - assert_eq!(cfg.project, None); - assert_eq!(cfg.project_id, None); + .expect("resolve auth source") } - #[tokio::test] - async fn resolve_post_login_project_rejects_cross_org_default_project() { - let mut base = make_base(); - base.project = Some("demo-project".to_string()); - - let err = resolve_post_login_project( - &base, - "test-api-key", - "https://api.example.test", - "https://www.example.test", - None, - ) - .await - .expect_err("cross-org project selection should fail"); - - assert!(err - .to_string() - .contains("cannot set a default project in cross-org mode")); + #[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 resolve_requested_org_for_api_key_login_keeps_matching_requested_org() { - let orgs = vec![login_org("org_1", "acme")]; - - let resolution = - resolve_requested_org_for_api_key_login(&orgs, Some("acme"), false, |_, _| { - panic!("prompt should not be called") - }) - .expect("resolve"); - - assert_eq!(resolution, RequestedOrgResolution::UseRequestedOrg); + fn auth_source_default_order_is_oauth_then_env_then_stored_api_key() { + 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::EnvApiKey("env".into()) + ); + assert_eq!( + auth_source(false, None, None, None, Some("ak")), + AuthSource::ApiKey("ak".into()) + ); + assert_eq!(auth_source(false, None, None, None, None), AuthSource::None); } #[test] - fn resolve_requested_org_for_api_key_login_errors_without_prompt() { - let orgs = vec![login_org("org_1", "braintrustdata.com")]; - - let err = - resolve_requested_org_for_api_key_login(&orgs, Some("ced-test-1"), false, |_, _| { - panic!("prompt should not be called") - }) - .expect_err("should fail"); - - assert!(err - .to_string() - .contains("org 'ced-test-1' not found. Available: braintrustdata.com")); + 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 resolve_requested_org_for_api_key_login_can_switch_to_oauth() { - let orgs = vec![login_org("org_1", "braintrustdata.com")]; - - let resolution = resolve_requested_org_for_api_key_login( - &orgs, - Some("ced-test-1"), - true, - |requested_org_name, available_orgs| { - assert_eq!(requested_org_name, "ced-test-1"); - assert_eq!(available_orgs.len(), 1); - Ok(ApiKeyOrgMismatchAction::UseOauth) - }, + 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("resolve"); - - assert_eq!(resolution, RequestedOrgResolution::SwitchToOauth); + .expect_err("ambiguous oauth should stop the ladder"); + assert!(err.to_string().contains("multiple oauth logins")); } - #[test] - fn resolve_requested_org_for_api_key_login_can_continue_with_api_key() { - let orgs = vec![login_org("org_1", "braintrustdata.com")]; + #[tokio::test] + async fn available_instances_dedupes_logins_by_app_url() { + let _env = TestEnv::new(None, None).await; + let mut store = AuthStore::default(); + for (slot, kind, app_url) in [ + ("oauth-a", AuthKind::Oauth, "https://one.example.test/"), + ("key-a", AuthKind::ApiKey, "https://one.example.test"), + ("key-b", AuthKind::ApiKey, "https://two.example.test"), + ] { + store.profiles.insert( + slot.into(), + AuthProfile { + auth_kind: kind, + app_url: Some(app_url.into()), + org_id: (kind == AuthKind::ApiKey).then(|| format!("org_{slot}")), + org_name: (kind == AuthKind::ApiKey).then(|| format!("org-{slot}")), + ..Default::default() + }, + ); + } + save_auth_store(&store).expect("save auth store"); - let resolution = resolve_requested_org_for_api_key_login( - &orgs, - Some("ced-test-1"), - true, - |requested_org_name, available_orgs| { - assert_eq!(requested_org_name, "ced-test-1"); - assert_eq!(available_orgs.len(), 1); - Ok(ApiKeyOrgMismatchAction::UseApiKey) - }, - ) - .expect("resolve"); + let instances = available_instances(&BaseArgs::default()).expect("list instances"); + assert_eq!( + instances + .iter() + .map(|instance| instance.app_url.as_str()) + .collect::>(), + ["https://one.example.test", "https://two.example.test"] + ); - assert_eq!(resolution, RequestedOrgResolution::IgnoreRequestedOrg); + let filtered = available_instances(&BaseArgs { + app_url: Some("https://two.example.test/".into()), + app_url_source: Some(crate::args::ArgValueSource::CommandLine), + ..Default::default() + }) + .expect("filter instances"); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].app_url, "https://two.example.test"); } - #[test] - fn obscure_api_key_standard() { - assert_eq!(obscure_api_key("sk-LumEdp0BbLRzhJwO"), "sk-****zhJwO"); + #[tokio::test] + async fn org_filter_includes_oauth_login_when_discovered_membership_matches() { + let _env = TestEnv::new(None, None).await; + let app_url = spawn_api_key_login_server("test-org"); + let mut store = AuthStore::default(); + let oauth_slot = save_cached_oauth_login(&mut store, &app_url); + store.profiles.insert( + "other-key".into(), + AuthProfile { + app_url: Some(app_url.clone()), + api_url: Some("https://api.example.test".into()), + ..org_profile(AuthKind::ApiKey, "org_other", "other-org") + }, + ); + save_auth_store(&store).expect("save auth store"); + let base = BaseArgs::default(); + let candidates = filter_auth_store(&base, &store, None, None); + let filtered = filter_auth_store_for_org(&base, &mut store, candidates, Some("test-org")) + .await + .expect("filter by org"); + assert_eq!( + filtered.profiles.into_keys().collect::>(), + [oauth_slot] + ); } #[test] - fn obscure_api_key_short() { - assert_eq!(obscure_api_key("abc"), "****"); + fn command_auth_uses_builtin_url_defaults_when_urls_are_unset() { + let default_oauth = AuthProfile { + auth_kind: AuthKind::Oauth, + app_url: Some(DEFAULT_APP_URL.into()), + ..Default::default() + }; + let custom_oauth = AuthProfile { + auth_kind: AuthKind::Oauth, + app_url: Some("https://www.example.test".into()), + ..Default::default() + }; + assert!(profile_matches_urls(&BaseArgs::default(), &default_oauth)); + assert!(!profile_matches_urls(&BaseArgs::default(), &custom_oauth)); + assert!(profile_matches_url_filters( + &BaseArgs::default(), + &custom_oauth + )); } #[test] - fn obscure_api_key_no_dash() { - assert_eq!(obscure_api_key("abcdefghijklm"), "****ijklm"); - } + fn login_filter_matches_instance_and_auth_kind() { + let mut store = AuthStore::default(); + store.profiles.insert( + "oauth".into(), + AuthProfile { + auth_kind: AuthKind::Oauth, + app_url: Some("https://www.example.test".into()), + ..Default::default() + }, + ); + store.profiles.insert( + "key".into(), + AuthProfile { + app_url: Some("https://www.example.test".into()), + api_url: Some("https://api.example.test".into()), + api_key_hint: Some("sk-****abcde".into()), + ..org_profile(AuthKind::ApiKey, "org_test", "test-org") + }, + ); + let base = BaseArgs { + app_url: Some("https://www.example.test/".into()), + api_url: Some("https://api.example.test/".into()), + ..Default::default() + }; + let filtered = filter_auth_store(&base, &store, None, None); + assert_eq!(filtered.profiles.len(), 2); + let filtered = filter_auth_store(&base, &store, Some(AuthKind::ApiKey), None); + assert_eq!(filtered.profiles.into_keys().collect::>(), ["key"]); - #[test] - fn obscure_api_key_non_ascii() { - assert_eq!(obscure_api_key("sk-café-résumé-key"), "****"); + let mismatched_api = BaseArgs { + app_url: base.app_url.clone(), + api_url: Some("https://other-api.example.test".into()), + ..Default::default() + }; + let filtered = filter_auth_store(&mismatched_api, &store, None, None); + assert_eq!(filtered.profiles.into_keys().collect::>(), ["oauth"]); } #[test] - fn decode_jwt_identity_extracts_claims() { - let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"RS256"}"#); - let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(r#"{"name":"Alice","email":"alice@example.com"}"#); - let token = format!("{header}.{payload}.sig"); - let id = decode_jwt_identity(&token); - assert_eq!(id.name.as_deref(), Some("Alice")); - assert_eq!(id.email.as_deref(), Some("alice@example.com")); + fn requested_api_key_org_resolution() { + fn no_prompt(_: &str, _: &[LoginOrgInfo]) -> Result { + panic!("prompt should not be called") + } + let orgs = vec![login_org("org_test", "test-org")]; + assert_eq!( + resolve_requested_org_for_api_key_login(&orgs, Some("test-org"), false, no_prompt) + .unwrap(), + RequestedOrgResolution::UseRequestedOrg + ); + assert!(resolve_requested_org_for_api_key_login( + &orgs, + Some("other-org"), + false, + no_prompt + ) + .unwrap_err() + .to_string() + .contains("org 'other-org' not found. Available: test-org")); + + for (action, expected) in [ + ( + ApiKeyOrgMismatchAction::UseOauth, + RequestedOrgResolution::SwitchToOauth, + ), + ( + ApiKeyOrgMismatchAction::UseApiKey, + RequestedOrgResolution::IgnoreRequestedOrg, + ), + ] { + let actual = resolve_requested_org_for_api_key_login( + &orgs, + Some("other-org"), + true, + |requested, available| { + assert_eq!(requested, "other-org"); + assert_eq!(available.len(), 1); + Ok(action) + }, + ) + .unwrap(); + assert_eq!(actual, expected); + } } #[test] - fn decode_jwt_identity_handles_missing_claims() { - let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"RS256"}"#); - let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"sub":"123"}"#); - let token = format!("{header}.{payload}.sig"); - let id = decode_jwt_identity(&token); - assert_eq!(id.name, None); - assert_eq!(id.email, None); + fn obscure_api_keys() { + for (key, expected) in [ + ("sk-LumEdp0BbLRzhJwO", "sk-****zhJwO"), + ("abc", "****"), + ("abcdefghijklm", "****ijklm"), + // Late first dash leaves no maskable middle: fully mask rather than + // reveal the whole key (would otherwise be "abcdefg-****g-hij"). + ("abcdefg-hij", "****"), + ("sk-café-résumé-key", "****"), + ] { + assert_eq!(obscure_api_key(key), expected); + } } #[test] - fn decode_jwt_identity_handles_garbage() { + fn decode_jwt_identity_handles_claims_and_invalid_tokens() { + let encode = |payload| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload); + let header = encode(r#"{"alg":"RS256"}"#); + for (payload, expected) in [ + ( + r#"{"name":"Test User","email":"user@example.test"}"#, + (Some("Test User"), Some("user@example.test")), + ), + (r#"{"sub":"123"}"#, (None, None)), + ] { + let id = decode_jwt_identity(&format!("{header}.{}.sig", encode(payload))); + assert_eq!((id.name.as_deref(), id.email.as_deref()), expected); + } let id = decode_jwt_identity("not-a-jwt"); - assert_eq!(id.name, None); - assert_eq!(id.email, None); + assert_eq!((id.name, id.email), (None, None)); } #[test] - fn format_verification_line_ok_with_identity() { - let v = ProfileVerification { - name: "work".into(), + fn auth_logins_are_grouped_by_org() { + let verification = |name: &str, org: Option<&str>| ProfileVerification { + name: name.into(), + slot_hash: None, auth: "oauth".into(), - org: Some("acme".into()), - user_name: Some("Alice".into()), - user_email: Some("alice@example.com".into()), - api_key_hint: None, - status: "ok".into(), - error: None, - }; - assert_eq!( - format_verification_line(&v), - "work — oauth — org: acme — Alice (alice@example.com)" - ); - } - - #[test] - fn format_verification_line_ok_with_api_key_hint() { - let v = ProfileVerification { - name: "work".into(), - auth: "api_key".into(), - org: Some("acme".into()), + org: org.map(str::to_string), + org_id: None, user_name: None, user_email: None, - api_key_hint: Some("sk-****zhJwO".into()), + api_key_hint: None, + app_url: Some(DEFAULT_APP_URL.to_string()), + api_url: None, status: "ok".into(), error: None, }; + let mut verifications = vec![ + verification("profile-z", Some("test-org-a")), + verification("profile-a", Some("test-org-b")), + verification("profile-m", Some("test-org-a")), + verification("profile-x", None), + ]; + + sort_profile_verifications(&mut verifications); + + let order = verifications + .iter() + .map(|v| (v.org.as_deref(), v.name.as_str())) + .collect::>(); assert_eq!( - format_verification_line(&v), - "work — api_key — org: acme — sk-****zhJwO" + order, + vec![ + (None, "profile-x"), + (Some("test-org-a"), "profile-m"), + (Some("test-org-a"), "profile-z"), + (Some("test-org-b"), "profile-a"), + ] ); } #[test] - fn format_verification_line_expired() { - let v = ProfileVerification { - name: "old".into(), - auth: "oauth".into(), - org: 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"); - } + fn verification_line_formatting() { + for (name, email, hint, expected) in [ + ( + Some("Test User"), + Some("user@example.test"), + None, + Some("Test User (user@example.test)"), + ), + ( + None, + Some("user@example.test"), + None, + Some("user@example.test"), + ), + (None, None, Some("sk-****abcde"), Some("sk-****abcde")), + (None, None, None, None), + ] { + assert_eq!(identity_label(name, email, hint).as_deref(), expected); + } - #[test] - fn format_verification_line_error() { - let v = ProfileVerification { - name: "bad".into(), - auth: "api_key".into(), - org: Some("corp".into()), - user_name: None, - user_email: None, - api_key_hint: None, - status: "error".into(), - error: Some("invalid API key".into()), + let verification = |auth: &str, + org: Option<&str>, + identity: Option<&str>, + hint: Option<&str>, + status: &str, + error: Option<&str>| ProfileVerification { + name: "test-profile".into(), + slot_hash: None, + auth: auth.into(), + org: org.map(str::to_string), + org_id: None, + user_name: identity.map(str::to_string), + user_email: identity.map(|_| "user@example.test".into()), + api_key_hint: hint.map(str::to_string), + app_url: (auth == "oauth").then(|| DEFAULT_APP_URL.to_string()), + api_url: None, + status: status.into(), + error: error.map(str::to_string), }; - assert_eq!( - format_verification_line(&v), - "bad — api_key — org: corp — invalid API key" - ); + let cases = [ + ( + verification( + "oauth", + Some("test-org"), + Some("Test User"), + None, + "ok", + None, + ), + "https://www.braintrust.dev — oauth — Test User (user@example.test)", + ), + ( + verification( + "api_key", + Some("test-org"), + None, + Some("sk-****abcde"), + "ok", + None, + ), + "test-org — api_key — sk-****abcde", + ), + ( + verification("oauth", None, None, None, "expired", None), + "https://www.braintrust.dev — oauth — token expired", + ), + ( + verification( + "api_key", + Some("test-org"), + None, + None, + "error", + Some("invalid API key"), + ), + "test-org — api_key — invalid API key", + ), + ]; + for (verification, expected) in cases { + assert_eq!(format_verification_line(&verification), expected); + } } #[tokio::test] @@ -5217,80 +5805,44 @@ mod tests { } } - #[tokio::test] - async fn oauth_callback_listener_responds_to_http_request() { + async fn assert_oauth_callback(stale_connection: bool, code: &str, state: &str) { use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; use tokio::net::TcpStream; - let callback_server = bind_oauth_callback_server().expect("bind callback server"); - let addr = format!("127.0.0.1:{}", callback_server.port); - let callback = tokio::spawn(wait_for_oauth_callback(callback_server)); - - let mut stream = TcpStream::connect(addr) - .await - .expect("connect to callback listener"); - stream - .write_all( - b"GET /callback?code=test-code&state=test-state HTTP/1.1\r\nHost: 127.0.0.1\r\nUser-Agent: test\r\n\r\n", - ) - .await - .expect("write callback request"); + let server = bind_oauth_callback_server().unwrap(); + let addr = format!("127.0.0.1:{}", server.port); + let callback = tokio::spawn(wait_for_oauth_callback(server)); + if stale_connection { + drop(TcpStream::connect(&addr).await.unwrap()); + } + let mut stream = TcpStream::connect(addr).await.unwrap(); + let request = + format!("GET /callback?code={code}&state={state} HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"); + stream.write_all(request.as_bytes()).await.unwrap(); let mut response = vec![0u8; 4096]; - let bytes_read = tokio::time::timeout(Duration::from_secs(1), stream.read(&mut response)) - .await - .expect("callback response timed out") - .expect("read callback response"); - let response = String::from_utf8_lossy(&response[..bytes_read]); - - let params = callback + let read = tokio::time::timeout(Duration::from_secs(1), stream.read(&mut response)) .await - .expect("callback task") - .expect("callback params"); - assert_eq!(params.code.as_deref(), Some("test-code")); - assert_eq!(params.state.as_deref(), Some("test-state")); + .unwrap() + .unwrap(); + let params = callback.await.unwrap().unwrap(); + assert_eq!( + (params.code.as_deref(), params.state.as_deref()), + (Some(code), Some(state)) + ); + let response = String::from_utf8_lossy(&response[..read]); assert!(response.starts_with("HTTP/1.1 200 OK")); assert!(response.contains("Authorization Successful")); } #[tokio::test] - async fn oauth_callback_listener_ignores_empty_connection() { - use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; - use tokio::net::TcpStream; - - let callback_server = bind_oauth_callback_server().expect("bind callback server"); - let addr = format!("127.0.0.1:{}", callback_server.port); - let callback = tokio::spawn(wait_for_oauth_callback(callback_server)); - - let stale = TcpStream::connect(&addr) - .await - .expect("connect stale callback socket"); - drop(stale); - - let mut stream = TcpStream::connect(addr) - .await - .expect("connect to callback listener"); - stream - .write_all( - b"GET /callback?code=next-code&state=next-state HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", - ) - .await - .expect("write callback request"); - - let mut response = vec![0u8; 4096]; - let bytes_read = tokio::time::timeout(Duration::from_secs(1), stream.read(&mut response)) - .await - .expect("callback response timed out") - .expect("read callback response"); - let response = String::from_utf8_lossy(&response[..bytes_read]); + async fn oauth_callback_listener_responds_to_http_request() { + assert_oauth_callback(false, "test-code", "test-state").await; + } - let params = callback - .await - .expect("callback task") - .expect("callback params"); - assert_eq!(params.code.as_deref(), Some("next-code")); - assert_eq!(params.state.as_deref(), Some("next-state")); - assert!(response.starts_with("HTTP/1.1 200 OK")); + #[tokio::test] + async fn oauth_callback_listener_ignores_empty_connection() { + assert_oauth_callback(true, "next-code", "next-state").await; } #[tokio::test] @@ -5301,14 +5853,17 @@ mod tests { #[tokio::test] async fn login_read_only_cached_project_id_and_org_uses_fast_path() { - let env = TestEnv::new(Some("proj_123"), None).await; + let env = TestEnv::new(Some("proj_123"), Some("test-org")).await; + let mut base = base_args_for_path_probe(Some("test-org")); + base.app_url = Some(spawn_api_key_login_server("test-org")); + set_global_config_urls(base.app_url.as_deref().unwrap(), None); let ctx = env - .login_read_only_probe(Some("acme")) + .login_read_only_with_base(base) .await .expect("fast path should succeed"); - assert_eq!(ctx.login.org_name().as_deref(), Some("acme")); - assert_eq!(ctx.login.org_id().as_deref(), Some("")); + assert_eq!(ctx.login.org_name().as_deref(), Some("test-org")); + assert_eq!(ctx.login.org_id().as_deref(), Some("org_test")); assert_eq!(ctx.api_url, "not-a-valid-url"); } @@ -5318,12 +5873,6 @@ mod tests { assert_invalid_api_url(env.login_read_only_probe(None).await); } - #[tokio::test] - async fn login_read_only_cached_project_id_but_whitespace_org_falls_back_to_login() { - let env = TestEnv::new(Some("proj_123"), None).await; - assert_invalid_api_url(env.login_read_only_probe(Some(" ")).await); - } - #[tokio::test] async fn login_read_only_whitespace_project_id_is_treated_as_not_cached() { let env = TestEnv::new(Some(" "), None).await; // has_cached_project_id => false @@ -5349,9 +5898,12 @@ mod tests { ]); save_profile_secret_plaintext("acme-profile", "acme-secret").expect("save acme secret"); save_profile_secret_plaintext("other-profile", "other-secret").expect("save other secret"); + set_global_config_urls("https://www.acme.example", Some("https://api.acme.example")); + let mut base = make_base(); + crate::config::apply_base_config(&mut base); let ctx = env - .login_read_only_with_base(make_base()) + .login_read_only_with_base(base) .await .expect("fast path should succeed with cfg org"); @@ -5363,19 +5915,22 @@ mod tests { #[tokio::test] async fn login_read_only_cached_project_id_and_org_uses_default_urls() { - let env = TestEnv::new(Some("proj_123"), None).await; + let env = TestEnv::new(Some("proj_123"), Some("test-org")).await; let mut base = make_base(); base.api_key = Some("test-api-key".into()); - base.org_name = Some("acme".into()); + base.org_name = Some("test-org".into()); + let app_url = spawn_api_key_login_server("test-org"); + base.app_url = Some(app_url.clone()); + set_global_config_urls(&app_url, None); let ctx = env .login_read_only_with_base(base) .await .expect("fast path should succeed"); - assert_eq!(ctx.login.org_name().as_deref(), Some("acme")); - assert_eq!(ctx.api_url, DEFAULT_API_URL); - assert_eq!(ctx.app_url, DEFAULT_APP_URL); + assert_eq!(ctx.login.org_name().as_deref(), Some("test-org")); + assert_eq!(ctx.api_url, "https://api.example.test"); + assert_eq!(ctx.app_url, app_url); } #[tokio::test] diff --git a/src/config/mod.rs b/src/config/mod.rs index 779499d5..9d732d52 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,4 +1,4 @@ -use anyhow::{anyhow, bail, Result}; +use anyhow::{anyhow, bail, Context, Result}; use clap::{Args, Subcommand}; use std::{ env, fs, @@ -8,7 +8,7 @@ use std::{ use serde::{Deserialize, Serialize}; -use crate::args::BaseArgs; +use crate::args::{BaseArgs, DEFAULT_APP_URL}; use crate::ui::{print_command_status, CommandStatus}; mod get; @@ -18,36 +18,74 @@ mod set; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(default)] pub struct Config { - pub profile: Option, pub org: Option, + pub org_id: Option, pub project: Option, pub project_id: Option, + pub app_url: Option, + pub api_url: Option, #[serde(flatten)] pub extra: serde_json::Map, } -pub const KNOWN_KEYS: &[&str] = &["profile", "org", "project", "project_id"]; +pub const KNOWN_KEYS: &[&str] = &[ + "org", + "org_id", + "project", + "project_id", + "app_url", + "api_url", +]; impl Config { pub fn get_field(&self, key: &str) -> Option<&str> { match key { - "profile" => self.profile.as_deref(), "org" => self.org.as_deref(), + "org_id" => self.org_id.as_deref(), "project" => self.project.as_deref(), "project_id" => self.project_id.as_deref(), + "app_url" => self.app_url.as_deref(), + "api_url" => self.api_url.as_deref(), _ => None, } } pub fn set_field(&mut self, key: &str, value: String) -> bool { match key { - "profile" => self.profile = Some(value), - "org" => self.org = Some(value), + "org" => { + let value = value.trim().to_string(); + if self.org.as_ref() != Some(&value) { + self.org_id = None; + self.project = None; + self.project_id = None; + } + self.org = (!value.is_empty()).then_some(value); + } + "org_id" => self.org_id = self.org.as_ref().map(|_| value), "project" => { self.project = Some(value); self.project_id = None; } "project_id" => self.project_id = Some(value), + "app_url" => { + let value = value.trim().to_string(); + let previous = self.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + let next = if !value.is_empty() { + value.as_str() + } else { + DEFAULT_APP_URL + }; + if !urls_equal(previous, next) { + self.org = None; + self.org_id = None; + self.project = None; + self.project_id = None; + } + self.app_url = (!value.is_empty()).then_some(value); + } + "api_url" => { + self.api_url = trimmed_option(Some(&value)).map(str::to_string); + } _ => return false, } true @@ -55,13 +93,32 @@ impl Config { pub fn unset_field(&mut self, key: &str) -> bool { match key { - "profile" => self.profile = None, - "org" => self.org = None, + "org" => { + self.org = None; + self.org_id = None; + self.project = None; + self.project_id = None; + } + "org_id" => self.org_id = None, "project" => { self.project = None; self.project_id = None; } "project_id" => self.project_id = None, + "app_url" => { + if self + .app_url + .as_deref() + .is_some_and(|url| !urls_equal(url, DEFAULT_APP_URL)) + { + self.org = None; + self.org_id = None; + self.project = None; + self.project_id = None; + } + self.app_url = None; + } + "api_url" => self.api_url = None, _ => return false, } true @@ -74,25 +131,105 @@ impl Config { .collect() } - pub(crate) fn merge(&self, other: &Config) -> Config { + pub(crate) fn set_context( + &mut self, + org: (&str, &str), + project: Option<(&str, &str)>, + app_url: &str, + api_url: &str, + ) { + self.org = Some(org.0.trim().to_string()); + self.org_id = Some(org.1.trim().to_string()); + (self.project, self.project_id) = project + .map(|(name, id)| (name.to_string(), id.to_string())) + .unzip(); + self.app_url = Some(app_url.to_string()); + self.api_url = Some(api_url.to_string()); + } + + pub(crate) fn merge(&self, local: &Config) -> Config { let mut extra = self.extra.clone(); - extra.extend(other.extra.clone()); - let project = other.project.clone().or_else(|| self.project.clone()); - let project_id = if other.project.is_some() { - other.project_id.clone() - } else { - self.project_id.clone() + extra.extend(local.extra.clone()); + + let app_url = local.app_url.clone().or_else(|| self.app_url.clone()); + let api_url = local.api_url.clone().or_else(|| self.api_url.clone()); + let global_app = self.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + let merged_app = app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + let same_instance = urls_equal(global_app, merged_app); + let same_org = same_instance && local.org == self.org; + let global_project_id = self.project.as_ref().and(self.project_id.clone()); + + let (org, org_id, project, project_id) = match (&local.org, &local.project) { + (Some(org), Some(project)) => ( + Some(org.clone()), + local.org_id.clone(), + Some(project.clone()), + local.project_id.clone(), + ), + (Some(org), None) if same_org => ( + Some(org.clone()), + local.org_id.clone().or_else(|| self.org_id.clone()), + self.project.clone(), + global_project_id, + ), + (Some(org), None) => (Some(org.clone()), local.org_id.clone(), None, None), + (None, Some(project)) => (None, None, Some(project.clone()), local.project_id.clone()), + (None, None) if same_instance => ( + self.org.clone(), + self.org_id.clone(), + self.project.clone(), + global_project_id, + ), + (None, None) => (None, None, None, None), }; Config { - profile: other.profile.clone().or_else(|| self.profile.clone()), - org: other.org.clone().or_else(|| self.org.clone()), + org, + org_id, project, project_id, + app_url, + api_url, extra, } } } +pub(crate) fn urls_equal(left: &str, right: &str) -> bool { + left.trim().trim_end_matches('/') == right.trim().trim_end_matches('/') +} + +/// Apply config-file URL and org-ID fallbacks after clap has resolved CLI/env. +pub fn apply_base_config(base: &mut BaseArgs) { + let cfg = load().unwrap_or_default(); + apply_config_to_base(base, &cfg); +} + +fn apply_config_to_base(base: &mut BaseArgs, cfg: &Config) { + if base.app_url.is_none() { + base.app_url = cfg.app_url.clone(); + } + + if base.api_url.is_none() { + base.api_url = cfg.api_url.clone(); + } + + let effective_app = base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + let config_app = cfg.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + let same_instance = urls_equal(effective_app, config_app); + if base.org_name_source.is_none() { + if base.org_name.is_none() && same_instance { + base.org_name = cfg.org.clone(); + } + if same_instance && base.org_name == cfg.org { + base.org_id = cfg.org_id.clone(); + } else { + base.org_id = None; + } + } else { + base.org_id = None; + } +} + pub fn global_config_dir() -> Result { if let Some(xdg) = env::var_os("XDG_CONFIG_HOME") { return Ok(PathBuf::from(xdg).join("bt")); @@ -119,7 +256,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 +267,20 @@ pub fn load_file(path: &Path) -> Config { } }; + config.extra.remove("profile"); + + config.org = trimmed_option(config.org.as_deref()).map(str::to_string); + config.org_id = config + .org + .as_ref() + .and(trimmed_option(config.org_id.as_deref()).map(str::to_string)); + if config.org.is_none() { + config.project = None; + config.project_id = None; + } + config.app_url = trimmed_option(config.app_url.as_deref()).map(str::to_string); + config.api_url = trimmed_option(config.api_url.as_deref()).map(str::to_string); + for key in config.extra.keys() { print_command_status( CommandStatus::Error, @@ -181,20 +332,20 @@ pub(crate) fn project_from_config_for_context( } 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()); - 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), + let requested_app = base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + let cfg_app = cfg.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + if !urls_equal(requested_app, cfg_app) { + return false; } + + let cfg_org = org_option(cfg.org.as_deref()); + let requested_org = org_option(resolved_org).or_else(|| org_option(base.org_name.as_deref())); + + requested_org.is_none_or(|resolved| cfg_org == Some(resolved)) +} + +pub(crate) fn org_option(value: Option<&str>) -> Option<&str> { + trimmed_option(value) } pub(crate) fn trimmed_option(value: Option<&str>) -> Option<&str> { @@ -220,57 +371,116 @@ pub fn save_global(config: &Config) -> Result<()> { } pub fn find_local_config_dir() -> Option { - let home = dirs::home_dir(); - let mut current_dir = std::env::current_dir().ok()?; + find_local_config_dir_from(std::env::current_dir().ok()?, dirs::home_dir().as_deref()) +} + +enum ProjectBoundary { + Bt(PathBuf), + Git(PathBuf), + Home, + Root, +} - loop { - if current_dir.join(".bt").is_dir() { - return Some(current_dir.join(".bt")); +fn project_boundary(start: PathBuf, home: Option<&Path>) -> ProjectBoundary { + // `current_dir()` is the physical path (symlinks resolved) while `$HOME` may + // not be, so also compare canonicalized forms — exact equality alone can + // walk straight past a symlinked home boundary. + let home_canon = home.and_then(|h| fs::canonicalize(h).ok()); + for dir in start.ancestors() { + let at_home = + Some(dir) == home || (home_canon.is_some() && fs::canonicalize(dir).ok() == home_canon); + if at_home { + return ProjectBoundary::Home; } - if current_dir.join(".git").exists() { - return None; + if dir.parent().is_none() { + return ProjectBoundary::Root; } - if Some(¤t_dir) == home.as_ref() { - return None; + let bt = dir.join(".bt"); + if bt.is_dir() { + return ProjectBoundary::Bt(bt); } - if !current_dir.pop() { - return None; + if dir.join(".git").exists() { + return ProjectBoundary::Git(dir.to_path_buf()); } } + unreachable!("path ancestors always include a filesystem root") } -pub fn local_path() -> Option { - find_local_config_dir().map(|dir| dir.join("config.json")) -} - -pub enum WriteTarget { - Global(PathBuf), - Local(PathBuf), +fn find_local_config_dir_from(current_dir: PathBuf, home: Option<&Path>) -> Option { + match project_boundary(current_dir, home) { + ProjectBoundary::Bt(dir) if dir.join("config.json").is_file() => Some(dir), + _ => None, + } } -pub fn write_target() -> Result { - match local_path() { - Some(p) => Ok(WriteTarget::Local(p)), - None => Ok(WriteTarget::Global(global_path()?)), - } +pub fn local_path() -> Option { + find_local_config_dir().map(|dir| dir.join("config.json")) } /// Resolve which config file to write based on --global/--local flags. pub fn resolve_write_path(global: bool, local: bool) -> Result { if global { - global_path() - } else if local { - match local_path() { - Some(p) => Ok(p), - None => { - bail!("No local .bt directory found. Use bt init to initialize this directory.") - } + return global_path(); + } + match local_path() { + Some(path) => Ok(path), + None if local => { + bail!("No existing local .bt/config.json found. Run `bt init` first, or use --global.") } - } else { - match write_target()? { - WriteTarget::Local(p) | WriteTarget::Global(p) => Ok(p), + None => global_path(), + } +} + +/// Resolve the create/overwrite target for `bt init`. +pub fn init_target(here: bool, force: bool) -> Result { + init_target_from( + std::env::current_dir().context("could not read current directory")?, + dirs::home_dir().as_deref(), + here, + force, + ) +} + +fn init_target_from( + current_dir: PathBuf, + home: Option<&Path>, + here: bool, + force: bool, +) -> Result { + if here { + let path = current_dir.join(".bt/config.json"); + if path.exists() && !force { + bail!( + "{} already exists; rerun with --force to overwrite it", + path.display() + ); } + return Ok(path); + } + + let path = match project_boundary(current_dir, home) { + ProjectBoundary::Home => bail!( + "reached the home directory without finding a project git root; run `bt init` inside a repository, or pass --here" + ), + ProjectBoundary::Root => bail!( + "reached the filesystem root without finding a project git root; run `bt init` inside a repository, or pass --here" + ), + ProjectBoundary::Git(dir) => return Ok(dir.join(".bt/config.json")), + ProjectBoundary::Bt(dir) => dir.join("config.json"), + }; + if !path.is_file() { + bail!( + "found {} without config.json; remove the incomplete .bt directory, then rerun `bt init`", + path.parent().unwrap_or(&path).display() + ); + } + if !force { + bail!( + "{} already exists; use `bt switch` to change it, or rerun with --force to overwrite it", + path.display() + ); } + Ok(path) } pub fn local_save_path() -> Result { @@ -289,15 +499,53 @@ pub fn save_local(config: &Config, create_dir: bool) -> Result { // --- CLI commands --- -#[derive(Debug, Clone, Args)] +#[derive(Debug, Clone, Default, Args)] pub struct ScopeArgs { - /// Apply to global config (~/.config/bt/config.json) + /// Use global config (~/.config/bt/config.json) #[arg(long, short = 'g', conflicts_with = "local")] - global: bool, + pub(crate) global: bool, - /// Apply to local config (.bt/config.json) + /// Use local config (.bt/config.json) #[arg(long, short = 'l')] - local: bool, + pub(crate) local: bool, +} + +fn scope_labels(global: &Path, local: &Path) -> [String; 2] { + [ + format!("Global ({})", global.parent().unwrap_or(global).display()), + format!("Local ({})", local.parent().unwrap_or(local).display()), + ] +} + +type ResolvedScope = (PathBuf, &'static str); + +impl ScopeArgs { + pub(crate) fn preflight(&self, can_prompt: bool) -> Result<()> { + (!can_prompt) + .then(|| self.resolve(false, "")) + .transpose() + .map(drop) + } + + pub(crate) fn resolve(&self, can_prompt: bool, prompt: &str) -> Result { + if self.global || self.local { + let scope = if self.global { "global" } else { "local" }; + return resolve_write_path(self.global, self.local).map(|path| (path, scope)); + } + let Some(local) = local_path() else { + return Ok((global_path()?, "global")); + }; + if !can_prompt { + bail!("both global and local config scopes are available; pass --global or --local"); + } + let global = global_path()?; + let options = scope_labels(&global, &local); + Ok(if crate::ui::fuzzy_select(prompt, &options, 1)? == 0 { + (global, "global") + } else { + (local, "local") + }) + } } #[derive(Debug, Clone, Args)] @@ -318,14 +566,14 @@ enum ConfigCommands { }, /// Get a config value Get { - /// Config key (profile, org, project, project_id) + /// Config key (org, org_id, project, project_id, app_url, api_url) key: String, #[command(flatten)] scope: ScopeArgs, }, /// Set a config value Set { - /// Config key (profile, org, project, project_id) + /// Config key (org, org_id, project, project_id, app_url, api_url) key: String, /// Value to set value: String, @@ -334,7 +582,7 @@ enum ConfigCommands { }, /// Remove a config value Unset { - /// Config key (profile, org, project, project_id) + /// Config key (org, org_id, project, project_id, app_url, api_url) key: String, #[command(flatten)] scope: ScopeArgs, @@ -378,87 +626,211 @@ mod tests { use tempfile::TempDir; #[test] - fn merge_other_takes_precedence() { - let base = Config { - org: Some("base-org".into()), - project: Some("base-proj".into()), - ..Default::default() - }; - let other = Config { - org: Some("other-org".into()), - project: Some("other-proj".into()), + fn merge_keeps_org_and_project_contexts_together() { + let c = |org: Option<&str>, project: Option<&str>, id: Option<&str>| Config { + org: org.map(str::to_string), + project: project.map(str::to_string), + project_id: id.map(str::to_string), ..Default::default() }; - let merged = base.merge(&other); - assert_eq!(merged.org, Some("other-org".into())); - assert_eq!(merged.project, Some("other-proj".into())); + let g = || c(Some("global"), Some("global-proj"), Some("proj_g")); + let cases = [ + (Config::default(), Config::default(), Config::default()), + ( + g(), + c(Some("other"), Some("other-proj"), None), + c(Some("other"), Some("other-proj"), None), + ), + ( + c(Some("base"), None, None), + c(None, Some("local"), None), + c(None, Some("local"), None), + ), + (g(), c(Some("global"), None, None), g()), + ( + g(), + c(Some("local"), None, None), + c(Some("local"), None, None), + ), + ( + g(), + c(None, Some("local"), Some("proj_l")), + c(None, Some("local"), Some("proj_l")), + ), + (g(), c(Some(""), None, None), c(Some(""), None, None)), + (g(), Config::default(), g()), + ]; + for (global, local, expected) in cases { + assert_eq!(global.merge(&local), expected); + } } #[test] - fn merge_self_fills_when_other_none() { - let base = Config { - org: Some("base-org".into()), - project: Some("base-proj".into()), + fn merge_inherits_context_only_within_the_same_instance() { + let global = Config { + org: Some("test-org".into()), + org_id: Some("org_test".into()), + project: Some("test-project".into()), + project_id: Some("proj_test".into()), + app_url: Some("https://www.example.test".into()), + api_url: Some("https://api.example.test".into()), ..Default::default() }; - let other = Config::default(); - let merged = base.merge(&other); - assert_eq!(merged.org, Some("base-org".into())); - assert_eq!(merged.project, Some("base-proj".into())); - } + let same_instance = Config { + app_url: Some("https://www.example.test/".into()), + api_url: Some("https://proxy.example.test".into()), + ..Default::default() + }; + let merged = global.merge(&same_instance); + assert_eq!(merged.org.as_deref(), Some("test-org")); + assert_eq!(merged.org_id.as_deref(), Some("org_test")); + assert_eq!(merged.project_id.as_deref(), Some("proj_test")); + assert_eq!( + merged.api_url.as_deref(), + Some("https://proxy.example.test") + ); - #[test] - fn merge_both_none_stays_none() { - let base = Config::default(); - let other = Config::default(); - let merged = base.merge(&other); + let other_instance = Config { + app_url: Some("https://self-hosted.example.test".into()), + ..Default::default() + }; + let merged = global.merge(&other_instance); assert_eq!(merged.org, None); + assert_eq!(merged.org_id, None); assert_eq!(merged.project, None); + assert_eq!(merged.app_url, other_instance.app_url); + } + + #[test] + fn config_fills_urls_and_coupled_org_id_without_overriding_cli() { + let cfg = Config { + org: Some("config-org".into()), + org_id: Some("org_config".into()), + app_url: Some("https://www.example.test".into()), + api_url: Some("https://api.example.test".into()), + ..Default::default() + }; + let mut base = BaseArgs::default(); + apply_config_to_base(&mut base, &cfg); + assert_eq!(base.org_name.as_deref(), Some("config-org")); + assert_eq!(base.org_id.as_deref(), Some("org_config")); + assert_eq!(base.app_url, cfg.app_url); + assert_eq!(base.api_url, cfg.api_url); + + let mut base = BaseArgs { + org_name: Some("cli-org".into()), + org_name_source: Some(crate::args::ArgValueSource::CommandLine), + app_url: Some("https://cli.example.test".into()), + ..Default::default() + }; + apply_config_to_base(&mut base, &cfg); + assert_eq!(base.org_name.as_deref(), Some("cli-org")); + assert_eq!(base.org_id, None); + assert_eq!(base.app_url.as_deref(), Some("https://cli.example.test")); + assert_eq!(base.api_url, cfg.api_url); + + let mut same_instance = BaseArgs { + app_url: Some("https://www.example.test/".into()), + ..Default::default() + }; + apply_config_to_base(&mut same_instance, &cfg); + assert_eq!(same_instance.api_url, cfg.api_url); + + let mut other_instance = BaseArgs { + app_url: Some("https://other.example.test".into()), + ..Default::default() + }; + apply_config_to_base(&mut other_instance, &cfg); + assert_eq!(other_instance.org_name, None); + assert_eq!(other_instance.org_id, None); } #[test] - fn merge_partial_fill() { - let base = Config { - org: Some("base-org".into()), - project: None, + fn configured_project_does_not_cross_instance_boundaries() { + let cfg = Config { + org: Some("test-org".into()), + project: Some("test-project".into()), + app_url: Some("https://www.example.test".into()), ..Default::default() }; - let other = Config { - org: None, - project: Some("other-proj".into()), + let matching = BaseArgs { + org_name: Some("test-org".into()), + app_url: Some("https://www.example.test/".into()), ..Default::default() }; - let merged = base.merge(&other); - assert_eq!(merged.org, Some("base-org".into())); - assert_eq!(merged.project, Some("other-proj".into())); - } - - fn base_with_profile(profile: Option<&str>) -> BaseArgs { - BaseArgs { - json: false, - verbose: false, - verbose_source: None, - quiet: false, - quiet_source: None, - 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, - api_url: None, - app_url: None, - ca_cert: None, - env_file: None, + assert_eq!( + project_from_config_for_context(&matching, &cfg, Some("test-org")).as_deref(), + Some("test-project") + ); + + let other = BaseArgs { + app_url: Some("https://other.example.test".into()), + ..matching + }; + assert_eq!( + project_from_config_for_context(&other, &cfg, Some("test-org")), + None + ); + } + + #[test] + fn changing_app_url_clears_coupled_context() { + let mut cfg = Config { + org: Some("test-org".into()), + org_id: Some("org_test".into()), + project: Some("test-project".into()), + project_id: Some("proj_test".into()), + app_url: Some("https://www.example.test".into()), + ..Default::default() + }; + assert!(cfg.set_field("app_url", "https://other.example.test".into())); + assert_eq!(cfg.org, None); + assert_eq!(cfg.org_id, None); + assert_eq!(cfg.project, None); + assert_eq!(cfg.project_id, None); + } + + #[test] + fn scope_labels_are_plain_text() { + let labels = scope_labels( + Path::new("/home/test-user/.config/bt/config.json"), + Path::new("/work/test-project/.bt/config.json"), + ); + assert_eq!(labels[1], "Local (/work/test-project/.bt)"); + assert!(labels.iter().all(|label| !label.contains('\u{1b}'))); + } + + #[test] + fn option_helpers_handle_empty_values() { + for (input, org, trimmed) in [ + (None, None, None), + (Some(""), None, None), + (Some(" "), None, None), + (Some("test-org"), Some("test-org"), Some("test-org")), + ] { + assert_eq!(org_option(input), org); + assert_eq!(trimmed_option(input), trimmed); } + + let mut cfg = Config::default(); + cfg.set_context( + ("test-org", "org_test"), + Some(("test-project", "proj_test")), + "https://www.example.test", + "https://api.example.test", + ); + assert_eq!(cfg.org.as_deref(), Some("test-org")); + assert_eq!(cfg.org_id.as_deref(), Some("org_test")); + assert_eq!(cfg.project.as_deref(), Some("test-project")); + assert_eq!(cfg.project_id.as_deref(), Some("proj_test")); } - fn config(profile: Option<&str>, org: Option<&str>, project: Option<&str>) -> Config { + fn base_args() -> BaseArgs { + BaseArgs::default() + } + + 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() @@ -466,22 +838,18 @@ mod tests { } #[test] - fn project_config_matches_explicit_profile_or_legacy_org() { - 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(Some("work"), Some("acme"), Some("demo")), - Some("demo"), - ), - ]; - - for (cfg, expected) in cases { + fn project_config_must_match_org_context() { + let base = base_args(); + for (config_org, resolved_org, expected) in [ + (Some("test-org"), "test-org", Some("test-project")), + (Some("other-org"), "test-org", None), + (None, "test-org", None), + (Some(""), "test-org", None), + (Some(""), "", Some("test-project")), + ] { + let cfg = config(config_org, Some("test-project")); assert_eq!( - project_from_config_for_context(&base, &cfg, Some("acme")).as_deref(), + project_from_config_for_context(&base, &cfg, Some(resolved_org)).as_deref(), expected ); } @@ -539,6 +907,37 @@ 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 load_purges_obsolete_empty_org_context() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("config.json"); + fs::write( + &path, + r#"{"org":"","org_id":"org_old","project":"old","project_id":"proj_old"}"#, + ) + .unwrap(); + let loaded = load_file(&path); + assert_eq!(loaded.org, None); + assert_eq!(loaded.org_id, None); + assert_eq!(loaded.project, None); + assert_eq!(loaded.project_id, None); + } + #[test] fn unknown_keys_roundtrip_through_save() { let tmp = TempDir::new().unwrap(); @@ -571,4 +970,113 @@ mod tests { save_file(&path, &config).unwrap(); assert!(path.exists()); } + + #[test] + fn local_discovery_requires_config_json_and_stops_at_first_bt() { + let tmp = TempDir::new().unwrap(); + let repo = tmp.path().join("repo"); + let nested = repo.join("a").join("b"); + fs::create_dir_all(&nested).unwrap(); + fs::create_dir(repo.join(".git")).unwrap(); + fs::create_dir(repo.join(".bt")).unwrap(); + + assert_eq!(find_local_config_dir_from(nested.clone(), None), None); + + fs::write(repo.join(".bt/config.json"), "{}").unwrap(); + assert_eq!( + find_local_config_dir_from(nested, None), + Some(repo.join(".bt")) + ); + } + + #[test] + fn local_discovery_does_not_use_home_bt() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("home"); + fs::create_dir_all(home.join(".bt")).unwrap(); + fs::write(home.join(".bt/config.json"), "{}").unwrap(); + + assert_eq!( + find_local_config_dir_from(home.clone(), Some(home.as_path())), + None + ); + } + + #[test] + fn init_target_finds_nested_git_directory_or_file() { + for git_is_file in [false, true] { + let tmp = TempDir::new().unwrap(); + let repo = tmp.path().join("repo"); + let nested = repo.join("nested").join("deeper"); + fs::create_dir_all(&nested).unwrap(); + if git_is_file { + fs::write(repo.join(".git"), "gitdir: synthetic").unwrap(); + } else { + fs::create_dir(repo.join(".git")).unwrap(); + } + + assert_eq!( + init_target_from(nested, Some(tmp.path()), false, false).unwrap(), + repo.join(".bt/config.json") + ); + } + } + + #[test] + fn init_target_existing_bt_requires_force_and_existing_config() { + let tmp = TempDir::new().unwrap(); + let repo = tmp.path().join("repo"); + let nested = repo.join("nested"); + fs::create_dir_all(repo.join(".bt")).unwrap(); + fs::create_dir_all(&nested).unwrap(); + + assert!(init_target_from(nested.clone(), Some(tmp.path()), false, true).is_err()); + + let target = repo.join(".bt/config.json"); + fs::write(&target, "{}").unwrap(); + assert!(init_target_from(nested.clone(), Some(tmp.path()), false, false).is_err()); + assert_eq!( + init_target_from(nested, Some(tmp.path()), false, true).unwrap(), + target + ); + } + + #[test] + fn init_target_here_bypasses_home_boundary_and_honors_force() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("home"); + fs::create_dir_all(&home).unwrap(); + let target = home.join(".bt/config.json"); + + assert_eq!( + init_target_from(home.clone(), Some(home.as_path()), true, false).unwrap(), + target + ); + fs::create_dir_all(target.parent().unwrap()).unwrap(); + fs::write(&target, "{}").unwrap(); + assert!(init_target_from(home.clone(), Some(home.as_path()), true, false).is_err()); + assert_eq!( + init_target_from(home, Some(tmp.path()), true, true).unwrap(), + target + ); + } + + #[test] + fn init_target_home_wins_over_git_marker() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("home"); + fs::create_dir_all(home.join(".git")).unwrap(); + assert!(init_target_from(home.clone(), Some(home.as_path()), false, false).is_err()); + } + + #[cfg(unix)] + #[test] + fn init_target_here_bypasses_filesystem_root_boundary() { + let root = PathBuf::from("/"); + assert_eq!( + init_target_from(root.clone(), None, true, true).unwrap(), + root.join(".bt/config.json") + ); + assert!(init_target_from(root, None, false, false).is_err()); + } } diff --git a/src/datasets/pipeline.rs b/src/datasets/pipeline.rs index f1c43e49..a00b5618 100644 --- a/src/datasets/pipeline.rs +++ b/src/datasets/pipeline.rs @@ -2065,26 +2065,7 @@ mod tests { } fn test_base_args() -> BaseArgs { - BaseArgs { - json: false, - verbose: false, - verbose_source: None, - quiet: false, - quiet_source: None, - 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, - api_url: None, - app_url: None, - ca_cert: None, - env_file: None, - } + BaseArgs::default() } fn test_source() -> PipelineSourceInspect { diff --git a/src/datasets/snapshots.rs b/src/datasets/snapshots.rs index 00f4a804..cdb4cdd2 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::{ @@ -1088,11 +1089,9 @@ fn print_restore_preview( } fn resolve_default_snapshot_author(base: &BaseArgs, ctx: &ResolvedContext) -> Option { - if api_key_override_active(base) { - 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())) + .ok() + .flatten()?; profile_author_slug(&profile) } @@ -1101,14 +1100,6 @@ fn default_snapshot_name(author: &str, now: DateTime) -> String { format!("{author}-{}", now.format("%Y%m%d-%H%M%Sz")) } -fn api_key_override_active(base: &BaseArgs) -> bool { - !base.prefer_profile - && base - .api_key - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/eval.rs b/src/eval.rs index ebc4f4dc..ab6bfc79 100644 --- a/src/eval.rs +++ b/src/eval.rs @@ -35,6 +35,7 @@ use crate::ui::{ summary_metric_unit, SummaryExperimentColumn, SummaryMetricCell, SummaryMetricKind, SummaryMetricRow, SummaryTableOptions, }; +use crate::utils::shell_quote_arg; const MAX_NAME_LENGTH: usize = 40; const WATCH_POLL_INTERVAL: Duration = Duration::from_millis(500); @@ -746,7 +747,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 +2806,7 @@ struct EvalUi { deferred_errors: Vec, suppressed_stderr_lines: usize, finished: bool, - profile: Option, + org: Option, } struct EvalBarState { @@ -2815,7 +2816,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 +2842,7 @@ impl EvalUi { deferred_errors: Vec::new(), suppressed_stderr_lines: 0, finished: false, - profile, + org, } } @@ -2869,7 +2870,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 +3351,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 +3376,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,31 +3388,20 @@ 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), )) } -fn shell_quote_arg(value: &str) -> String { - if value - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/' | ':' | '=')) - { - value.to_string() - } else { - format!("'{}'", value.replace('\'', "'\\''")) - } -} - #[cfg(test)] mod tests { use super::*; @@ -4544,7 +4534,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 +4554,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/experiments/mod.rs b/src/experiments/mod.rs index a3eaabbb..7ee4bec8 100644 --- a/src/experiments/mod.rs +++ b/src/experiments/mod.rs @@ -219,17 +219,8 @@ 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 { + let has_org_override = base.org_name_source.is_some(); + if !has_org_override { if let Some(org) = parsed_url .org .as_deref() @@ -237,6 +228,7 @@ fn apply_experiment_url_hints_to_base( .filter(|v| !v.is_empty()) { base.org_name = Some(org.to_string()); + base.org_id = None; } } @@ -382,6 +374,32 @@ mod tests { ); } + #[test] + fn comparison_url_org_overrides_config_but_not_cli() { + let parsed = ParsedExperimentCompareUrl { + org: Some("url-org".to_string()), + project: None, + base_experiment: None, + comparison_experiment: None, + }; + let config_base = BaseArgs { + org_name: Some("config-org".to_string()), + org_id: Some("org_config".to_string()), + ..Default::default() + }; + let updated = apply_experiment_url_hints_to_base(config_base, Some(&parsed)); + assert_eq!(updated.org_name.as_deref(), Some("url-org")); + assert_eq!(updated.org_id, None); + + let cli_base = BaseArgs { + org_name: Some("cli-org".to_string()), + org_name_source: Some(crate::args::ArgValueSource::CommandLine), + ..Default::default() + }; + let updated = apply_experiment_url_hints_to_base(cli_base, Some(&parsed)); + assert_eq!(updated.org_name.as_deref(), Some("cli-org")); + } + #[test] fn compare_startup_url_uses_url_like_positional_arg() { let args = ExperimentsArgs { diff --git a/src/functions/push.rs b/src/functions/push.rs index 4d986b2f..958ded96 100644 --- a/src/functions/push.rs +++ b/src/functions/push.rs @@ -13,8 +13,7 @@ use indicatif::{ProgressBar, ProgressStyle}; use serde::Deserialize; use serde_json::{json, Map, Value}; -use crate::args::BaseArgs; -use crate::args::DEFAULT_API_URL; +use crate::args::{custom_api_without_app_url, BaseArgs}; use crate::auth::{list_available_orgs, resolve_auth}; use crate::config; @@ -270,21 +269,10 @@ pub async fn run(base: BaseArgs, args: PushArgs) -> Result<()> { ); } }; - let has_app_url = resolved_auth - .app_url - .as_deref() - .map(str::trim) - .is_some_and(|value| !value.is_empty()); - let custom_api_without_app_url = resolved_auth - .api_url - .as_deref() - .map(str::trim) - .map(|value| value.trim_end_matches('/')) - .is_some_and(|api_url| { - !api_url.eq_ignore_ascii_case(DEFAULT_API_URL.trim_end_matches('/')) - }) - && !has_app_url; - if custom_api_without_app_url { + if custom_api_without_app_url( + resolved_auth.api_url.as_deref(), + resolved_auth.app_url.as_deref(), + ) { return fail_push( &base, 0, @@ -4055,25 +4043,6 @@ mod tests { } fn test_base_args() -> BaseArgs { - BaseArgs { - json: false, - verbose: false, - verbose_source: None, - quiet: false, - quiet_source: None, - 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, - api_url: None, - app_url: None, - ca_cert: None, - env_file: None, - } + BaseArgs::default() } } diff --git a/src/init.rs b/src/init.rs index e9976c1b..00bd6e65 100644 --- a/src/init.rs +++ b/src/init.rs @@ -1,93 +1,105 @@ -use anyhow::{bail, Result}; +use anyhow::{Context, Result}; use clap::Args; use crate::{ - args::BaseArgs, - auth::{self, login}, - config, - http::ApiClient, - ui::{is_interactive, print_command_status, select_project, CommandStatus, ProjectSelectMode}, + args::{ArgValueSource, BaseArgs, DEFAULT_API_URL, DEFAULT_APP_URL}, + config, switch, + ui::{print_command_status, CommandStatus}, }; #[derive(Debug, Clone, Args)] #[command(after_help = "\ Examples: bt init - bt init --org acme --project my-app + bt init --org test-org --project test-project + bt init --here + bt init --here --force ")] -pub struct InitArgs {} +pub struct InitArgs { + /// Create .bt/config.json in the current directory without searching upward. + /// + /// Bypasses the normal home and filesystem-root search boundaries, so it + /// also applies when the current directory is ~ or /. + #[arg(long)] + here: bool, -pub async fn run(base: BaseArgs, _args: InitArgs) -> Result<()> { - let config_path = config::local_save_path()?; - if config_path.exists() { - if base.json { - let existing = config::load_file(&config_path); - let payload = serde_json::json!({ - "initialized": false, - "status": "already-initialized", - "org": existing.org, - "project": existing.project, - "path": config_path.display().to_string(), - }); - println!("{}", serde_json::to_string(&payload)?); - } else { - print_command_status(CommandStatus::Warning, "Already Initialized"); - } - return Ok(()); - } - - eprintln!("Link to a Braintrust project..."); + /// Overwrite an existing .bt/config.json. Does not change discovery. + #[arg(long, short = 'f')] + force: bool, +} - let (org, project) = if let (Some(o), Some(p)) = (&base.org_name, &base.project) { - (o.clone(), p.clone()) - } else if !is_interactive() { - bail!("--org and --project required in non-interactive mode"); +pub async fn run(base: BaseArgs, args: InitArgs) -> Result<()> { + let config_path = config::init_target(args.here, args.force)?; + let current_cfg = config::load().unwrap_or_default(); + let requested_org = matches!( + base.org_name_source, + Some(ArgValueSource::CommandLine | ArgValueSource::EnvVariable) + ) + .then(|| base.org_name.as_deref()) + .flatten(); + let (instance, org, project) = switch::select_context( + &base, + requested_org, + base.project.as_deref(), + ¤t_cfg, + Some("Link to project"), + ) + .await?; + let api_url = if matches!( + base.api_url_source, + Some(ArgValueSource::CommandLine | ArgValueSource::EnvVariable) + ) { + base.api_url.clone() } 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); - } - } - let ctx = login(&login_base).await?; - let client = ApiClient::new(&ctx)?; - - let org = client.org_name().to_string(); - let project = select_project( - &client, - None, - Some("Link to project"), - ProjectSelectMode::ExistingOnly, - ) - .await? - .name; - - (org, project) - }; + org.api_url.clone().or_else(|| { + config::urls_equal( + current_cfg.app_url.as_deref().unwrap_or(DEFAULT_APP_URL), + &instance.app_url, + ) + .then(|| current_cfg.api_url.clone()) + .flatten() + }) + } + .unwrap_or_else(|| DEFAULT_API_URL.to_string()); - let cfg = config::Config { - org: Some(org.clone()), - project: Some(project.clone()), - ..Default::default() - }; + // With --force, preserve unknown passthrough keys from the old file. + let mut cfg = config::load_file(&config_path); + cfg.set_context( + (org.name.as_str(), org.id.as_str()), + Some((project.name.as_str(), project.id.as_str())), + &instance.app_url, + &api_url, + ); - let written_path = config::save_local(&cfg, true)?; + config::save_file(&config_path, &cfg).with_context(|| { + format!( + "authentication succeeded, but initialization failed: could not create or write {}; any credential updates remain saved", + config_path.display() + ) + })?; if base.json { let payload = serde_json::json!({ "initialized": true, "status": "created", - "org": org, - "project": project, - "path": written_path.display().to_string(), + "org": org.name, + "org_id": org.id, + "project": project.name, + "project_id": project.id, + "app_url": instance.app_url, + "api_url": api_url, + "path": config_path.display().to_string(), }); println!("{}", serde_json::to_string(&payload)?); } else { print_command_status( CommandStatus::Success, - &format!("Project linked to {org}/{project}"), + &format!("Project linked to {}/{}", org.name, project.name), + ); + print_command_status( + CommandStatus::Success, + &format!("Created {}", config_path.display()), ); - print_command_status(CommandStatus::Success, "Created .bt/config.json"); } Ok(()) diff --git a/src/main.rs b/src/main.rs index 5f12e60f..5a880534 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") { @@ -58,7 +58,7 @@ const HELP_TEMPLATE: &str = "\ Core init Initialize .bt config directory and files auth Authenticate bt with Braintrust - switch Switch org and project context + switch Switch instance, org, and project context view View logs, traces, and spans Projects & resources @@ -80,13 +80,13 @@ Data & evaluation Additional docs Manage workflow docs for coding agents setup Configure Braintrust setup flows - status Show current org and project context + status Show saved logins and current context 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=] @@ -161,9 +161,9 @@ enum Commands { Sync(CLIArgs), /// Local utility commands Util(CLIArgs), - /// Switch org and project context + /// Switch instance, org, and project context Switch(CLIArgs), - /// Show current org and project context + /// Show saved auth logins and current org/project context Status(CLIArgs), // /// View and modify config // Config(CLIArgs), @@ -296,7 +296,7 @@ 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); + config::apply_base_config(cli.command.base_mut()); apply_base_output_defaults(&mut cli.command); configure_output(cli.command.base()); apply_runtime_env_overrides(cli.command.base()); @@ -347,7 +347,11 @@ 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); + base.api_url_source = find_value_source(matches, "api_url").and_then(map_value_source); + base.app_url_source = find_value_source(matches, "app_url").and_then(map_value_source); } fn apply_base_output_defaults(command: &mut Commands) { @@ -490,17 +494,22 @@ fn has_io_error(err: &anyhow::Error) -> bool { } fn looks_like_user_error(err: &anyhow::Error) -> bool { - let message = err.to_string().to_lowercase(); - message.contains("required") - || message.contains("use:") - || message.contains("not found") - || message.contains("invalid") + err.chain().any(|source| { + let message = source.to_string().to_lowercase(); + message.contains("required") + || message.contains("use:") + || message.contains("not found") + || message.contains("invalid") + || message.contains("already exists") + || message.contains("without finding") + || message.contains("without config.json") + }) } 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 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."); @@ -547,6 +556,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 45ebfbcd..bea36fa1 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -1179,6 +1179,10 @@ fn should_print_agent_selection_intro( fn apply_setup_config_fallbacks(base: &mut BaseArgs) { let cfg = config::load().unwrap_or_default(); + let same_instance = config::urls_equal( + base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL), + cfg.app_url.as_deref().unwrap_or(DEFAULT_APP_URL), + ); if base .org_name @@ -1186,11 +1190,16 @@ fn apply_setup_config_fallbacks(base: &mut BaseArgs) { .map(str::trim) .is_none_or(str::is_empty) { - base.org_name = cfg - .org - .as_deref() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()); + if same_instance { + base.org_name = cfg + .org + .as_deref() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + base.org_id = base.org_name.as_ref().and(cfg.org_id.clone()); + } else { + base.org_id = None; + } } if base @@ -1401,7 +1410,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,21 +1475,17 @@ 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); - 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,91 +1496,43 @@ async fn run_setup_browser_auth( }) } -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!( - "profile '{profile_name}' not found; run `bt auth profiles` to see available profiles" - ); + 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)) - .map(|profile| profile.name.clone()) - .collect::>(); - matches.sort(); - - return match matches.len() { - 0 => Ok(None), - 1 => Ok(Some(matches.remove(0))), - _ 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(", ") - ), - }; - } - - 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 profile selected")) - } else { - Ok(None) + _ => Ok(None), } } @@ -1612,11 +1568,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,111 +1682,90 @@ 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( +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()); - - match auth::login(&auth_base).await { - Ok(ctx) => { - base.profile = auth_base.profile.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!( - " Profile '{}' 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)) + || auth::has_oauth_login_for_instance(&auth_base)?; + + 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), } } 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 { 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, @@ -1847,7 +1784,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 @@ -1863,7 +1800,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() @@ -1876,12 +1812,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 @@ -1889,7 +1819,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"); @@ -1899,80 +1829,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!("profile '{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", - profile.org_name.as_deref().unwrap_or("(none)"), - org_name - ); - } - org_name - } - None => profile.org_name.as_deref().ok_or_else(|| { - anyhow!("profile '{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(); - if base.prefer_profile && matching_profile_count == 0 { - bail!("no profile found for org '{org_name}'"); - } - 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( @@ -1986,9 +1843,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), @@ -2017,92 +1874,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? @@ -2110,9 +1881,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, @@ -2126,9 +1897,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, @@ -5426,26 +5197,7 @@ mod tests { } fn make_base_args() -> BaseArgs { - BaseArgs { - json: false, - verbose: false, - verbose_source: None, - quiet: false, - quiet_source: None, - 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, - api_url: None, - app_url: None, - ca_cert: None, - env_file: None, - } + BaseArgs::default() } fn restore_env_var(key: &str, previous: Option) { @@ -5773,18 +5525,18 @@ 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, email: None, 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, email: None, @@ -5792,69 +5544,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(), - org_name: Some("Acme".to_string()), + auth_method: "api_key".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("profile '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(), - org_name: Some("Acme".to_string()), + auth_method: "api_key".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 93b555ed..f8d26476 100644 --- a/src/status.rs +++ b/src/status.rs @@ -1,10 +1,10 @@ -use anyhow::Result; +use anyhow::{bail, Result}; use clap::Args; use serde::Serialize; -use crate::args::BaseArgs; +use crate::args::{ArgValueSource, BaseArgs, DEFAULT_API_URL, DEFAULT_APP_URL}; use crate::auth; -use crate::{config, utils::resolve_profile_info}; +use crate::config; #[derive(Debug, Clone, Args)] #[command(after_help = "\ @@ -17,30 +17,37 @@ pub struct StatusArgs {} #[derive(Serialize)] struct StatusOutput { + logins: Vec, + credentials: String, org: Option, + org_id: Option, project: Option, - profile: Option, + project_id: Option, + app_url: Option, + api_url: 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, } -fn format_identity(p: &auth::ProfileInfo) -> Option { - if let Some(ref email) = p.email { - match p.user_name.as_deref() { - Some(name) => Some(format!("{name} ({email})")), - None => Some(email.clone()), - } - } else { - p.api_key_hint.clone() - } +fn format_auth(p: &auth::ProfileInfo) -> String { + auth::identity_label( + p.user_name.as_deref(), + p.email.as_deref(), + p.api_key_hint.as_deref(), + ) + .map(|identity| format!("{} — {identity}", p.auth_method)) + .unwrap_or_else(|| p.auth_method.clone()) } pub async fn run(base: BaseArgs, _args: StatusArgs) -> Result<()> { + let saved_logins = auth::saved_login_status().await?; let global_path = config::global_path().ok(); let global_cfg = config::load_global().unwrap_or_default(); let local_path = config::local_path(); @@ -49,141 +56,257 @@ 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 (mut org, mut project, source) = resolve_config( - cli_org, - cli_project, + let overrides = ConfigOverrides::from_base(&base); + let (mut org, mut project, mut source) = resolve_config( + overrides, &global_cfg, &local_cfg, &local_path, &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 + let app_url = base + .app_url + .clone() + .or_else(|| merged_cfg.app_url.clone()) + .unwrap_or_else(|| DEFAULT_APP_URL.to_string()); + let api_url = base + .api_url + .clone() + .or_else(|| merged_cfg.api_url.clone()) + .unwrap_or_else(|| DEFAULT_API_URL.to_string()); + let auth_info = auth::active_auth_info(&base, org.as_deref())?; + let ad_hoc_api_key_source = base.api_key_source.filter(|_| { + base.api_key + .as_deref() + .is_some_and(|key| !key.trim().is_empty()) + && auth_info + .as_ref() + .is_some_and(|info| info.auth_method == "api_key") + }); + + let (org_id, project_id) = if let Some(api_key_source) = ad_hoc_api_key_source { + let requested_org = base + .org_name_source + .and_then(|_| config::org_option(base.org_name.as_deref())); + let mut orgs = auth::list_available_orgs_for_api_key( + base.api_key.as_deref().expect("non-empty API key checked"), + &app_url, + ) + .await?; + orgs.retain(|org| { + org.api_url .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) + .is_none_or(|url| config::urls_equal(url, &api_url)) }); - 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 selected_org = match requested_org { + Some(requested) => orgs.into_iter().find(|org| { + org.id == requested + || org.name == requested + || org.name.eq_ignore_ascii_case(requested) + }), + None if orgs.is_empty() => None, + None if orgs.len() == 1 => orgs.pop(), + None => bail!("API key belongs to multiple organizations; pass --org "), } - } - - if base - .project - .as_deref() - .map(str::trim) - .is_none_or(str::is_empty) - { - let mut project_base = base.clone(); - if project_base - .profile + .ok_or_else(|| anyhow::anyhow!("API key has no matching organization"))?; + org = Some(selected_org.name.clone()); + project = None; + source = Some( + if api_key_source == ArgValueSource::CommandLine { + "cli" + } else { + "env" + } + .to_string(), + ); + (Some(selected_org.id), None) + } else { + if base + .project .as_deref() .map(str::trim) .is_none_or(str::is_empty) { - project_base.profile = selected_profile.clone(); + project = config::project_from_config_for_context(&base, &merged_cfg, org.as_deref()); } - project = - config::project_from_config_for_context(&project_base, &merged_cfg, org.as_deref()); - } + + let org_id = if base.org_name_source.is_some() { + None + } else { + base.org_id.clone() + }; + let configured_project = + config::project_from_config_for_context(&base, &merged_cfg, org.as_deref()); + let project_id = (base.project_source.is_none() + && configured_project.is_some() + && configured_project == project) + .then(|| merged_cfg.project_id.clone()) + .flatten(); + (org_id, project_id) + }; if base.json { let output = StatusOutput { - org, + logins: saved_logins.logins, + credentials: saved_logins.credentials_path.display().to_string(), + org: org.clone(), + org_id, 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()), + project_id, + app_url: Some(app_url), + api_url: Some(api_url), + 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)?); return Ok(()); } + auth::print_saved_login_status(&base, &saved_logins); if base.verbose { println!("org: {}", org.as_deref().unwrap_or("(unset)")); + println!("org_id: {}", org_id.as_deref().unwrap_or("(unset)")); 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(src) = source { - println!("source: {src}"); - } - } else if org.is_some() { - let scope = match (&org, &project) { - (Some(o), Some(p)) => format!("{o}/{p}"), - (Some(o), None) => o.to_string(), - _ => 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), + println!("project_id: {}", project_id.as_deref().unwrap_or("(unset)")); + println!("app_url: {app_url}"); + println!("api_url: {api_url}"); + println!( + "auth: {}", + auth_info + .as_ref() + .map(format_auth) + .unwrap_or_else(|| "(unset)".to_string()) + ); + println!("source: {}", source.as_deref().unwrap_or("(unset)")); + } else { + let header = match org.as_deref() { + Some(org) => match project.as_deref() { + Some(project) => format!("{org}/{project}"), + None => org.to_string(), }, - None => " profile: (none)".to_string(), + None if auth_info.is_some() => "No default org".to_string(), + None => "No org/project configured. Run `bt switch` to set one.".to_string(), }; - println!("{profile_line}"); - } else { - println!("No org/project configured. Run `bt switch` to set one."); + println!("{header}"); + match &auth_info { + Some(p) => println!(" auth: {}", format_auth(p)), + None if org.is_some() => println!(" auth: (none)"), + None => {} + } } 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, + cli_app_url: Option, + env_app_url: Option, + cli_api_url: Option, + env_api_url: 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), + }; + let (cli_app_url, env_app_url) = match base.app_url_source { + Some(ArgValueSource::CommandLine) => (base.app_url.clone(), None), + Some(ArgValueSource::EnvVariable) => (None, base.app_url.clone()), + None => (None, None), + }; + let (cli_api_url, env_api_url) = match base.api_url_source { + Some(ArgValueSource::CommandLine) => (base.api_url.clone(), None), + Some(ArgValueSource::EnvVariable) => (None, base.api_url.clone()), + None => (None, None), + }; + + Self { + cli_org, + env_org, + cli_project, + env_project, + cli_app_url, + env_app_url, + cli_api_url, + env_api_url, + } + } +} + +/// 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 org = cli_org - .clone() - .or_else(|| env_org.clone()) - .or_else(|| local.org.clone()) - .or_else(|| global.org.clone()); + let ConfigOverrides { + cli_org, + env_org, + cli_project, + env_project, + cli_app_url, + env_app_url, + cli_api_url, + env_api_url, + } = overrides; + let env_project = env_project.filter(|s| !s.is_empty()); + let merged = global.merge(local); + let app_override = cli_app_url.as_deref().or(env_app_url.as_deref()); + let config_app = merged.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); + let same_instance = app_override.is_none_or(|app| config::urls_equal(app, config_app)); + let config_org = same_instance.then(|| merged.org.clone()).flatten(); + let config_project = same_instance.then(|| merged.project.clone()).flatten(); + let org = cli_org.clone().or_else(|| env_org.clone()).or(config_org); let project = cli_project .clone() .or_else(|| env_project.clone()) - .or_else(|| local.project.clone()) - .or_else(|| global.project.clone()); + .or(config_project); - let source = if cli_org.is_some() || cli_project.is_some() { + let source = if cli_org.is_some() + || cli_project.is_some() + || cli_app_url.is_some() + || cli_api_url.is_some() + { Some("cli".to_string()) - } else if env_org.is_some() || env_project.is_some() { + } else if env_org.is_some() + || env_project.is_some() + || env_app_url.is_some() + || env_api_url.is_some() + { Some("env".to_string()) - } else if local.org.is_some() || local.project.is_some() { + } else if local.org.is_some() + || local.project.is_some() + || local.app_url.is_some() + || local.api_url.is_some() + { local_path.as_ref().map(|p| p.display().to_string()) - } else if global.org.is_some() || global.project.is_some() { + } else if global.org.is_some() + || global.project.is_some() + || global.app_url.is_some() + || global.api_url.is_some() + { global_path.as_ref().map(|p| p.display().to_string()) } else { None @@ -192,32 +315,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::*; @@ -236,146 +333,101 @@ mod tests { } #[test] - fn cli_overrides_everything() { - let global = config(Some("global-org"), Some("global-proj")); - let local = config(Some("local-org"), Some("local-proj")); + fn config_precedence_and_context_safety() { + let both = || config(Some("global-org"), Some("global-proj")); + let cases = [ + ( + "cli", + ConfigOverrides { + cli_org: s("cli-org"), + cli_project: s("cli-proj"), + ..Default::default() + }, + both(), + config(Some("local-org"), Some("local-proj")), + (Some("cli-org"), Some("cli-proj"), Some("cli")), + ), + ( + "env", + ConfigOverrides { + env_org: s("env-org"), + env_project: s("env-proj"), + ..Default::default() + }, + both(), + config(Some("local-org"), Some("local-proj")), + (Some("env-org"), Some("env-proj"), Some("env")), + ), + ( + "local", + ConfigOverrides::default(), + both(), + config(Some("local-org"), Some("local-proj")), + ( + Some("local-org"), + Some("local-proj"), + Some("/project/.bt/config.json"), + ), + ), + ( + "global", + ConfigOverrides::default(), + both(), + config(None, None), + ( + Some("global-org"), + Some("global-proj"), + Some("/home/.bt/config.json"), + ), + ), + ( + "empty", + ConfigOverrides::default(), + config(None, None), + config(None, None), + (None, None, None), + ), + ( + "app override does not inherit another instance's context", + ConfigOverrides { + cli_app_url: s("https://other.example.test"), + ..Default::default() + }, + both(), + config(None, None), + (None, None, Some("cli")), + ), + ( + "mixed cli/local", + ConfigOverrides { + cli_org: s("cli-org"), + ..Default::default() + }, + both(), + config(None, Some("local-proj")), + (Some("cli-org"), Some("local-proj"), Some("cli")), + ), + ( + "local project does not inherit global org", + ConfigOverrides::default(), + config(Some("global-org"), None), + config(None, Some("local-proj")), + (None, Some("local-proj"), Some("/project/.bt/config.json")), + ), + ]; 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( - s("cli-org"), - s("cli-proj"), - &global, - &local, - &local_path, - &global_path, - ); - - assert_eq!(org, s("cli-org")); - assert_eq!(project, s("cli-proj")); - assert_eq!(source, s("cli")); - } - - #[test] - fn local_overrides_global() { - 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(None, None, &global, &local, &local_path, &global_path); - - assert_eq!(org, s("local-org")); - assert_eq!(project, s("local-proj")); - assert_eq!(source, s("/project/.bt/config.json")); - } - - #[test] - fn global_used_when_local_empty() { - let global = config(Some("global-org"), Some("global-proj")); - let local = config(None, None); - 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); - - assert_eq!(org, s("global-org")); - assert_eq!(project, s("global-proj")); - assert_eq!(source, s("/home/.bt/config.json")); - } - - #[test] - fn no_source_when_all_empty() { - let global = config(None, None); - let local = config(None, None); - 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); - - assert_eq!(org, None); - assert_eq!(project, None); - assert_eq!(source, None); - } - - #[test] - fn mixed_sources_org_cli_project_local() { - let global = config(Some("global-org"), Some("global-proj")); - let local = config(None, 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( - s("cli-org"), - None, - &global, - &local, - &local_path, - &global_path, - ); - - assert_eq!(org, s("cli-org")); - assert_eq!(project, s("local-proj")); - assert_eq!(source, s("cli")); - } - - #[test] - fn values_cascade_across_layers() { - let global = config(Some("global-org"), None); - let local = config(None, 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(None, None, &global, &local, &local_path, &global_path); - - assert_eq!(org, s("global-org")); - assert_eq!(project, s("local-proj")); - assert_eq!(source, s("/project/.bt/config.json")); - } - - fn profile( - name: &str, - user_name: Option<&str>, - email: Option<&str>, - api_key_hint: Option<&str>, - ) -> auth::ProfileInfo { - auth::ProfileInfo { - name: name.into(), - org_name: None, - user_name: user_name.map(Into::into), - email: email.map(Into::into), - api_key_hint: api_key_hint.map(Into::into), + for (name, overrides, global, local, expected) in cases { + let actual = resolve_config(overrides, &global, &local, &local_path, &global_path); + assert_eq!( + actual, + ( + expected.0.map(str::to_string), + expected.1.map(str::to_string), + expected.2.map(str::to_string), + ), + "{name}" + ); } } - - #[test] - fn format_identity_name_and_email() { - let p = profile("work", Some("Alice"), Some("alice@example.com"), None); - assert_eq!( - format_identity(&p), - Some("Alice (alice@example.com)".into()) - ); - } - - #[test] - fn format_identity_email_only() { - let p = profile("work", 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")); - assert_eq!(format_identity(&p), Some("sk-****zhJwO".into())); - } - - #[test] - fn format_identity_none() { - let p = profile("work", None, None, None); - assert_eq!(format_identity(&p), None); - } } diff --git a/src/switch.rs b/src/switch.rs index 35495bb6..5ae73538 100644 --- a/src/switch.rs +++ b/src/switch.rs @@ -1,30 +1,23 @@ use anyhow::{bail, Context, Result}; use clap::Args; -use dialoguer::{console, theme::ColorfulTheme, Select}; -use crate::args::BaseArgs; -use crate::auth::{self, login}; +use crate::args::{ArgValueSource, BaseArgs, DEFAULT_API_URL, DEFAULT_APP_URL}; +use crate::auth::{self, login, AvailableInstance, AvailableOrg}; use crate::config; use crate::http::ApiClient; -use crate::projects::api; -use crate::ui::{ - is_interactive, print_command_status, select_project, with_spinner, CommandStatus, -}; +use crate::ui::{can_prompt, print_command_status, select_or_create_project, CommandStatus}; #[derive(Debug, Clone, Args)] #[command(after_help = "\ Examples: bt switch - bt switch my-project - bt switch personal-org/my-project + bt switch test-project + bt switch test-org/test-project ")] pub struct SwitchArgs { - /// Force set global config value - #[arg(long, short = 'g', conflicts_with = "local")] - global: bool, - /// Force set local config value - #[arg(long, short = 'l')] - local: bool, + #[command(flatten)] + scope: config::ScopeArgs, + /// Target: project name or org/project #[arg(value_name = "TARGET")] target: Option, @@ -34,137 +27,207 @@ impl SwitchArgs { fn resolve_target(&self, base: &BaseArgs) -> (Option, Option) { let (pos_org, pos_project) = match &self.target { None => (None, None), - Some(t) if t.contains('/') => { - let parts: Vec<&str> = t.splitn(2, '/').collect(); - let o = (!parts[0].is_empty()).then(|| parts[0].to_string()); - let p = (!parts[1].is_empty()).then(|| parts[1].to_string()); - (o, p) + Some(target) if target.contains('/') => { + let parts: Vec<&str> = target.splitn(2, '/').collect(); + let org = (!parts[0].trim().is_empty()).then(|| parts[0].trim().to_string()); + let project = (!parts[1].trim().is_empty()).then(|| parts[1].trim().to_string()); + (org, project) } - Some(t) => (None, Some(t.clone())), + Some(target) => (None, Some(target.clone())), }; - let org = base.org_name.clone().or(pos_org); - let project = base.project.clone().or(pos_project); - - (org, project) + ( + base.org_name + .as_ref() + .filter(|_| { + matches!( + base.org_name_source, + Some(ArgValueSource::CommandLine | ArgValueSource::EnvVariable) + ) + }) + .cloned() + .or(pos_org), + base.project.clone().or(pos_project), + ) } } -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()); +fn find_org<'a>(orgs: &'a [AvailableOrg], identifier: &str) -> Option<&'a AvailableOrg> { + orgs.iter() + .find(|org| org.id == identifier || org.name == identifier) + .or_else(|| { + let lowered = identifier.to_ascii_lowercase(); + orgs.iter() + .find(|org| org.name.to_ascii_lowercase() == lowered) + }) +} - 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)?) - } +fn select_instance( + instances: &[AvailableInstance], + current_app_url: Option<&str>, +) -> Result { + match instances { + [] => bail!("no saved auth logins found; run `bt auth login` to create one"), + [instance] => Ok(instance.clone()), + _ if can_prompt() => { + let labels = instances + .iter() + .map(|instance| instance.app_url.as_str()) + .collect::>(); + let default = current_app_url + .and_then(|current| { + instances + .iter() + .position(|instance| config::urls_equal(&instance.app_url, current)) + }) + .unwrap_or(0); + let idx = crate::ui::fuzzy_select("Select Braintrust instance", &labels, default)?; + Ok(instances[idx].clone()) } - 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, - )?, - }; + _ => bail!( + "multiple Braintrust instances are available; pass --app-url or rerun interactively" + ), + } +} - // 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 +fn select_org( + orgs: &[AvailableOrg], + requested: Option<&str>, + current: Option<&str>, +) -> Result { + if let Some(requested) = requested { + return find_org(orgs, requested) + .cloned() + .ok_or_else(|| anyhow::anyhow!("organization '{requested}' is not available")); + } + match orgs { + [] => bail!("no organizations are available for the selected Braintrust instance"), + [org] => Ok(org.clone()), + _ if can_prompt() => { + let labels = orgs.iter().map(|org| org.name.as_str()).collect::>(); + let default = current + .and_then(|current| { + orgs.iter() + .position(|org| org.id == current || org.name == current) + }) + .unwrap_or(0); + let idx = crate::ui::fuzzy_select("Select organization", &labels, default)?; + Ok(orgs[idx].clone()) } - }; + _ => bail!("organization selection requires an interactive terminal; pass --org "), + } +} + +pub(crate) async fn select_context( + base: &BaseArgs, + requested_org: Option<&str>, + requested_project: Option<&str>, + current_cfg: &config::Config, + project_prompt: Option<&str>, +) -> Result<( + AvailableInstance, + AvailableOrg, + crate::projects::api::Project, +)> { + let instances = auth::available_instances(base)?; + let instance = select_instance(&instances, current_cfg.app_url.as_deref())?; + let orgs = auth::available_orgs_for_instance(base, &instance.app_url).await?; + let same_current_instance = config::urls_equal( + current_cfg.app_url.as_deref().unwrap_or(DEFAULT_APP_URL), + &instance.app_url, + ); + let current_org = same_current_instance + .then(|| current_cfg.org_id.as_deref().or(current_cfg.org.as_deref())) + .flatten(); + let org = select_org(&orgs, requested_org, current_org)?; + + let explicit_api_url = matches!( + base.api_url_source, + Some(ArgValueSource::CommandLine | ArgValueSource::EnvVariable) + ) + .then(|| base.api_url.clone()) + .flatten(); + let current_api_url = same_current_instance + .then(|| current_cfg.api_url.clone()) + .flatten(); + let api_url = explicit_api_url + .or_else(|| org.api_url.clone()) + .or(current_api_url) + .unwrap_or_else(|| DEFAULT_API_URL.to_string()); + + let mut login_base = base.clone(); + login_base.app_url = Some(instance.app_url.clone()); + login_base.api_url = Some(api_url); + login_base.org_name = Some(org.name.clone()); + login_base.org_id = Some(org.id.clone()); + login_base.project = None; + login_base.project_source = None; let ctx = login(&login_base).await?; let client = ApiClient::new(&ctx)?; - let org_name = client.org_name().to_string(); - - let project = match resolved_project { - Some(p) => validate_or_create_project(&client, &p).await?, - None => { - if !is_interactive() { - bail!("target required. Use: bt switch or bt switch /"); - } - interactive = true; - select_project( - &client, - None, - None, - crate::ui::ProjectSelectMode::ExistingOnly, - ) - .await? - } - }; + let current_project = (same_current_instance + && current_cfg.org_id.as_deref() == Some(org.id.as_str())) + .then_some(current_cfg.project.as_deref()) + .flatten(); + let project = + select_or_create_project(&client, requested_project, current_project, project_prompt) + .await?; + + Ok((instance, org, project)) +} - let (path, scope) = if args.local { - ( - config::local_path().ok_or_else(|| { - anyhow::anyhow!( - "No local .bt directory found. Use bt init to initialize this directory." - ) - })?, - "local", - ) - } else if args.global { - (config::global_path()?, "global") - } else if interactive && config::local_path().is_some() { - select_scope()? +pub async fn run(base: BaseArgs, args: SwitchArgs) -> Result<()> { + args.scope.preflight(can_prompt())?; + let current_cfg = if args.scope.global { + config::load_global().unwrap_or_default() } else { - (config::global_path()?, "global") + config::load().unwrap_or_default() }; + let (requested_org, requested_project) = args.resolve_target(&base); + let (instance, org, project) = select_context( + &base, + requested_org.as_deref(), + requested_project.as_deref(), + ¤t_cfg, + None, + ) + .await?; + let api_url = if matches!( + base.api_url_source, + Some(ArgValueSource::CommandLine | ArgValueSource::EnvVariable) + ) { + base.api_url.clone() + } else { + org.api_url.clone().or_else(|| { + config::urls_equal( + current_cfg.app_url.as_deref().unwrap_or(DEFAULT_APP_URL), + &instance.app_url, + ) + .then(|| current_cfg.api_url.clone()) + .flatten() + }) + } + .unwrap_or_else(|| DEFAULT_API_URL.to_string()); + let (path, scope) = args.scope.resolve(can_prompt(), "Save to")?; 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), + cfg.set_context( + (org.name.as_str(), org.id.as_str()), + Some((project.name.as_str(), project.id.as_str())), + &instance.app_url, + &api_url, ); config::save_file(&path, &cfg) - .context(format!("Could not save config to {}", path.display()))?; + .with_context(|| format!("Could not save config to {}", path.display()))?; if base.json { let payload = serde_json::json!({ - "org": org_name, + "org": org.name, + "org_id": org.id, "project": project.name, "project_id": project.id, - "profile": config_profile, + "app_url": instance.app_url, + "api_url": api_url, "scope": scope, "path": path.display().to_string(), }); @@ -172,8 +235,10 @@ pub async fn run(base: BaseArgs, args: SwitchArgs) -> Result<()> { return Ok(()); } - let display = format!("{org_name}/{}", project.name); - print_command_status(CommandStatus::Success, &format!("Switched to {display}")); + print_command_status( + CommandStatus::Success, + &format!("Switched to {}/{}", org.name, project.name), + ); if base.verbose { eprintln!("Wrote to {}", path.display()); } @@ -181,461 +246,30 @@ pub async fn run(base: BaseArgs, args: SwitchArgs) -> Result<()> { Ok(()) } -pub(crate) fn select_scope() -> Result<(std::path::PathBuf, &'static str)> { - let global = config::global_path()?; - let local = config::local_path().unwrap(); - let options = [ - format!( - "Global ({})", - console::style( - dirs::home_dir() - .and_then(|home| global - .parent() - .unwrap() - .strip_prefix(&home) - .ok() - .map(|rel| format!("~/{}", rel.display()))) - .unwrap_or_else(|| global.parent().unwrap().display().to_string()) - ) - .dim() - ), - format!( - "Local ({})", - console::style( - local - .parent() - .and_then(|bt| { - let bt_name = bt.file_name()?; - let parent_name = bt.parent()?.file_name()?; - Some(format!( - "{}/{}", - parent_name.to_string_lossy(), - bt_name.to_string_lossy() - )) - }) - .unwrap_or_else(|| local.parent().unwrap().display().to_string()), - ) - .dim() - ), - ]; - let idx = Select::with_theme(&ColorfulTheme::default()) - .with_prompt("Save to") - .items(&options) - .default(1) - .interact()?; - if idx == 0 { - Ok((global, "global")) - } else { - Ok((local, "local")) - } -} - -pub(crate) async fn validate_or_create_project( - client: &ApiClient, - name: &str, -) -> Result { - let exists = with_spinner("Loading project...", api::get_project_by_name(client, name)).await?; - - if let Some(project) = exists { - return Ok(project); - } - - if !is_interactive() { - bail!("project '{name}' not found"); - } - - let create = dialoguer::Confirm::new() - .with_prompt(format!("Project '{name}' not found. Create it?")) - .default(false) - .interact()?; - - if create { - with_spinner("Creating project...", api::create_project(client, name)).await - } else { - bail!("project '{name}' not found"); - } -} - -pub(crate) fn apply_switch_config( - cfg: &mut config::Config, - 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.org = config::trimmed_option(org_name).map(str::to_string); - match project { - Some(project) => { - cfg.project = Some(project.name.clone()); - cfg.project_id = Some(project.id.clone()); - } - None => { - cfg.project = None; - cfg.project_id = None; - } - } -} - -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, - local: false, - target: target.map(String::from), - } - } - - fn base_args(org: Option<&str>, project: Option<&str>) -> BaseArgs { - BaseArgs { - json: false, - verbose: false, - verbose_source: None, - quiet: false, - quiet_source: None, - 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, - api_url: None, - app_url: None, - ca_cert: None, - env_file: None, - } - } - - fn profile_info(name: &str, org_name: Option<&str>) -> ProfileInfo { - ProfileInfo { - name: name.to_string(), - org_name: org_name.map(String::from), - user_name: None, - email: None, - api_key_hint: None, - } - } - - // --- resolve_target tests (unchanged) --- - - #[test] - fn no_args_returns_none() { - let args = switch_args(None); - let base = base_args(None, None); - assert_eq!(args.resolve_target(&base), (None, None)); - } - - #[test] - fn positional_org_project() { - let args = switch_args(Some("myorg/proj")); - let base = base_args(None, None); - assert_eq!( - args.resolve_target(&base), - (Some("myorg".into()), Some("proj".into())) - ); - } - - #[test] - fn positional_project_only() { - let args = switch_args(Some("proj")); - let base = base_args(None, None); - assert_eq!(args.resolve_target(&base), (None, Some("proj".into()))); - } - - #[test] - fn slash_with_empty_org() { - let args = switch_args(Some("/project")); - let base = base_args(None, None); - assert_eq!(args.resolve_target(&base), (None, Some("project".into()))); - } - - #[test] - fn slash_with_empty_project() { - let args = switch_args(Some("org/")); - let base = base_args(None, None); - assert_eq!(args.resolve_target(&base), (Some("org".into()), None)); - } - - #[test] - fn flag_org_only() { - let args = switch_args(None); - let base = base_args(Some("x"), None); - assert_eq!(args.resolve_target(&base), (Some("x".into()), None)); - } - - #[test] - fn flag_project_only() { - let args = switch_args(None); - let base = base_args(None, Some("y")); - assert_eq!(args.resolve_target(&base), (None, Some("y".into()))); - } - - #[test] - fn flags_only() { - let args = switch_args(None); - let base = base_args(Some("a"), Some("b")); - assert_eq!( - args.resolve_target(&base), - (Some("a".into()), Some("b".into())) - ); - } - - #[test] - fn flag_overrides_positional_project() { - let args = switch_args(Some("myorg/proj")); - let base = base_args(None, Some("foo")); - assert_eq!( - args.resolve_target(&base), - (Some("myorg".into()), Some("foo".into())) - ); - } - - #[test] - fn flag_org_with_positional_project() { - let args = switch_args(Some("proj")); - let base = base_args(Some("bar"), None); - assert_eq!( - args.resolve_target(&base), - (Some("bar".into()), Some("proj".into())) - ); - } - - #[test] - fn flag_override_both() { - let args = switch_args(Some("myorg/proj")); - let base = base_args(Some("x"), Some("y")); - assert_eq!( - args.resolve_target(&base), - (Some("x".into()), Some("y".into())) - ); - } - - // --- 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")) + fn resolve_target_combines_positionals_and_flags() { + let args = SwitchArgs { + scope: config::ScopeArgs::default(), + target: Some("test-org/test-project".to_string()), }; - 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(); - let project = api::Project { - id: "proj_123".to_string(), - name: "my-project".to_string(), - org_id: "org_123".to_string(), - description: None, - }; - - apply_switch_config(&mut cfg, Some("work"), Some("acme-org"), Some(&project)); - - assert_eq!(cfg.profile.as_deref(), Some("work")); - 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() { - 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.as_deref(), Some("work")); - assert_eq!(cfg.project.as_deref(), Some("next-project")); + let base = BaseArgs::default(); + let actual = args.resolve_target(&base); + assert_eq!(actual.0.as_deref(), Some("test-org")); + assert_eq!(actual.1.as_deref(), Some("test-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); - - assert_eq!(cfg.profile.as_deref(), Some("next")); - 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); + fn find_org_matches_name_id_and_case() { + let orgs = vec![AvailableOrg { + id: "org_test".to_string(), + name: "test-org".to_string(), + api_url: None, + }]; + assert!(find_org(&orgs, "org_test").is_some()); + assert!(find_org(&orgs, "TEST-ORG").is_some()); } } diff --git a/src/traces.rs b/src/traces.rs index 6ef7ff1c..c5250124 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, ); @@ -5260,7 +5260,7 @@ fn logs_hints( has_more: bool, object_ref: &str, next_cursor: Option<&str>, - profile: Option<&str>, + org: Option<&str>, limit: usize, ) -> Vec { let mut hints = vec![ @@ -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(org) )); } else { hints.push(format!( @@ -5293,13 +5293,13 @@ fn trace_hints( object_ref: &str, trace_id: &str, next_cursor: Option<&str>, - profile: Option<&str>, + org: Option<&str>, limit: usize, ) -> Vec { 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(org) ), "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(org) )); } else { hints.push(format!( @@ -5337,21 +5337,21 @@ fn detail_view_trace_url_value(view: DetailView) -> Option<&'static str> { } } -fn thread_hints(profile: Option<&str>) -> Vec { +fn thread_hints(org: Option<&str>) -> Vec { vec![ format!( "Open an interactive thread view with `bt view thread{} --trace-id `.", - profile_flag_suffix(profile) + org_flag_suffix(org) ), "Use `--non-interactive` for a compact text transcript.".to_string(), ] } -fn waterfall_hints(profile: Option<&str>, has_more: bool) -> Vec { +fn waterfall_hints(org: 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(org) ), "Use `--json` for computed offsets, token counts, costs, cache metrics, and raw ids." .to_string(), @@ -5369,7 +5369,7 @@ fn print_logs_text( rows: &[TraceSummaryRow], list_mode: ListMode, object_ref: &str, - profile: Option<&str>, + org: Option<&str>, limit: usize, preview_length: usize, next_cursor: Option<&str>, @@ -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(org) ); } else { println!("No additional rows."); @@ -5513,7 +5513,7 @@ fn print_thread_text( target: &ResolvedTraceCommandTarget, messages: &[Value], summary: &ThreadSummary, - profile: Option<&str>, + org: Option<&str>, ) { println!( "bt view thread: project={} root_span_id={} messages={}", @@ -5552,7 +5552,7 @@ fn print_thread_text( println!( "\njson: bt view thread --json{} --trace-id {}", - profile_flag_suffix(profile), + org_flag_suffix(org), target.root_span_id ); } @@ -5561,7 +5561,7 @@ fn print_trace_text( trace_id: &str, rows: &[Map], object_ref: &str, - profile: Option<&str>, + org: Option<&str>, limit: usize, preview_length: usize, next_cursor: Option<&str>, @@ -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(org), 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(org), object_ref, trace_id, cursor @@ -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 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(), } } @@ -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,20 @@ 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()); + let has_org_override = base.org_name_source.is_some(); - 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.org_id = None; } base } +#[cfg(test)] +fn apply_url_hints_for_test(base: BaseArgs, parsed_url: Option<&ParsedTraceUrl>) -> BaseArgs { + apply_url_hints_to_base(base, parsed_url) +} + fn select_startup_url( long_url: Option<&str>, positional_url: Option<&str>, @@ -6720,26 +6698,7 @@ mod tests { use serde_json::json; fn base_args() -> BaseArgs { - BaseArgs { - json: false, - verbose: false, - verbose_source: None, - quiet: false, - quiet_source: None, - 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, - api_url: None, - app_url: None, - ca_cert: None, - env_file: None, - } + BaseArgs::default() } fn parsed_url_with_org(org: &str) -> ParsedTraceUrl { @@ -6963,30 +6922,28 @@ mod tests { } #[test] - fn apply_url_hints_infers_profile_from_url_org() { - let base = base_args(); + fn apply_url_hints_override_config_org_and_clear_its_id() { + let mut base = base_args(); + base.org_name = Some("config-org".to_string()); + base.org_id = Some("org_config".to_string()); 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.as_deref(), Some("lovable-profile")); + assert_eq!(updated.org_id, 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()); + base.org_name_source = Some(crate::args::ArgValueSource::CommandLine); 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.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/traces/waterfall.rs b/src/traces/waterfall.rs index fbdf5fc7..374556f8 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, }; @@ -140,7 +140,7 @@ pub(super) fn build_waterfall_view( pub(super) fn print_waterfall_text( target: &ResolvedTraceCommandTarget, waterfall: &WaterfallView, - profile: Option<&str>, + org: Option<&str>, limit: usize, has_more: bool, ) { @@ -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(org), 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(org), target.root_span_id ); } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 3f51e64d..3b0f28b4 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -56,6 +56,7 @@ pub use ratatui_table::{ box_with_title, render_experiment_summary_table, summary_metric_unit, SummaryExperimentColumn, SummaryMetricCell, SummaryMetricKind, SummaryMetricRow, SummaryTableOptions, }; +pub(crate) use select::select_or_create_project; pub use select::{fuzzy_select, select_project, ProjectSelectMode}; pub use spinner::{with_spinner, with_spinner_visible}; diff --git a/src/ui/select.rs b/src/ui/select.rs index bcfd02e2..1e0bcebe 100644 --- a/src/ui/select.rs +++ b/src/ui/select.rs @@ -2,7 +2,7 @@ use std::io::IsTerminal; use anyhow::{bail, Result}; use dialoguer::console::{style, Key, Term}; -use dialoguer::{theme::ColorfulTheme, FuzzySelect, Input}; +use dialoguer::{theme::ColorfulTheme, Confirm, FuzzySelect, Input}; use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; const MAX_VISIBLE_ITEMS: usize = 12; @@ -246,6 +246,41 @@ fn fuzzy_select_with_pinned_first( } } +/// Resolve an explicitly named project, optionally creating it, or select an existing one. +pub(crate) async fn select_or_create_project( + client: &ApiClient, + requested: Option<&str>, + current: Option<&str>, + select_label: Option<&str>, +) -> Result { + let Some(name) = requested else { + return select_project( + client, + current, + select_label, + ProjectSelectMode::ExistingOnly, + ) + .await; + }; + if let Some(project) = + with_spinner("Loading project...", api::get_project_by_name(client, name)).await? + { + return Ok(project); + } + let Some(term) = super::prompt_term() else { + bail!("project '{name}' not found"); + }; + if Confirm::new() + .with_prompt(format!("Project '{name}' not found. Create it?")) + .default(false) + .interact_on(&term)? + { + with_spinner("Creating project...", api::create_project(client, name)).await + } else { + bail!("project '{name}' not found") + } +} + /// Interactive selector for project data. pub async fn select_project( client: &ApiClient, @@ -258,6 +293,20 @@ pub async fn select_project( let label = select_label.unwrap_or("Select project"); + if mode == ProjectSelectMode::ExistingOnly { + match projects.len() { + 0 => bail!("organization '{}' has no projects", client.org_name()), + 1 => return Ok(projects.remove(0)), + _ if !super::can_prompt() => { + bail!( + "organization '{}' has multiple projects; pass --project ", + client.org_name() + ) + } + _ => {} + } + } + if mode_allows_create(mode) { let names = project_display_names(&projects, mode); let default_sel = default_project_selection(&projects, current, mode)?; diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 05429ee0..6c945ed9 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -6,6 +6,7 @@ mod ids; mod json_object; mod plurals; mod profile; +mod shell; pub(crate) use app_url::{app_project_url, app_project_url_with_encoded_path}; pub use duration::parse_duration_to_seconds; @@ -14,4 +15,5 @@ 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}; +pub(crate) use shell::quote_arg as shell_quote_arg; diff --git a/src/utils/profile.rs b/src/utils/profile.rs index b97812b2..ebd80bb3 100644 --- a/src/utils/profile.rs +++ b/src/utils/profile.rs @@ -1,60 +1,12 @@ -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) -} - -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); - } - return None; - } - - if profiles.len() == 1 { - return profiles.into_iter().next(); - } - - None -} +use crate::auth::ProfileInfo; +/// Slug for the authenticated human (name, else email local part). Org is +/// deliberately not a fallback: a bare API key has no author, so callers +/// substitute a generic placeholder rather than name the snapshot after the org. 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()), ] .into_iter() .flatten() @@ -98,13 +50,17 @@ 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 { + "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), @@ -112,36 +68,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") @@ -150,17 +79,21 @@ 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_falls_back_to_profile_name() { - let profile = profile_info("Work Profile", None, None, None); - assert_eq!( - profile_author_slug(&profile).as_deref(), - Some("work-profile") - ); + fn profile_author_slug_returns_none_without_identity() { + let profile = profile_info(None, None, None); + assert_eq!(profile_author_slug(&profile), None); + } + + #[test] + fn profile_author_slug_ignores_org_name() { + // A bare API key has an org but no human identity — not an author. + let profile = profile_info(Some("test-org"), None, None); + assert_eq!(profile_author_slug(&profile), None); } #[test] diff --git a/src/utils/shell.rs b/src/utils/shell.rs new file mode 100644 index 00000000..5d4f0a2e --- /dev/null +++ b/src/utils/shell.rs @@ -0,0 +1,10 @@ +pub(crate) fn quote_arg(value: &str) -> String { + if value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/' | ':' | '=')) + { + value.to_string() + } else { + format!("'{}'", value.replace('\'', "'\\''")) + } +} diff --git a/tests/cli.rs b/tests/cli.rs index acb09bfd..084add7c 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", ] { @@ -50,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(); @@ -113,13 +121,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() @@ -137,12 +139,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"); @@ -152,11 +154,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()) @@ -167,7 +167,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":"default-profile""#)) .stdout(predicate::str::contains(r#""org":"profile-org""#).not()); } @@ -459,12 +458,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 +480,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/eval_dev_server.rs b/tests/eval_dev_server.rs index 227221d7..3a5095f9 100644 --- a/tests/eval_dev_server.rs +++ b/tests/eval_dev_server.rs @@ -71,7 +71,7 @@ fn start_mock_auth_server() -> (u16, thread::JoinHandle<()>) { .expect("set mock listener blocking"); let handle = thread::spawn(move || { - let response_body = r#"{"org_info": [{"name": "test-org"}]}"#; + let response_body = r#"{"org_info": [{"id": "org_test", "name": "test-org"}]}"#; let http_response = format!( "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", response_body.len(), diff --git a/tests/functions.rs b/tests/functions.rs index 03f70300..af0e49dd 100644 --- a/tests/functions.rs +++ b/tests/functions.rs @@ -190,10 +190,6 @@ fn sanitized_env_keys() -> &'static [&'static str] { ] } -fn auth_profiles_command(cwd: &Path, config_dir: &Path) -> Command { - auth_sub_command(cwd, config_dir, &["profiles"]) -} - #[derive(Debug, Clone)] struct MockProject { id: String, @@ -669,15 +665,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"); @@ -701,80 +699,50 @@ fn functions_help_lists_push_and_pull() { assert!(stdout.contains("pull")); } -#[test] -fn auth_profiles_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()) - .env("BRAINTRUST_API_KEY", "test-key") - .output() - .expect("run bt auth profiles with api key env"); - - 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("Auth source: --api-key/BRAINTRUST_API_KEY override")); - assert!(!stderr.contains("pass --prefer-profile or unset BRAINTRUST_API_KEY")); -} - -#[test] -fn auth_profiles_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()) - .env_remove("BRAINTRUST_API_KEY") - .output() - .expect("run bt auth profiles with dotenv api key"); - - 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("Auth source: --api-key/BRAINTRUST_API_KEY override")); - assert!(!stderr.contains("pass --prefer-profile or unset BRAINTRUST_API_KEY")); -} - -#[test] -fn auth_profiles_json_with_no_profiles_emits_empty_array() { +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn status_api_key_uses_urls_but_not_context_from_config() { + let server = MockServer::start(Arc::new(MockServerState::default())).await; let cwd = tempdir().expect("create temp cwd"); let config_dir = tempdir().expect("create temp config dir"); + let bt_dir = config_dir.path().join("bt"); + fs::create_dir_all(&bt_dir).expect("create bt config dir"); + fs::write( + bt_dir.join("config.json"), + serde_json::json!({ + "org": "stale-org", + "project": "stale-project", + "app_url": server.base_url.clone(), + "api_url": server.base_url.clone(), + }) + .to_string(), + ) + .expect("write active config"); - let output = auth_profiles_command(cwd.path(), config_dir.path()) - .arg("--json") + let output = Command::new(bt_binary_path()) + .args(["status", "--api-key", "test-key", "--json"]) + .current_dir(cwd.path()) + .env("XDG_CONFIG_HOME", config_dir.path()) + .env("APPDATA", config_dir.path()) + .env_remove("BRAINTRUST_ORG_NAME") + .env_remove("BRAINTRUST_DEFAULT_PROJECT") + .env_remove("BRAINTRUST_APP_URL") + .env_remove("BRAINTRUST_API_URL") .output() - .expect("run bt auth profiles --json"); + .expect("run bt status"); + server.stop().await; assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - assert_eq!(stdout, "[]"); + let status: Value = serde_json::from_slice(&output.stdout).expect("parse status JSON"); + assert_eq!(status["org"], "test-org"); + assert_eq!(status["org_id"], "org_mock"); + assert!(status["project"].is_null()); + assert!(status["project_id"].is_null()); + assert_eq!(status["auth_method"], "api_key"); + assert_eq!(status["source"], "cli"); } -#[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( @@ -786,7 +754,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 and environment scrubbing. let mut cmd = Command::new(bt_binary_path()); cmd.arg("auth") .args(sub) @@ -794,7 +762,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 +784,58 @@ 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_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_logout_bare_does_not_default_to_current_login() { + let cwd = tempdir().expect("create temp cwd"); + let config_dir = tempdir().expect("create temp config dir"); + let bt_config_dir = config_dir.path().join("bt"); + fs::create_dir_all(&bt_config_dir).expect("create bt config dir"); + fs::write(bt_config_dir.join("config.json"), r#"{"org":"test-org-a"}"#) + .expect("write active config"); + fs::write( + bt_config_dir.join("auth.json"), + r#"{"profiles":{"test-profile-a":{"auth_kind":"api_key","org_id":"org_test_a","org_name":"test-org-a","api_key_hint":"sk-****aaaaa"},"test-profile-b":{"auth_kind":"api_key","org_id":"org_test_b","org_name":"test-org-b","api_key_hint":"sk-****bbbbb"}}}"#, + ) + .expect("write auth logins"); + + let output = auth_sub_command( + cwd.path(), + config_dir.path(), + &["logout", "--no-input", "--force"], + ) + .output() + .expect("run bare non-interactive auth logout"); + + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("multiple auth logins match")); + let remaining = + fs::read_to_string(bt_config_dir.join("auth.json")).expect("read remaining auth logins"); + assert!(remaining.contains("test-org-a")); + assert!(remaining.contains("test-org-b")); +} + +#[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 + // 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 +844,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 +1893,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 +2081,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 +2214,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 +2360,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 +2422,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 +2496,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 +2579,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 +2669,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 +2787,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 +2866,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");