diff --git a/README.md b/README.md index cfa19e70..f7bbc605 100644 --- a/README.md +++ b/README.md @@ -312,53 +312,77 @@ Local version and pagination-key conversion helpers: ## `bt auth` -- Authenticate interactively (prompts for auth method, profile name defaults to org name): +- Authenticate interactively (prompts for auth method and organization): - `bt auth login` - First prompt chooses: `OAuth (browser)` (default) or `API key`. - - If your API key can access multiple orgs, `bt` uses a searchable picker (alphabetized) and lets you choose a specific org or no default org (cross-org mode). - - After login, `bt` updates the active profile/org context immediately. If `--project` is set, it also switches that project; otherwise it clears any stale default project for the new login. + - OAuth can be saved for an org or in cross-org mode. API-key logins are saved per org; if a key can access multiple orgs, `bt` uses a searchable org picker. + - After login, `bt` updates the active org context immediately. If `--project` is set, it validates and saves that project's name and ID. Without `--project`, a same-org login preserves the existing project; changing orgs clears stale project context. + - Use `--global` or `--local` to choose the config scope. Without either flag, an existing local config causes an interactive scope picker (default: local); non-interactive runs must pass a scope. `--local` never creates `.bt`. - `bt` confirms the resolved API URL before saving. - Login with OAuth (browser-based, stores refresh token in secure credential store): - - `bt auth login --oauth --profile work` + - `bt auth login --oauth --org myorg` - You can pass `--no-browser` to print the URL without auto-opening. - On remote/SSH hosts, paste the final callback URL from your local browser if localhost callback cannot be delivered. -- List profiles: - - `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` +- List saved auth logins: + - `bt auth logins` + - `bt auth logins --org test-org` (matches stored org name or ID) + - `bt auth logins --prefer-api-key` (API-key logins only) + - Both filters can be combined. +- Log out: + - `bt auth logout` — choose from all saved logins interactively + - `bt auth logout --org test-org --oauth` — filter to the org's OAuth login + - `bt auth logout --org test-org --api-key-hint sk-****abcde` — select an API-key login + - `bt auth logout --force` (skip confirmation after selecting a login) +- Show current auth context: + - `bt status` - Force-refresh OAuth access token for debugging: - - `bt auth refresh --profile work` + - `bt auth refresh --org myorg` Auth resolution order for commands is: -1. Explicit `--profile` -2. `--api-key` or `BRAINTRUST_API_KEY` (unless `--prefer-profile` is set) -3. `BRAINTRUST_PROFILE` -4. Org-based profile match (profile whose org matches `--org`/config org) -5. Single-profile auto-select (if only one profile exists) -6. Interactive profile picker (if multiple profiles exist and a TTY is available) +1. Explicit `--api-key sk-...` +2. `--prefer-api-key` / `BRAINTRUST_PREFER_API_KEY` (uses `BRAINTRUST_API_KEY` first, then a stored API key for the selected org, then falls back to OAuth) +3. Stored OAuth login for the selected org (or cross-org OAuth when selected) +4. `BRAINTRUST_API_KEY` +5. Stored API key login for the selected org + +`--prefer-api-key` without `--org` targets the org shown by `bt status`. It cannot be used from cross-org context; pass a concrete `--org`. Once a key is selected, an invalid key or a key belonging to another requested org is an error and does not fall back to OAuth. Multiple keys in one org remain separate and are shown with key hints. 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. +## `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 `org`, `project`, and `project_id`. Cross-org is excluded from the init picker because init always requires a project. + ## `bt switch` Interactively switch org and project context: -- `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` — first choose a saved OAuth-backed org or a specific API-key login, then choose a project +- `bt switch myproject` — switch the current org to a project by name +- `bt switch test-org/test-project` — switch to a specific org and project +- `bt switch --org cross-org` — select cross-org OAuth and clear project context - `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 login/project is selected automatically. Multiple API keys in one org remain separate picker entries with hints; OAuth accounts collapse to an org choice, so run `bt auth login` again to change OAuth accounts within that org. 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`. Local context wins, but a local org inherits the global project only when both configs select the same org. A local project with no org never inherits a global org. Cross-org is stored as `"org": ""`, treated as a distinct org, and rendered as `cross-org` by `bt status`. Legacy `profile` fields are ignored; unknown extra keys are preserved when context is updated. ## `bt status` Show current org and project context: -- `bt status` — display current org, project, and config source -- `bt status --verbose` — show detailed config resolution +- `bt status` — display current org, project, selected auth method, and config source +- `bt status --verbose` — show detailed config and auth resolution - `bt status -j` — JSON output ## `bt setup` and `bt docs` diff --git a/src/args.rs b/src/args.rs index c15c6478..1770da97 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,13 @@ pub struct BaseArgs { #[arg(long, env = "BRAINTRUST_NO_INPUT", global = true, value_parser = clap::builder::BoolishValueParser::new(), default_value_t = false)] pub no_input: bool, - /// 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, + /// Override active project #[arg( short = 'p', @@ -60,6 +55,9 @@ pub struct BaseArgs { )] pub project: Option, + #[arg(skip)] + pub project_source: Option, + /// Override stored API key (or via BRAINTRUST_API_KEY) #[arg(long, env = "BRAINTRUST_API_KEY", global = true, hide = true)] pub api_key: Option, @@ -67,9 +65,13 @@ 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, + /// Exact auth slot selected internally by switch/init. + #[arg(skip)] + pub pinned_auth_slot: Option, + + /// 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( @@ -117,6 +119,19 @@ pub struct CLIArgs { pub base: BaseArgs, } +fn parse_org_name(value: &str) -> Result { + Ok(crate::config::normalize_org(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 +142,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; + use super::{custom_api_without_app_url, parse_org_name, DEFAULT_API_URL}; #[test] - fn has_explicit_profile_arg_detects_split_flag() { - let args = vec![ - OsString::from("bt"), - OsString::from("status"), - OsString::from("--profile"), - OsString::from("work"), - ]; - assert!(has_explicit_profile_arg(&args)); - } - - #[test] - fn has_explicit_profile_arg_detects_equals_flag() { - let args = vec![ - OsString::from("bt"), - OsString::from("status"), - OsString::from("--profile=work"), - ]; - assert!(has_explicit_profile_arg(&args)); - } - - #[test] - fn has_explicit_profile_arg_ignores_passthrough_args() { - let args = vec![ - OsString::from("bt"), - OsString::from("eval"), - OsString::from("--"), - OsString::from("--profile"), - OsString::from("work"), - ]; - assert!(!has_explicit_profile_arg(&args)); + fn org_normalization() { + for (input, expected) in [ + ("cross-org", ""), + (" ", ""), + (" test-org ", "test-org"), + (" org_test_123 ", "org_test_123"), + ] { + assert_eq!(parse_org_name(input).unwrap(), expected); + } + 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 44db3d77..a300a550 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,7 +1,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::error::Error as StdError; use std::fs; -use std::io::{IsTerminal, Write}; +use std::io::Write; use std::net::TcpListener; use std::path::{Path, PathBuf}; use std::process::Command; @@ -25,6 +25,7 @@ use oauth2::{ }; use serde::{Deserialize, Serialize}; use serde_json::json; +use sha2::{Digest, Sha256}; use tokio::sync::oneshot; use crate::{ @@ -32,10 +33,12 @@ use crate::{ config, http::{build_http_client, build_http_client_from_builder, ApiClient}, projects::api, - switch, ui, + 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; @@ -57,24 +60,18 @@ pub struct ResolvedAuth { pub app_url: Option, pub org_name: 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 AvailableOrg { pub id: String, @@ -84,8 +81,6 @@ pub struct AvailableOrg { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RecoverableAuthErrorKind { - OauthProfileSelection, - OauthClientId, OauthRefreshToken, StoredCredential, } @@ -115,9 +110,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 ) }) @@ -128,110 +121,11 @@ 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(), - }) - .collect()) -} - -pub(crate) fn list_stored_profiles() -> 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(), - }) + .values() + .map(profile_info_from_store_entry) .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())) -} - pub async fn list_available_orgs(base: &BaseArgs) -> Result> { let resolved = resolve_auth(base).await?; let app_url = resolved @@ -246,36 +140,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)) - }); - - Ok(orgs - .into_iter() - .map(|org| AvailableOrg { - id: org.id, - name: org.name, - api_url: org.api_url, - }) - .collect()) + available_orgs(&api_key, &app_url).await } pub(crate) async fn list_available_orgs_for_api_key( api_key: &str, app_url: &str, ) -> Result> { - 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 +} +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 { @@ -286,7 +163,7 @@ pub(crate) async fn list_available_orgs_for_api_key( .collect()) } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] struct AuthStore { #[serde(default)] profiles: BTreeMap, @@ -298,7 +175,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, @@ -307,9 +184,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)] @@ -317,10 +194,14 @@ struct AuthProfile { #[serde(default)] email: Option, #[serde(default)] + api_key_hash: Option, + #[serde(default)] api_key_hint: Option, + #[serde(default)] + legacy_secret_key: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] enum AuthKind { #[default] @@ -328,6 +209,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, @@ -375,10 +263,12 @@ struct OAuthErrorResponse { #[derive(Debug, Clone, Args)] #[command(after_help = "\ Examples: - bt auth login - bt auth profiles - bt auth refresh - bt auth logout --profile work + bt auth login --global + bt auth login --oauth --org test-org --local + bt auth logins --org test-org --prefer-api-key + bt auth refresh --org test-org + bt auth logout + bt auth logout --org test-org --oauth ")] pub struct AuthArgs { #[command(subcommand)] @@ -389,20 +279,16 @@ pub struct AuthArgs { enum AuthCommand { /// Authenticate with Braintrust (OAuth or API key) Login(AuthLoginArgs), - /// Force-refresh OAuth access token for a profile + /// Force-refresh OAuth access token for the selected org Refresh, - /// List auth profiles and check connection status - Profiles(AuthProfilesArgs), - /// Log out by removing a saved profile + /// List saved auth logins and check connection status + Logins(AuthLoginsArgs), + /// Log out by removing a saved auth login Logout(AuthLogoutArgs), } #[derive(Debug, Clone, Args)] -struct AuthProfilesArgs { - /// Only show the profile with this name - #[arg(long, value_name = "NAME")] - profile: Option, -} +struct AuthLoginsArgs {} #[derive(Debug, Clone, Args)] struct AuthLoginArgs { @@ -410,20 +296,23 @@ struct AuthLoginArgs { #[arg(long)] oauth: bool, - /// OAuth client id (defaults to bt_cli_) - #[arg(long, value_name = "CLIENT_ID")] - client_id: Option, - /// Do not try to open a browser automatically #[arg(long)] no_browser: bool, + + #[command(flatten)] + scope: config::ScopeArgs, } #[derive(Debug, Clone, Args)] 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')] @@ -437,9 +326,12 @@ struct PostLoginContextUpdate { pub async fn run(base: BaseArgs, args: AuthArgs) -> Result<()> { match args.command { - AuthCommand::Login(login_args) => run_login_set(&base, login_args).await, + AuthCommand::Login(login_args) => { + login_args.scope.preflight(ui::can_prompt())?; + run_login_set(&base, login_args).await + } AuthCommand::Refresh => run_login_refresh(&base).await, - AuthCommand::Profiles(profile_args) => run_profiles(&base, profile_args).await, + AuthCommand::Logins(logins_args) => run_logins(&base, logins_args).await, AuthCommand::Logout(logout_args) => run_login_logout(base, logout_args), } } @@ -460,7 +352,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!( @@ -494,7 +385,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!( @@ -523,12 +413,18 @@ 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(), @@ -543,9 +439,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()) @@ -797,42 +701,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(); @@ -842,194 +747,714 @@ 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) +fn config_auth_context_from_config(base: &BaseArgs, cfg: &crate::config::Config) -> Option { + if crate::config::org_option(base.org_name.as_deref()).is_none() { + crate::config::org_option(cfg.org.as_deref()).map(str::to_string) } else { None - }; + } +} - 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) - } else { - None - }; +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())) +} + +/// 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); + } - (profile, org) + 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) } pub async fn resolve_auth(base: &BaseArgs) -> Result { let mut store = load_auth_store()?; - let mut auth_base = base.clone(); - let (cfg_profile, cfg_org) = config_auth_context(base); - if let Some(profile) = cfg_profile { - auth_base.profile = Some(profile); - } + let cfg_org = config_auth_context(base); + let can_prompt = ui::can_prompt(); - if let Some(profile_name) = - maybe_select_profile_for_auth(&auth_base, &store, &cfg_org, ui::can_prompt())? - { - auth_base.profile = Some(profile_name); + let effective_org = effective_org_name(base, &cfg_org); + reject_cross_org_api_key_preference(base.prefer_api_key, effective_org, &store)?; + + if let Some(slot) = base.pinned_auth_slot.clone() { + return resolve_saved_auth_slot(base, &mut store, &None, &slot).await; } - let mut auth = resolve_auth_from_store_with_secret_lookup( - &auth_base, - &store, - load_profile_secret, - &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) | 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 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), + 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()) - }) +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_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}" + ) + })?; + resolved_org = Some(selected_org.name.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, + 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(|| { - recoverable_auth_error( - RecoverableAuthErrorKind::OauthProfileSelection, - "oauth profile requested but none selected".to_string(), + anyhow::anyhow!( + "saved auth login not found; run `bt auth logins` to see available logins" ) - })? - .to_string(); + })?; + 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(|| { - recoverable_auth_error( - RecoverableAuthErrorKind::OauthClientId, - format!( - "oauth profile '{profile_name}' is missing client_id; re-run `bt auth login --oauth --profile {profile_name}`" - ), - ) - })?; - 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)? - { - auth.api_key = Some(cached_access_token); - return Ok(auth); + .get(profile_name) + .cloned() + .ok_or_else(|| anyhow::anyhow!("saved auth login not found; run `bt auth logins`"))?; + 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 refresh_token = load_profile_oauth_refresh_token(&profile_name)?.ok_or_else(|| { + let api_key = load_profile_secret_with_legacy( + profile_name, + profile.legacy_secret_key.as_deref(), + )? + .ok_or_else(|| { recoverable_auth_error( - RecoverableAuthErrorKind::OauthRefreshToken, + RecoverableAuthErrorKind::StoredCredential, format!( - "oauth refresh token missing for profile '{profile_name}'; re-run `bt auth login --oauth --profile {profile_name}`" + "no keychain credential found for auth login '{}'; re-run `bt auth login --org --api-key `", + auth_slot_label(&profile) ), ) })?; - let refreshed = - refresh_oauth_access_token(&api_url, &refresh_token, client_id, &profile_name).await?; - save_profile_oauth_access_token(&profile_name, &refreshed.access_token)?; - 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)?; - } - } - if let Some(profile) = store.profiles.get_mut(&profile_name) { - profile.oauth_access_expires_at = determine_oauth_access_expiry_epoch(&refreshed); - } - save_auth_store(&store)?; - auth.api_key = Some(refreshed.access_token); - Ok(auth) + + let resolved = ResolvedAuth { + api_key: Some(api_key.clone()), + api_url: base.api_url.clone().or_else(|| profile.api_url.clone()), + app_url: base.app_url.clone().or_else(|| profile.app_url.clone()), + org_name: effective_org_name(base, cfg_org) + .map(str::to_string) + .or_else(|| profile.org_name.clone()), + is_oauth: false, + slot_key: Some(profile_name.to_string()), + }; + + maybe_rekey_api_key_profile_after_secret_load(store, profile_name, &api_key)?; + Ok(resolved) } -pub async fn resolved_auth_env(base: &BaseArgs) -> Result> { - let auth = resolve_auth(base).await?; - let mut envs = Vec::new(); +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()); + } - if let Some(api_key) = auth.api_key { - envs.push(("BRAINTRUST_API_KEY".to_string(), api_key)); + let unchanged = canonical_key == current_key + && store + .profiles + .get(current_key) + .is_some_and(|existing| existing == &profile); + if unchanged { + return false; } - if let Some(api_url) = auth.api_url { - envs.push(("BRAINTRUST_API_URL".to_string(), api_url)); + + if canonical_key != current_key { + store.profiles.remove(current_key); + if let Some(existing) = store.profiles.get(&canonical_key) { + if !should_replace_migrated_profile(existing, &profile) { + return true; + } + } } - if let Some(app_url) = auth.app_url { - envs.push(("BRAINTRUST_APP_URL".to_string(), app_url)); + 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) + { + return Ok(()); } - if let Some(org_name) = auth.org_name { - envs.push(("BRAINTRUST_ORG_NAME".to_string(), org_name)); + + profile.api_key_hash = Some(api_key_hash(api_key)); + if replace_with_canonical_auth_profile(store, profile_name, profile) { + save_auth_store(store)?; } - Ok(envs) + Ok(()) } -pub async fn resolved_runner_env(base: &BaseArgs) -> Result> { - let mut envs = resolved_auth_env(base).await?; - let project = base +async fn reconcile_oauth_slot_from_access_token( + store: &mut AuthStore, + slot_key: &str, + access_token: &str, + app_url: &str, +) -> Result<()> { + let Some(mut profile) = store.profiles.get(slot_key).cloned() else { + return Ok(()); + }; + if profile.auth_kind != AuthKind::Oauth || profile.org_id.is_some() { + return Ok(()); + } + + let Some(org_name) = profile + .org_name + .as_deref() + .map(str::trim) + .filter(|org| !org.is_empty()) + else { + profile.org_id = Some(String::new()); + if replace_with_canonical_auth_profile(store, slot_key, profile) { + save_auth_store(store)?; + } + return Ok(()); + }; + + // A legacy auth entry only cached the org name. Resolve the stable ID from + // the newly refreshed token; a failed best-effort lookup must not turn a + // successful token refresh into a failed command. + let Ok(orgs) = fetch_login_orgs(access_token, app_url).await else { + return Ok(()); + }; + let Some(org) = find_login_org(&orgs, org_name) else { + return Ok(()); + }; + + profile.org_id = Some(org.id.clone()); + profile.org_name = Some(org.name.clone()); + let identity = decode_jwt_identity(access_token); + if identity.email.is_some() { + profile.email = identity.email; + } + if identity.name.is_some() { + profile.user_name = identity.name; + } + if replace_with_canonical_auth_profile(store, slot_key, profile) { + save_auth_store(store)?; + } + Ok(()) +} + +fn reconcile_resolved_auth_slot(auth: &ResolvedAuth, login: &LoginState) -> Result<()> { + let Some(slot_key) = auth.slot_key.as_deref() else { + return Ok(()); + }; + let mut store = load_auth_store()?; + let Some(mut profile) = store.profiles.get(slot_key).cloned() else { + return Ok(()); + }; + + let login_org_id = login.org_id().unwrap_or_default(); + let is_cross_org = profile.auth_kind == AuthKind::Oauth + && auth + .org_name + .as_deref() + .is_none_or(|org| org.trim().is_empty()); + if login_org_id.trim().is_empty() && !is_cross_org { + // The SDK's OAuth compatibility path does not always return org + // metadata. `bt auth logins` performs the same reconciliation after + // its explicit credential verification request. + return Ok(()); + } + + profile.org_id = Some(login_org_id); + profile.org_name = if is_cross_org { + None + } else { + login + .org_name() + .filter(|org| !org.trim().is_empty()) + .or_else(|| auth.org_name.clone()) + }; + match profile.auth_kind { + AuthKind::ApiKey => { + let Some(api_key) = auth.api_key.as_deref() else { + return Ok(()); + }; + profile.api_key_hash = Some(api_key_hash(api_key)); + if profile.api_key_hint.is_none() { + profile.api_key_hint = Some(obscure_api_key(api_key)); + } + } + AuthKind::Oauth if profile.email.as_deref().is_none_or(str::is_empty) => return Ok(()), + AuthKind::Oauth => {} + } + + if replace_with_canonical_auth_profile(&mut store, slot_key, profile) { + save_auth_store(&store)?; + } + Ok(()) +} + +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 auth logins`"))?; + let api_url = base + .api_url + .clone() + .or_else(|| profile.api_url.clone()) + .unwrap_or_else(|| DEFAULT_API_URL.to_string()); + let app_url = base.app_url.clone().or_else(|| profile.app_url.clone()); + let org_name = effective_org_name(base, cfg_org) + .map(str::to_string) + .or_else(|| profile.org_name.clone()); + + let mut auth = ResolvedAuth { + api_key: None, + api_url: Some(api_url.clone()), + app_url, + org_name, + is_oauth: true, + slot_key: Some(profile_name.to_string()), + }; + + if let Some(cached_access_token) = load_valid_cached_oauth_access_token( + profile_name, + &profile, + profile.oauth_access_expires_at, + )? { + auth.api_key = Some(cached_access_token); + return Ok(auth); + } + + let refresh_token = load_profile_oauth_refresh_token_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 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)?; + refresh_rotated = true; + } + } + 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)?; + reconcile_oauth_slot_from_access_token( + store, + profile_name, + &refreshed.access_token, + auth.app_url.as_deref().unwrap_or(DEFAULT_APP_URL), + ) + .await?; + auth.api_key = Some(refreshed.access_token); + Ok(auth) +} + +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)); + } + if let Some(api_url) = auth.api_url { + envs.push(("BRAINTRUST_API_URL".to_string(), api_url)); + } + if let Some(app_url) = auth.app_url { + envs.push(("BRAINTRUST_APP_URL".to_string(), app_url)); + } + if let Some(org_name) = auth.org_name { + envs.push(("BRAINTRUST_ORG_NAME".to_string(), org_name)); + } + envs +} + +pub async fn resolved_runner_env(base: &BaseArgs) -> Result> { + 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 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 { + config::display_org(profile_org(profile)).to_string() +} + +fn oauth_reauth_command(profile: &AuthProfile) -> String { + format!( + "bt auth login --oauth --org {}", + shell_quote_arg(config::display_org(profile_org(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, + ) +} + +fn auth_slot_label(profile: &AuthProfile) -> String { + let mut parts = vec![profile_org_label(profile)]; + parts.push(auth_kind_label(profile.auth_kind).to_string()); + if let Some(identity) = profile_identity_label(profile) { + parts.push(identity); } + parts.join(" — ") +} - let matches: Vec<&str> = store - .profiles - .iter() - .filter(|(_, p)| p.org_name.as_deref() == Some(org)) - .map(|(name, _)| name.as_str()) - .collect(); +fn is_cross_org_oauth_profile(profile: &AuthProfile) -> bool { + profile.auth_kind == AuthKind::Oauth && profile_org(profile).is_empty() +} - match matches.len() { - 0 => None, - 1 => Some(matches[0]), - _ => None, +fn reject_cross_org_api_key_preference( + prefer_api_key: bool, + org: Option<&str>, + store: &AuthStore, +) -> Result<()> { + let cross_org = org == Some("") + || (org.is_none() && store.profiles.values().any(is_cross_org_oauth_profile)); + if prefer_api_key && cross_org { + bail!("--prefer-api-key cannot be used from cross-org context; rerun with --org "); } + Ok(()) } -fn profile_names_for_org<'a>(org: &str, store: &'a AuthStore) -> Vec<&'a str> { +fn auth_profile_names_by_kind<'a>( + 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)| match org { + Some(org) => profile_matches_org_identifier(profile, org), + None if kind == AuthKind::Oauth => is_cross_org_oauth_profile(profile), + None => false, + }) .map(|(name, _)| name.as_str()) .collect() } +fn profile_info_from_store_entry(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(); + + reject_cross_org_api_key_preference(base.prefer_api_key, org, &store)?; + + let select = |kind| match auth_profile_names_by_kind(&store, org, kind).as_slice() { + [] => Ok(None), + [name] => Ok(Some((*name).to_string())), + _ => bail!("multiple {kind:?} logins"), + }; + + let 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() + .filter(|(_, profile)| { + !is_cross_org_oauth_profile(profile) && !profile_org(profile).is_empty() + }) + .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( @@ -1049,159 +1474,132 @@ 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( - base: &BaseArgs, - store: &AuthStore, - cfg_org: &Option, - can_prompt: bool, -) -> Result> { - if resolve_api_key_override(base).is_some() { - return Ok(None); - } - - let requested_profile = base - .profile - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()); - if requested_profile.is_some() { - return Ok(None); - } - - let effective_org = base.org_name.as_deref().or(cfg_org.as_deref()); - if let Some(org) = effective_org { - if resolve_profile_for_org(org, store).is_some() { - return Ok(None); - } +fn saved_login_names(store: &AuthStore, include_cross_org: bool) -> Vec<&str> { + let mut oauth_orgs = BTreeSet::new(); + store + .profiles + .iter() + .filter(|(_, profile)| { + profile.auth_kind == AuthKind::ApiKey + || ((include_cross_org || !is_cross_org_oauth_profile(profile)) + && oauth_orgs.insert( + profile + .org_id + .as_deref() + .filter(|id| !id.is_empty()) + .unwrap_or_else(|| profile_org(profile)) + .to_ascii_lowercase(), + )) + }) + .map(|(name, _)| name.as_str()) + .collect() +} - let matching_profiles = profile_names_for_org(org, store); - if matching_profiles.is_empty() { - return Ok(None); +pub(crate) fn select_saved_login( + base: &mut BaseArgs, + current_org: Option<&str>, + include_cross_org: bool, +) -> Result { + let store = load_auth_store()?; + let names = saved_login_names(&store, include_cross_org); + let selected = match names.as_slice() { + [] => return Ok(false), + [name] => (*name).to_string(), + _ if ui::can_prompt() => { + select_profile_from_store("Select login", &names, current_org, &store)? } - - if !can_prompt { - bail!( - "multiple profiles for org '{org}': {}. Use --profile to disambiguate.", - matching_profiles.join(", ") - ); + _ => { + bail!("multiple saved logins match; pass --org , or rerun interactively to choose") } - - 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(", ") - ); + }; + let profile = &store.profiles[&selected]; + if profile.auth_kind == AuthKind::ApiKey { + base.pinned_auth_slot = Some(selected); + } else { + base.org_name = Some(profile_org(profile).to_string()); } + Ok(true) +} - select_profile_from_store("Select org", &names, None, store).map(Some) +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 resolve_auth_from_store_with_secret_lookup( +fn select_profile_for_auth( 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 + kind: AuthKind, + can_prompt: bool, +) -> Result> { + let org = effective_org_name(base, cfg_org); + let candidates = auth_profile_names_by_kind(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) +} - if let Some(profile_name) = selected_profile_name { - let profile = store.profiles.get(profile_name).ok_or_else(|| { - anyhow::anyhow!( - "profile '{profile_name}' not found; run `bt auth profiles` or `bt auth login --profile {profile_name}`" - ) - })?; - 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 {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, - }); +fn select_auth_profile_candidate( + kind_label: &str, + org: Option<&str>, + candidates: &[&str], + store: &AuthStore, + can_prompt: bool, +) -> Result> { + match candidates.len() { + 0 => Ok(None), + 1 => Ok(Some(candidates[0].to_string())), + _ if can_prompt => { + let prompt = org + .map(|org| format!("Multiple {kind_label} logins for '{org}'. Select one")) + .unwrap_or_else(|| format!("Select {kind_label} login")); + select_profile_from_store(&prompt, candidates, org, store).map(Some) + } + _ => { + let identities = candidate_identities(candidates, store).join(", "); + if let Some(org) = org { + bail!( + "multiple {kind_label} logins for org '{org}': {identities}. Rerun interactively or remove one with `bt auth logout`." + ); + } + bail!( + "multiple cross-org {kind_label} logins available: {identities}. Rerun interactively or remove one with `bt auth logout`." + ); + } } - - 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() == Some("") { + bail!( + "API-key login requires a concrete org; cross-org API keys do not exist. Use --oauth, or rerun with --org " + ); + } 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() { @@ -1225,7 +1623,6 @@ async fn run_login_set(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> { .clone() .unwrap_or_else(|| DEFAULT_APP_URL.to_string()); let login_orgs = fetch_login_orgs(&api_key, &login_app_url).await?; - let store = load_auth_store()?; let requested_org_resolution = resolve_requested_org_for_api_key_login( &login_orgs, base.org_name.as_deref(), @@ -1235,8 +1632,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 = config::load().ok().and_then(|cfg| cfg.org); let selected_org = select_login_org( login_orgs.clone(), match requested_org_resolution { @@ -1246,51 +1642,46 @@ 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, + false, explicitly_quiet(base), )?; + let selected_org = selected_org.ok_or_else(|| { + anyhow::anyhow!("API-key login requires an org; pass --org or rerun interactively") + })?; let selected_api_url = - resolve_profile_api_url(base.api_url.clone(), selected_org.as_ref(), &login_orgs)?; - let (profile_name, should_confirm_overwrite) = resolve_api_key_login_profile_name( - base.profile.as_deref(), - selected_org.as_ref().map(|org| org.name.as_str()), - &selected_api_url, - &store, - )?; - if should_confirm_overwrite { - confirm_profile_overwrite(&profile_name)?; - } + resolve_profile_api_url(base.api_url.clone(), Some(&selected_org), &login_orgs)?; commit_api_key_profile( - &profile_name, &api_key, selected_api_url.clone(), base.app_url.clone(), - selected_org.as_ref().map(|org| org.name.clone()), + selected_org.id.clone(), + selected_org.name.clone(), )?; let context_update = persist_post_login_context( base, - &profile_name, &api_key, &selected_api_url, &login_app_url, - selected_org.as_ref(), + Some(&selected_org), + &args.scope, ) .await .context("login succeeded, but failed to update active context")?; - let human = format_login_success(&selected_org, &profile_name, &selected_api_url); + let human = format_login_success(Some(&selected_org), &selected_api_url); emit_result( base.json, serde_json::json!({ - "name": profile_name, "auth": "api_key", - "org": selected_org.as_ref().map(|org| org.name.clone()), + "org": selected_org.name, + "org_id": selected_org.id, "api_url": selected_api_url, - "app_url": base.app_url.as_deref().unwrap_or(DEFAULT_APP_URL), + "app_url": login_app_url, + "api_key_hint": obscure_api_key(&api_key), "status": "ok", }), || { @@ -1315,23 +1706,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())) @@ -1366,22 +1746,15 @@ 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 = config::load().ok().and_then(|cfg| cfg.org); 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, @@ -1389,45 +1762,32 @@ async fn run_login_oauth(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> { )?; let selected_api_url = resolve_profile_api_url(base.api_url.clone(), selected_org.as_ref(), &login_orgs)?; - let jwt_id = decode_jwt_identity(&oauth_tokens.access_token); - let (profile_name, should_confirm_overwrite) = resolve_oauth_login_profile_name( - base.profile.as_deref(), - selected_org.as_ref().map(|org| org.name.as_str()), - &selected_api_url, - &app_url, - &jwt_id, - &store, - )?; - if should_confirm_overwrite { - confirm_profile_overwrite(&profile_name)?; - } commit_oauth_profile( - &profile_name, &oauth_tokens, selected_api_url.clone(), app_url.clone(), - client_id.clone(), - selected_org.as_ref().map(|org| org.name.clone()), + selected_org.as_ref(), )?; let context_update = persist_post_login_context( base, - &profile_name, &oauth_tokens.access_token, &selected_api_url, &app_url, selected_org.as_ref(), + &args.scope, ) .await .context("login succeeded, but failed to update active context")?; - let human = format_login_success(&selected_org, &profile_name, &selected_api_url); + let human = format_login_success(selected_org.as_ref(), &selected_api_url); emit_result( base.json, serde_json::json!({ - "name": profile_name, "auth": "oauth", "org": selected_org.as_ref().map(|org| org.name.clone()), + "org_id": selected_org.as_ref().map(|org| org.id.clone()), + "cross_org": selected_org.is_none(), "api_url": selected_api_url, "app_url": app_url, "status": "ok", @@ -1446,67 +1806,87 @@ async fn run_login_oauth(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> { } pub(crate) fn commit_api_key_profile( - profile_name: &str, api_key: &str, api_url: String, app_url: Option, - org_name: Option, + 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, + selected_org: Option<&LoginOrgInfo>, ) -> Result<()> { let refresh_token = tokens.refresh_token.as_ref().ok_or_else(|| { anyhow::anyhow!( - "oauth token response did not include a refresh_token; cannot create persistent oauth profile" + "oauth token response did not include a refresh_token; cannot create persistent oauth login" ) })?; - save_profile_oauth_refresh_token(profile_name, refresh_token)?; - save_profile_oauth_access_token(profile_name, &tokens.access_token)?; - let _ = delete_profile_secret(profile_name); let oauth_access_expires_at = determine_oauth_access_expiry_epoch(tokens); let jwt_id = decode_jwt_identity(&tokens.access_token); + let email = jwt_id + .email + .clone() + .filter(|email| !email.trim().is_empty()) + .ok_or_else(|| { + anyhow::anyhow!( + "oauth token did not include an email; cannot create persistent oauth login" + ) + })?; + let org_id = selected_org.map(|org| org.id.clone()).unwrap_or_default(); + let slot_key = oauth_slot_key(&org_id, &email); + + save_profile_oauth_refresh_token(&slot_key, refresh_token)?; + save_profile_oauth_access_token(&slot_key, &tokens.access_token)?; + let _ = delete_profile_secret(&slot_key); let mut store = load_auth_store()?; + if let Some(old_profile) = store.profiles.get(&slot_key) { + delete_legacy_profile_secrets(old_profile); + } store.profiles.insert( - profile_name.to_string(), + slot_key, 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: Some(org_id), + org_name: selected_org.map(|org| org.name.clone()), 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) @@ -1514,35 +1894,46 @@ 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 --org or run `bt auth logins` to see available logins" + ) + })?; let profile = store .profiles .get(profile_name.as_str()) - .ok_or_else(|| profile_not_found_err(&profile_name, &store))?; - if profile.auth_kind != AuthKind::Oauth { - bail!( - "profile '{profile_name}' uses api key auth; `bt auth refresh` only applies to oauth profiles" - ); - } + .cloned() + .ok_or_else(|| { + anyhow::anyhow!("OAuth login not found; run `bt auth logins` to see available logins") + })?; let api_url = 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 {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 {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(); @@ -1554,9 +1945,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() { @@ -1565,273 +1954,59 @@ 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; - } - save_auth_store(&store)?; - - if let Some(expires_at) = new_expires_at { - let now = current_unix_timestamp(); - let remaining = expires_at.saturating_sub(now); - eprintln!("New access token expiry: {expires_at} (about {remaining}s remaining)"); - } else { - eprintln!("New access token expiry: unknown"); - } - if refresh_rotated { - eprintln!("Refresh token rotation: yes"); - } else { - eprintln!("Refresh token rotation: no"); - } - - emit_result( - base.json, - serde_json::json!({ - "name": profile_name, - "auth": "oauth", - "access_expires_at": new_expires_at, - "refresh_token_rotated": refresh_rotated, - "status": "ok", - }), - || ui::print_command_status(ui::CommandStatus::Success, "OAuth refresh complete."), - ) -} - -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"); + if refresh_rotated || profile.legacy_secret_key.is_some() { + delete_legacy_profile_secrets(profile); + profile.legacy_secret_key = None; } - 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 next_available_profile_name(base_name: &str, store: &AuthStore) -> String { - if !store.profiles.contains_key(base_name) { - return base_name.to_string(); - } - - (2u32..) - .map(|idx| format!("{base_name}-{idx}")) - .find(|candidate| !store.profiles.contains_key(candidate)) - .expect("profile name sequence is infinite") -} - -fn resolve_api_key_login_profile_name( - explicit_profile: Option<&str>, - suggested_org_name: Option<&str>, - selected_api_url: &str, - store: &AuthStore, -) -> Result<(String, bool)> { - if let Some(profile_name) = explicit_profile { - let profile_name = resolve_profile_name(Some(profile_name), suggested_org_name)?; - let should_confirm_overwrite = store.profiles.get(&profile_name).is_some_and(|profile| { - !profile_matches_api_key_login_target(profile, selected_api_url, suggested_org_name) - }); - return Ok((profile_name.clone(), should_confirm_overwrite)); - } - - let default_name = default_profile_name(suggested_org_name); - let has_matching_api_key_profile = store.profiles.values().any(|profile| { - profile.auth_kind == AuthKind::ApiKey - && profile.api_url.as_deref() == Some(selected_api_url) - && profile.org_name.as_deref() == suggested_org_name - }); - - if has_matching_api_key_profile { - return Ok((next_available_profile_name(&default_name, store), false)); - } - - 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 -} - -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 -} + } + save_auth_store(&store)?; + reconcile_oauth_slot_from_access_token( + &mut store, + profile_name.as_str(), + &refreshed.access_token, + profile.app_url.as_deref().unwrap_or(DEFAULT_APP_URL), + ) + .await?; -fn confirm_profile_overwrite(profile_name: &str) -> Result<()> { - let store = load_auth_store()?; - if !store.profiles.contains_key(profile_name) { - return Ok(()); + if let Some(expires_at) = new_expires_at { + let now = current_unix_timestamp(); + let remaining = expires_at.saturating_sub(now); + eprintln!("New access token expiry: {expires_at} (about {remaining}s remaining)"); + } else { + eprintln!("New access token expiry: unknown"); } - let Some(term) = ui::prompt_term() else { - return Ok(()); - }; - let confirmed = Confirm::new() - .with_prompt(format!( - "Profile '{profile_name}' already exists. Overwrite?" - )) - .default(false) - .interact_on(&term)?; - if !confirmed { - bail!("login cancelled"); + if refresh_rotated { + eprintln!("Refresh token rotation: yes"); + } else { + eprintln!("Refresh token rotation: no"); } - Ok(()) + + emit_result( + base.json, + serde_json::json!({ + "auth": "oauth", + "org": profile.org_name, + "org_id": profile.org_id.filter(|org_id| !org_id.trim().is_empty()), + "user_email": profile.email, + "access_expires_at": new_expires_at, + "refresh_token_rotated": refresh_rotated, + "status": "ok", + }), + || ui::print_command_status(ui::CommandStatus::Success, "OAuth refresh complete."), + ) } -fn format_login_success( - selected_org: &Option, - profile_name: &str, - api_url: &str, -) -> String { - match selected_org.as_ref() { - Some(org) => format!( - "Logged in as {} (profile: {profile_name}, api: {api_url})", - org.name - ), - None => format!("Logged in (cross-org, profile: {profile_name}, api: {api_url})"), +fn format_login_success(selected_org: Option<&LoginOrgInfo>, api_url: &str) -> String { + match selected_org { + Some(org) => format!("Logged in as {} (api: {api_url})", org.name), + None => format!("Logged in (cross-org, api: {api_url})"), } } @@ -1886,36 +2061,38 @@ async fn resolve_post_login_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) + ui::select_or_create_project(&client, Some(project_name), None, None) .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>, + scope: &config::ScopeArgs, ) -> Result { + // Scope is prompted last, after org (during login) and project. 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 (path, _) = scope.resolve(ui::can_prompt(), "Where to use this login")?; 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(), - ); + let org = selected_org.map_or("", |org| org.name.as_str()); + let preserve_project = project.is_none() + && selected_org.is_some() + && config::org_option(cfg.org.as_deref()) == Some(org); + if !preserve_project { + cfg.set_context( + Some(org), + project + .as_ref() + .map(|project| (project.name.as_str(), project.id.as_str())), + ); + } config::save_file(&path, &cfg) - .context(format!("Could not save config to {}", path.display()))?; + .with_context(|| format!("Could not save config to {}", path.display()))?; Ok(PostLoginContextUpdate { display: format_post_login_context(selected_org, project.as_ref()), @@ -1923,20 +2100,6 @@ async fn persist_post_login_context( }) } -/// Build an actionable "profile not found" error that lists the available -/// profiles, so non-interactive callers can see what they can pick from. -fn profile_not_found_err(name: &str, store: &AuthStore) -> anyhow::Error { - let available: Vec = store.profiles.keys().cloned().collect(); - let suffix = if available.is_empty() { - String::new() - } else { - format!(": {}", available.join(", ")) - }; - anyhow::anyhow!( - "profile '{name}' not found; run `bt auth profiles` to see available profiles{suffix}" - ) -} - /// Emit a machine-readable JSON payload on stdout when `--json` is set, /// otherwise run the human-readable printer. Keeps stdout pure JSON. fn emit_result(json: bool, payload: serde_json::Value, human: impl FnOnce()) -> Result<()> { @@ -1948,36 +2111,49 @@ fn emit_result(json: bool, payload: serde_json::Value, human: impl FnOnce()) -> 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 - } - None => store, - }; +fn filter_auth_store( + store: &AuthStore, + org: Option<&str>, + kind: Option, + api_key_hint: Option<&str>, +) -> AuthStore { + let mut filtered = store.clone(); + filtered.profiles.retain(|_, profile| { + org.is_none_or(|org| profile_matches_org_identifier(profile, org)) + && 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()) + }) + }); + filtered +} - if filtered_store.profiles.is_empty() { +async fn run_logins(base: &BaseArgs, _args: AuthLoginsArgs) -> Result<()> { + let mut store = load_auth_store()?; + let has_filter = base.org_name.is_some() || base.prefer_api_key; + let filtered = filter_auth_store( + &store, + base.org_name.as_deref(), + base.prefer_api_key.then_some(AuthKind::ApiKey), + None, + ); + if filtered.profiles.is_empty() { return emit_result(base.json, serde_json::json!([]), || { - println!("No saved profiles. Run `bt auth login` to create one.") + if store.profiles.is_empty() && !has_filter { + println!("No saved auth logins. Run `bt auth login` to create one."); + } }); } - let verifications = verify_all_profiles_from_store(&filtered_store).await; + let verifications = verify_all_profiles_from_store(&filtered).await; + reconcile_verified_auth_slots(&mut store, &verifications)?; let all_network_errors = verifications .iter() .all(|v| v.status == "error" && !v.error.as_deref().unwrap_or("").contains("invalid")); if all_network_errors { - eprintln!("Could not reach Braintrust API. Showing saved profiles:"); - print_saved_profiles(&filtered_store, base.json)?; + eprintln!("Could not reach Braintrust API. Showing saved auth logins:"); + print_saved_profiles(&filtered, base.json)?; return Ok(()); } @@ -2004,79 +2180,110 @@ async fn run_profiles(base: &BaseArgs, args: AuthProfilesArgs) -> Result<()> { Ok(()) } +fn auth_profile_json(profile: &AuthProfile, status: &str) -> serde_json::Value { + serde_json::json!({ + "auth": auth_kind_label(profile.auth_kind), + "org": profile.org_name, + "org_id": profile.org_id.as_deref().filter(|org_id| !org_id.trim().is_empty()), + "user_name": profile.user_name, + "user_email": profile.email, + "api_key_hint": profile.api_key_hint, + "status": status, + }) +} + fn run_login_delete(profile_name: &str, force: bool, base_json: bool) -> Result<()> { let profile_name = profile_name.trim(); if profile_name.is_empty() { - bail!("profile name cannot be empty"); + bail!("auth login key cannot be empty"); } let mut store = load_auth_store()?; - if !store.profiles.contains_key(profile_name) { - return Err(profile_not_found_err(profile_name, &store)); - } + let profile = store.profiles.get(profile_name).cloned().ok_or_else(|| { + anyhow::anyhow!("auth login not found; run `bt auth logins` 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<()> { 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) + ) { + config::org_option(base.org_name.as_deref()) } else { - bail!("multiple profiles exist. Use --profile to specify which one."); + None + }; + let filtered = filter_auth_store( + &store, + requested_org, + args.oauth.then_some(AuthKind::Oauth), + args.api_key_hint.as_deref(), + ); + 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 auth logins` to see available logins"), + 1 => candidates[0].to_string(), + _ if ui::can_prompt() => select_profile_from_store( + "Select auth login to log out", + &candidates, + current_org, + &filtered, + )?, + _ => { + let labels = candidate_identities(&candidates, &filtered).join(", "); + bail!( + "multiple auth logins match: {labels}. Rerun interactively, or use --org with --oauth or --api-key-hint to disambiguate." + ); + } }; run_login_delete(&profile_name, args.force, base.json) @@ -2098,18 +2305,20 @@ enum CredentialLoad { fn load_credential_for_profile(name: &str, profile: &AuthProfile) -> CredentialLoad { match profile.auth_kind { - AuthKind::ApiKey => match load_profile_secret(name) { - Ok(Some(k)) => CredentialLoad::Found(k), - Ok(None) => CredentialLoad::Missing, - Err(e) => CredentialLoad::Error(e.to_string()), - }, + AuthKind::ApiKey => { + match load_profile_secret_with_legacy(name, profile.legacy_secret_key.as_deref()) { + Ok(Some(k)) => CredentialLoad::Found(k), + Ok(None) => CredentialLoad::Missing, + Err(e) => CredentialLoad::Error(e.to_string()), + } + } AuthKind::Oauth => { if let Some(ts) = profile.oauth_access_expires_at { if !oauth_access_token_is_fresh(ts) { return CredentialLoad::Expired; } } - match load_profile_oauth_access_token(name) { + match load_profile_oauth_access_token_for_profile(name, profile) { Ok(Some(k)) => CredentialLoad::Found(k), Ok(None) => CredentialLoad::Missing, Err(e) => CredentialLoad::Error(e.to_string()), @@ -2120,11 +2329,16 @@ 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, @@ -2139,6 +2353,7 @@ fn build_verification( name: &str, auth_kind: &str, org: Option, + org_id: Option, jwt_id: Option, api_key_hint: Option, status: ProfileStatus, @@ -2151,8 +2366,10 @@ fn build_verification( }; ProfileVerification { name: name.to_string(), + slot_hash: None, auth: auth_kind.to_string(), org, + org_id, user_name: jwt_id.as_ref().and_then(|j| j.name.clone()), user_email: jwt_id.as_ref().and_then(|j| j.email.clone()), api_key_hint, @@ -2163,15 +2380,16 @@ fn build_verification( async fn verify_profile_full(name: &str, profile: &AuthProfile) -> ProfileVerification { let app_url = profile.app_url.as_deref().unwrap_or(DEFAULT_APP_URL); - let auth_kind = match profile.auth_kind { - AuthKind::ApiKey => "api_key", - AuthKind::Oauth => "oauth", - }; + let auth_kind = auth_kind_label(profile.auth_kind); let mk = |status, jwt_id: Option, hint: Option| { build_verification( name, auth_kind, profile.org_name.clone(), + profile + .org_id + .clone() + .filter(|org_id| !org_id.trim().is_empty()), jwt_id, hint, status, @@ -2180,9 +2398,15 @@ async fn verify_profile_full(name: &str, profile: &AuthProfile) -> ProfileVerifi let credential = match load_credential_for_profile(name, profile) { CredentialLoad::Found(k) => k, - CredentialLoad::Missing => return mk(ProfileStatus::Missing, None, None), - CredentialLoad::Expired => return mk(ProfileStatus::Expired, None, None), - CredentialLoad::Error(e) => return mk(ProfileStatus::Error(e), None, None), + CredentialLoad::Missing => { + return mk(ProfileStatus::Missing, None, profile.api_key_hint.clone()) + } + CredentialLoad::Expired => { + return mk(ProfileStatus::Expired, None, profile.api_key_hint.clone()) + } + CredentialLoad::Error(e) => { + return mk(ProfileStatus::Error(e), None, profile.api_key_hint.clone()) + } }; let (jwt_id, hint) = match profile.auth_kind { @@ -2191,19 +2415,40 @@ async fn verify_profile_full(name: &str, profile: &AuthProfile) -> ProfileVerifi }; match fetch_login_orgs(&credential, app_url).await { - Ok(_) => mk(ProfileStatus::Ok, jwt_id, hint), + Ok(orgs) => { + let mut verification = mk(ProfileStatus::Ok, jwt_id, hint); + if !is_cross_org_oauth_profile(profile) { + if let Some(org) = profile + .org_id + .as_deref() + .and_then(|id| find_login_org(&orgs, id)) + .or_else(|| { + profile + .org_name + .as_deref() + .and_then(|name| find_login_org(&orgs, name)) + }) + { + verification.org = Some(org.name.clone()); + verification.org_id = Some(org.id.clone()); + } + } + if profile.auth_kind == AuthKind::ApiKey { + verification.slot_hash = Some(api_key_hash(&credential)); + } + verification + } Err(e) => { - let msg = e.to_string(); - let status = if msg.contains("401") || msg.contains("Unauthorized") { + let status = if is_unauthorized_auth_error(&e) { if profile.auth_kind == AuthKind::Oauth { ProfileStatus::Expired } else { ProfileStatus::Error("invalid API key".to_string()) } } else { - ProfileStatus::Error(msg) + ProfileStatus::Error(e.to_string()) }; - mk(status, None, None) + mk(status, None, hint) } } } @@ -2222,28 +2467,93 @@ 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 let Some(org_id) = verification.org_id.as_deref() { + profile.org_id = Some(org_id.to_string()); + profile.org_name = verification.org.clone(); + } else if is_cross_org_oauth_profile(&profile) { + profile.org_id = Some(String::new()); + profile.org_name = None; + } + + match profile.auth_kind { + AuthKind::ApiKey => { + let Some(hash) = verification.slot_hash.as_deref() else { + continue; + }; + if profile.org_id.as_deref().is_none_or(str::is_empty) { + continue; + } + profile.api_key_hash = Some(hash.to_string()); + } + AuthKind::Oauth => { + if let Some(email) = verification.user_email.as_deref() { + profile.email = Some(email.to_string()); + } + if let Some(user_name) = verification.user_name.as_deref() { + profile.user_name = Some(user_name.to_string()); + } + if profile.email.as_deref().is_none_or(str::is_empty) { + continue; + } + } + } + + changed |= replace_with_canonical_auth_profile(store, verification.name.as_str(), profile); + } + + if changed { + save_auth_store(store)?; } + Ok(()) +} + +fn format_verification_line(v: &ProfileVerification) -> String { + let mut parts = vec![ + config::display_org(v.org.as_deref().unwrap_or("")).to_string(), + 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()), + }, _ => { if let Some(ref e) = v.error { parts.push(e.clone()); @@ -2253,16 +2563,30 @@ fn format_verification_line(v: &ProfileVerification) -> String { parts.join(" — ") } +fn profiles_grouped_by_org(store: &AuthStore) -> Vec<(&str, &AuthProfile)> { + let mut profiles = store + .profiles + .iter() + .map(|(name, profile)| (name.as_str(), profile)) + .collect::>(); + profiles.sort_by(|(a_name, a), (b_name, b)| { + profile_org(a) + .cmp(profile_org(b)) + .then_with(|| a_name.cmp(b_name)) + }); + profiles +} + fn print_saved_profiles(store: &AuthStore, json: bool) -> Result<()> { + let profiles = profiles_grouped_by_org(store); if json { - let output: Vec = store - .profiles - .iter() - .map(|(name, p)| { + let output: Vec = profiles + .into_iter() + .map(|(_, p)| { serde_json::json!({ - "name": name, - "auth": match p.auth_kind { AuthKind::ApiKey => "api_key", AuthKind::Oauth => "oauth" }, + "auth": auth_kind_label(p.auth_kind), "org": p.org_name, + "org_id": p.org_id, "user_name": p.user_name, "user_email": p.email, "api_key_hint": p.api_key_hint, @@ -2272,26 +2596,8 @@ fn print_saved_profiles(store: &AuthStore, json: bool) -> Result<()> { .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}"); + for (_, profile) in profiles { + println!(" {}", auth_slot_label(profile)); } } Ok(()) @@ -2338,12 +2644,11 @@ fn select_login_org( 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 requested_org_name == Some("") { + return Ok(None); + } if let Some(name) = requested_org_name { return find_login_org(&orgs, name) @@ -2357,6 +2662,11 @@ fn select_login_org( } if !interactive { + if allow_cross_org { + bail!( + "organization selection required in non-interactive mode; pass --org or rerun interactively to choose cross-org mode" + ); + } return Ok(None); } @@ -2394,6 +2704,15 @@ fn select_login_org( )) } +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>, @@ -2417,7 +2736,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() @@ -2425,13 +2744,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( @@ -2515,25 +2839,6 @@ fn resolve_profile_api_url( ) } -fn default_oauth_client_id(profile_name: &str) -> String { - let sanitized = profile_name - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { - ch - } else { - '-' - } - }) - .collect::(); - let trimmed = sanitized.trim_matches('-'); - if trimmed.is_empty() { - "bt_cli_default".to_string() - } else { - format!("bt_cli_{trimmed}") - } -} - fn generate_random_token(num_bytes: usize) -> Result { let mut bytes = vec![0u8; num_bytes]; getrandom::fill(&mut bytes) @@ -2890,7 +3195,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, @@ -2906,7 +3210,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()), @@ -2917,20 +3221,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 {profile_name}`" - )); + message.push_str(&format!("; re-run `{}`", oauth_reauth_command(profile))); return recoverable_auth_error(RecoverableAuthErrorKind::OauthRefreshToken, message); } } @@ -2944,8 +3248,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() @@ -2958,7 +3261,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() @@ -2967,12 +3270,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 @@ -3005,18 +3303,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), @@ -3093,6 +3387,47 @@ fn load_profile_secret(profile_name: &str) -> Result> { } } +fn load_profile_secret_with_legacy( + primary_key: &str, + legacy_key: Option<&str>, +) -> Result> { + if let Some(secret) = load_profile_secret(primary_key)? { + return Ok(Some(secret)); + } + + let Some(legacy_key) = legacy_key + .map(str::trim) + .filter(|key| !key.is_empty() && *key != primary_key) + else { + return Ok(None); + }; + + let Some(secret) = load_profile_secret(legacy_key)? else { + return Ok(None); + }; + let _ = relocate_plaintext_secret_if_present(primary_key, legacy_key, &secret); + Ok(Some(secret)) +} + +fn relocate_plaintext_secret_if_present( + primary_key: &str, + legacy_key: &str, + secret: &str, +) -> Result<()> { + let path = secret_store_path()?; + if !path.exists() { + return Ok(()); + } + let mut store = load_secret_store()?; + if store.secrets.remove(legacy_key).is_some() { + store + .secrets + .insert(primary_key.to_string(), secret.to_string()); + save_secret_store(&store)?; + } + Ok(()) +} + fn delete_profile_secret(profile_name: &str) -> Result<()> { let keychain_err = delete_profile_secret_keychain(profile_name).err(); let plaintext_err = delete_profile_secret_plaintext(profile_name).err(); @@ -3445,9 +3780,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<()> { @@ -3460,9 +3802,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<()> { @@ -3470,8 +3819,24 @@ 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); + } + } +} + fn load_valid_cached_oauth_access_token( profile_name: &str, + profile: &AuthProfile, expires_at: Option, ) -> Result> { let Some(expires_at) = expires_at else { @@ -3480,7 +3845,7 @@ fn load_valid_cached_oauth_access_token( if !oauth_access_token_is_fresh(expires_at) { return Ok(None); } - load_profile_oauth_access_token(profile_name) + load_profile_oauth_access_token_for_profile(profile_name, profile) } fn oauth_access_token_is_fresh(expires_at: u64) -> bool { @@ -3543,6 +3908,30 @@ pub fn obscure_api_key(key: &str) -> String { format!("{}****{}", &key[..prefix_end], &key[suffix_start..]) } +fn sha256_hex(value: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(value.as_bytes()); + let digest = hasher.finalize(); + let mut out = String::with_capacity(digest.len() * 2); + for byte in digest { + use std::fmt::Write as _; + let _ = write!(&mut out, "{byte:02x}"); + } + out +} + +fn api_key_hash(api_key: &str) -> String { + sha256_hex(api_key) +} + +fn oauth_slot_key(org_id: &str, email: &str) -> String { + format!("{org_id}::{email}") +} + +fn api_key_slot_key(api_key_hash: &str, org_id: &str) -> String { + format!("{api_key_hash}::{org_id}") +} + fn current_unix_timestamp() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -3562,8 +3951,109 @@ fn load_auth_store_from_path(path: &Path) -> Result { let data = fs::read_to_string(path) .with_context(|| format!("failed to read auth config {}", path.display()))?; - serde_json::from_str(&data) - .with_context(|| format!("failed to parse auth config {}", path.display())) + let store: AuthStore = serde_json::from_str(&data) + .with_context(|| format!("failed to parse auth config {}", path.display()))?; + let migrated = migrate_auth_store(store.clone()); + if migrated != store { + save_auth_store_to_path(path, &migrated).with_context(|| { + format!("failed to persist auth config migration {}", path.display()) + })?; + } + Ok(migrated) +} + +fn migrate_auth_store(store: AuthStore) -> AuthStore { + let mut migrated = AuthStore::default(); + for (old_key, mut profile) in store.profiles { + normalize_profile_cached_fields_from_key(&old_key, &mut profile); + if profile.auth_kind == AuthKind::Oauth + && profile.org_id.is_none() + && profile + .org_name + .as_deref() + .is_none_or(|org| org.trim().is_empty()) + { + profile.org_id = Some(String::new()); + profile.org_name = None; + } + let new_key = canonical_profile_key(&old_key, &profile); + if new_key != old_key && profile.legacy_secret_key.is_none() { + profile.legacy_secret_key = Some(old_key.clone()); + } + if migrated + .profiles + .get(&new_key) + .is_some_and(|existing| !should_replace_migrated_profile(existing, &profile)) + { + continue; + } + migrated.profiles.insert(new_key, profile); + } + migrated +} + +fn should_replace_migrated_profile(existing: &AuthProfile, candidate: &AuthProfile) -> bool { + match (existing.auth_kind, candidate.auth_kind) { + (AuthKind::Oauth, AuthKind::Oauth) => { + candidate.oauth_access_expires_at.unwrap_or_default() + > existing.oauth_access_expires_at.unwrap_or_default() + } + _ => false, + } +} + +fn looks_like_sha256_hex(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn normalize_profile_cached_fields_from_key(current_key: &str, profile: &mut AuthProfile) { + let Some((left, right)) = current_key.split_once("::") else { + return; + }; + + match profile.auth_kind { + AuthKind::Oauth => { + let key_matches_email = profile.email.as_deref() == Some(right); + if key_matches_email || (profile.email.is_none() && right.contains('@')) { + 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()); + } + } + } +} + +fn canonical_profile_key(current_key: &str, profile: &AuthProfile) -> String { + match profile.auth_kind { + AuthKind::Oauth => { + let Some(email) = profile.email.as_deref().filter(|value| !value.is_empty()) else { + return current_key.to_string(); + }; + let org_id = profile.org_id.as_deref().unwrap_or_default(); + if profile.org_id.is_some() || profile.org_name.is_none() { + oauth_slot_key(org_id, email) + } else { + current_key.to_string() + } + } + AuthKind::ApiKey => match ( + profile + .api_key_hash + .as_deref() + .filter(|value| !value.is_empty()), + profile.org_id.as_deref().filter(|value| !value.is_empty()), + ) { + (Some(hash), Some(org_id)) => api_key_slot_key(hash, org_id), + _ => current_key.to_string(), + }, + } } fn save_auth_store(store: &AuthStore) -> Result<()> { @@ -3660,31 +4150,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() } @@ -3941,6 +4411,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() }; @@ -3948,21 +4419,25 @@ mod tests { crate::config::save_global(&cfg).expect("save global config"); } + 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() }, ); } @@ -4017,22 +4492,26 @@ 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 --org 'BT Staging'"); let err = map_refresh_oauth_error( "https://api.example.com", - "work", + &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(&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", ); @@ -4098,8 +4577,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 } } @@ -4111,678 +4593,565 @@ 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, - ) - .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")); - } - - #[test] - fn resolve_auth_prefers_explicit_api_key() { - let mut base = make_base(); - base.api_key = Some("explicit-key".to_string()); - base.api_url = Some("https://override.example.com".to_string()); - - let mut store = AuthStore::default(); - store.profiles.insert( - "work".to_string(), - AuthProfile { - auth_kind: AuthKind::ApiKey, - api_url: Some("https://api.example.com".to_string()), - app_url: None, - org_name: None, - oauth_client_id: None, - oauth_access_expires_at: None, - ..Default::default() - }, - ); - - let resolved = resolve_auth_from_store_with_secret_lookup( - &base, - &store, - |_| Ok(Some("profile-key".to_string())), - &None, - ) - .expect("resolve"); - assert_eq!(resolved.api_key.as_deref(), Some("explicit-key")); - assert_eq!( - resolved.api_url.as_deref(), - Some("https://override.example.com") - ); - assert!(!resolved.is_oauth); - } - - #[test] - fn resolve_auth_prefer_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()); - - 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 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() - }, - ); - - 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 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_marks_oauth_profiles() { - let mut base = make_base(); - base.profile = Some("work".to_string()); - - let mut store = AuthStore::default(); - store.profiles.insert( - "work".to_string(), - AuthProfile { - auth_kind: AuthKind::Oauth, - api_url: Some("https://api.example.com".to_string()), - app_url: Some("https://www.example.com".to_string()), - org_name: Some("Example Org".to_string()), - oauth_client_id: Some("bt_cli_work".to_string()), - oauth_access_expires_at: None, - ..Default::default() - }, - ); - - let resolved = resolve_auth_from_store_with_secret_lookup( - &base, - &store, - |_| Ok(Some("should-not-be-used".to_string())), - &None, - ) - .expect("resolve"); - - assert!(resolved.is_oauth); - assert_eq!(resolved.api_key, None); - assert_eq!(resolved.org_name.as_deref(), Some("Example Org")); - } - - #[test] - fn refresh_profile_selector_prefers_explicit_profile() { - let mut base = make_base(); - base.profile = Some(" work ".to_string()); - let store = AuthStore::default(); - let (profile_name, source) = - resolve_selected_profile_name_for_debug(&base, &store).expect("resolve"); - assert_eq!(profile_name, "work"); - assert_eq!(source, "--profile/BRAINTRUST_PROFILE"); - } - - #[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); - } - - #[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 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}" - ); + 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(), + ) } #[test] - fn resolve_profile_for_org_exact_profile_name() { + fn default_app_url_is_www() { + assert_eq!(DEFAULT_APP_URL, "https://www.braintrust.dev"); + } + + #[tokio::test] + async fn active_auth_info_without_org_uses_cross_org_oauth_only() { + let _env = TestEnv::new(None, None).await; let mut store = AuthStore::default(); store.profiles.insert( - "acme".into(), + api_key_slot_key(&api_key_hash("test-api-key"), "org_fake"), AuthProfile { - org_name: Some("acme-corp".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), Some("acme")); - } - - #[test] - fn resolve_profile_for_org_by_org_name() { - let mut store = AuthStore::default(); store.profiles.insert( - "work".into(), + oauth_slot_key("", "user@example.test"), AuthProfile { - org_name: Some("acme-corp".into()), + auth_kind: AuthKind::Oauth, + org_id: Some(String::new()), + org_name: None, + user_name: Some("Test User".to_string()), + email: Some("user@example.test".to_string()), ..Default::default() }, ); - assert_eq!(resolve_profile_for_org("acme-corp", &store), Some("work")); + save_auth_store(&store).expect("save auth store"); + + let info = active_auth_info(&make_base(), None) + .expect("resolve active auth") + .expect("active auth info"); + + assert_eq!(info.auth_method, "oauth"); + assert_eq!(info.email.as_deref(), Some("user@example.test")); + assert_eq!(info.org_name, None); + + let mut base = make_base(); + base.prefer_api_key = true; + assert!(resolve_auth(&base) + .await + .unwrap_err() + .to_string() + .contains("cross-org")); + assert!(active_auth_info(&base, None) + .unwrap_err() + .to_string() + .contains("cross-org")); } - #[test] - fn resolve_profile_for_org_no_match() { - let mut store = AuthStore::default(); + fn save_cached_oauth_login(store: &mut AuthStore, org_id: &str, org_name: &str) -> String { + let slot_key = oauth_slot_key(org_id, "user@example.test"); store.profiles.insert( - "work".into(), + slot_key.clone(), AuthProfile { - org_name: Some("acme-corp".into()), - ..Default::default() + api_url: Some("https://api.example.test".to_string()), + app_url: Some("https://www.example.test".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()), + ..org_profile(AuthKind::Oauth, org_id, org_name) }, ); - assert_eq!(resolve_profile_for_org("unknown", &store), None); + save_profile_secret_plaintext( + &oauth_access_secret_key(&slot_key), + "cached-oauth-access-token", + ) + .expect("save cached OAuth token"); + slot_key } - #[test] - fn resolve_profile_for_org_multiple_returns_none() { + #[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-1".into(), - AuthProfile { - org_name: Some("acme".into()), - ..Default::default() - }, - ); - store.profiles.insert( - "work-2".into(), - AuthProfile { - org_name: Some("acme".into()), - ..Default::default() - }, + save_cached_oauth_login(&mut store, "org_fake", "test-org"); + save_auth_store(&store).expect("save auth store"); + let mut base = make_base(); + base.org_name = Some("test-org".to_string()); + base.api_key = Some("environment-api-key".to_string()); + base.api_key_source = Some(crate::args::ArgValueSource::EnvVariable); + + let resolved = resolve_auth(&base).await.expect("resolve auth"); + + assert!(resolved.is_oauth); + assert_eq!( + resolved.api_key.as_deref(), + Some("cached-oauth-access-token") ); - assert_eq!(resolve_profile_for_org("acme", &store), None); } - #[test] - fn profile_selection_requires_choice_when_multiple_profiles_without_prompt() { - let base = make_base(); + #[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( - "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() - }, - ); + save_cached_oauth_login(&mut store, "org_fake", "test-org"); + save_auth_store(&store).expect("save auth store"); + let mut base = make_base(); + base.org_name = Some("test-org".to_string()); + base.api_key = Some("command-line-api-key".to_string()); + base.api_key_source = Some(crate::args::ArgValueSource::CommandLine); + base.app_url = Some(spawn_api_key_login_server("test-org")); + + let resolved = resolve_auth(&base).await.expect("resolve auth"); - let err = maybe_select_profile_for_auth(&base, &store, &None, false) - .expect_err("selection should be required"); + assert!(!resolved.is_oauth); + assert_eq!(resolved.api_key.as_deref(), Some("command-line-api-key")); + } - 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 ")); + #[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 profile_selection_requires_choice_for_ambiguous_org_without_prompt() { - let mut base = make_base(); - base.org_name = Some("acme".into()); - + fn selected_stored_api_key_wrong_org_fails_before_secret_lookup() { let mut store = AuthStore::default(); store.profiles.insert( - "work-1".into(), - AuthProfile { - org_name: Some("acme".into()), - ..Default::default() - }, - ); - store.profiles.insert( - "work-2".into(), - AuthProfile { - org_name: Some("acme".into()), - ..Default::default() - }, + "stored-slot".into(), + org_profile(AuthKind::ApiKey, "org_actual", "actual-org"), ); + let mut base = make_base(); + base.org_name = Some("requested-org".into()); - 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")); + 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 profile_selection_skips_when_api_key_override_is_active() { + #[tokio::test] + async fn auth_precedence_prefer_api_key_promotes_env_api_key() { + let _env = TestEnv::new(None, None).await; + let mut store = AuthStore::default(); + save_cached_oauth_login(&mut store, "org_fake", "test-org"); + save_auth_store(&store).expect("save auth store"); let mut base = make_base(); - base.api_key = Some("explicit-key".into()); + 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(spawn_api_key_login_server("test-org")); + + let resolved = resolve_auth(&base).await.expect("resolve auth"); + assert!(!resolved.is_oauth); + assert_eq!(resolved.api_key.as_deref(), Some("environment-api-key")); + } + + #[tokio::test] + async fn auth_precedence_prefer_api_key_falls_back_to_oauth_without_key() { + let _env = TestEnv::new(None, None).await; let mut store = AuthStore::default(); - store - .profiles - .insert("alpha".into(), AuthProfile::default()); - store.profiles.insert("beta".into(), AuthProfile::default()); + save_cached_oauth_login(&mut store, "org_fake", "test-org"); + save_auth_store(&store).expect("save auth store"); + let mut base = make_base(); + base.org_name = Some("test-org".to_string()); + base.prefer_api_key = true; - let selection = maybe_select_profile_for_auth(&base, &store, &None, false) - .expect("api key override should skip profile selection"); + let resolved = resolve_auth(&base).await.expect("resolve auth"); - assert_eq!(selection, None); + assert!(resolved.is_oauth); + assert_eq!( + resolved.api_key.as_deref(), + Some("cached-oauth-access-token") + ); } - #[test] - fn resolve_auth_uses_org_to_find_profile() { + #[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("acme-corp".into()); + 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".into(), + oauth_slot_key("org_fake", "user@example.test"), AuthProfile { - org_name: Some("acme-corp".into()), - api_url: Some("https://api.acme.com".into()), - ..Default::default() + user_name: Some("Test User".to_string()), + email: Some("user@example.test".to_string()), + ..org_profile(AuthKind::Oauth, "org_fake", "test-org") + }, + ); + store.profiles.insert( + api_key_slot_key(&api_key_hash("test-api-key"), "org_fake"), + AuthProfile { + api_key_hint: Some("sk-****abcde".to_string()), + ..org_profile(AuthKind::ApiKey, "org_fake", "test-org") }, ); + save_auth_store(&store).expect("save auth store"); + let mut base = make_base(); + base.prefer_api_key = true; - 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 info = active_auth_info(&base, Some("test-org")) + .expect("resolve active auth") + .expect("active auth info"); - #[test] - fn resolve_auth_uses_config_org_to_find_profile() { - let base = make_base(); + assert_eq!(info.auth_method, "api_key"); + assert_eq!(info.api_key_hint.as_deref(), Some("sk-****abcde")); + } + #[tokio::test] + async fn api_key_profile_rekeys_after_legacy_secret_load() { + let _env = TestEnv::new(None, None).await; let mut store = AuthStore::default(); store.profiles.insert( - "work".into(), - AuthProfile { - org_name: Some("acme-corp".into()), - api_url: Some("https://api.acme.com".into()), - ..Default::default() - }, + "work".to_string(), + org_profile(AuthKind::ApiKey, "org_fake", "test-org"), ); - 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")); + maybe_rekey_api_key_profile_after_secret_load(&mut store, "work", "test-api-key") + .expect("rekey api key profile"); + + let key = api_key_slot_key(&api_key_hash("test-api-key"), "org_fake"); + let profile = store.profiles.get(&key).expect("rekeyed profile"); + assert!(!store.profiles.contains_key("work")); + assert_eq!( + profile.api_key_hash.as_deref(), + Some(api_key_hash("test-api-key").as_str()) + ); + assert_eq!(profile.legacy_secret_key.as_deref(), Some("work")); } #[test] - fn resolve_auth_config_org_overrides_profile_org() { - let mut base = make_base(); - base.profile = Some("default-profile".to_string()); + 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( - "default-profile".into(), + "work".to_string(), AuthProfile { - org_name: Some("profile-org".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 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()); + save_auth_store_to_path(&path, &store).expect("save"); + let loaded = load_auth_store_from_path(&path).expect("load"); - let resolved = - resolve_auth_from_store_with_secret_lookup(&base, &store, |_| Ok(None), &cfg_org) - .expect("resolve"); + assert!(loaded.profiles.contains_key("work")); - assert_eq!(resolved.api_key.as_deref(), Some("explicit-key")); - assert_eq!(resolved.org_name.as_deref(), Some("local-org")); + let _ = fs::remove_dir_all(&dir); } #[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 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()), - ..Default::default() + email: Some("user@example.test".to_string()), + ..org_profile(AuthKind::Oauth, "org_fake", "test-org") }, ); + + let migrated = migrate_auth_store(store); + let key = oauth_slot_key("org_fake", "user@example.test"); + let profile = migrated.profiles.get(&key).expect("migrated profile"); + + assert_eq!(profile.legacy_secret_key.as_deref(), Some("work")); + } + + #[test] + fn migrate_auth_store_rekeys_cross_org_oauth_with_empty_org_id() { + let mut store = AuthStore::default(); store.profiles.insert( - "other".into(), + "legacy-cross-org".to_string(), AuthProfile { - org_name: Some("other-org".into()), - api_url: Some("https://api.other.com".into()), + auth_kind: AuthKind::Oauth, + org_name: None, + email: Some("user@example.test".to_string()), ..Default::default() }, ); - let resolved = resolve_auth_from_store_with_secret_lookup( - &base, - &store, - |_| Ok(Some("other-key".into())), - &None, - ) - .expect("resolve"); - assert_eq!(resolved.api_key.as_deref(), Some("other-key")); - assert_eq!(resolved.org_name.as_deref(), Some("acme-corp")); + let migrated = migrate_auth_store(store); + let profile = migrated + .profiles + .get(&oauth_slot_key("", "user@example.test")) + .expect("cross-org OAuth slot"); + + assert_eq!(profile.org_id.as_deref(), Some("")); + assert_eq!( + profile.legacy_secret_key.as_deref(), + Some("legacy-cross-org") + ); } #[test] - fn resolve_api_key_login_profile_name_creates_new_profile_for_matching_org() { + 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( - "acme".into(), + "work".to_string(), AuthProfile { auth_kind: AuthKind::ApiKey, - api_url: Some("https://api.acme.example".into()), - org_name: Some("acme".into()), + 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 (profile_name, should_confirm) = resolve_api_key_login_profile_name( - None, - Some("acme"), - "https://api.acme.example", - &store, - ) - .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!(profile_name, "acme-2"); - assert!(!should_confirm); + 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_api_key_login_profile_name_updates_explicit_matching_profile_without_confirm() { + 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(), + "legacy-login".to_string(), AuthProfile { - auth_kind: AuthKind::ApiKey, - api_url: Some("https://api.test.example".into()), - org_name: Some("test-org".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 (profile_name, should_confirm) = resolve_api_key_login_profile_name( - Some("work"), - Some("test-org"), - "https://api.test.example", - &store, - ) - .expect("resolve"); + let loaded = load_auth_store_from_path(&path).expect("load and migrate"); + let persisted: AuthStore = + serde_json::from_str(&fs::read_to_string(&path).expect("read migrated store")) + .expect("parse migrated store"); + let slot_key = oauth_slot_key("org_fake", "user@example.test"); + + for migrated in [&loaded, &persisted] { + let profile = migrated + .profiles + .get(&slot_key) + .expect("canonical OAuth slot"); + assert_eq!(profile.legacy_secret_key.as_deref(), Some("legacy-login")); + } - assert_eq!(profile_name, "work"); - assert!(!should_confirm); + let _ = fs::remove_dir_all(&dir); } - #[test] - fn resolve_api_key_login_profile_name_confirms_explicit_different_target() { + #[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( - "work".into(), + "legacy-oauth".to_string(), + AuthProfile { + auth_kind: AuthKind::Oauth, + org_name: Some("test-org".to_string()), + ..Default::default() + }, + ); + store.profiles.insert( + "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, + status: "ok".to_string(), + error: None, + }, + ProfileVerification { + name: "legacy-api-key".to_string(), + slot_hash: Some(hash.clone()), + auth: "api_key".to_string(), + org: Some("test-org".to_string()), + org_id: Some("org_fake".to_string()), + user_name: None, + user_email: None, + api_key_hint: Some("sk-****abcde".to_string()), + status: "ok".to_string(), + error: None, + }, + ]; - let (profile_name, should_confirm) = resolve_api_key_login_profile_name( - Some("work"), - Some("other-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("org_fake", "user@example.test")) + .expect("canonical OAuth slot"); + assert_eq!(oauth.legacy_secret_key.as_deref(), Some("legacy-oauth")); + let api_key = store + .profiles + .get(&api_key_slot_key(&hash, "org_fake")) + .expect("canonical API-key slot"); + assert_eq!(api_key.legacy_secret_key.as_deref(), Some("legacy-api-key")); } #[test] - fn default_login_org_name_uses_profile_org_when_org_not_requested() { + fn migrate_auth_store_dedupes_oauth_slots_by_latest_expiry() { let mut store = AuthStore::default(); - store.profiles.insert( - "work".into(), - AuthProfile { - org_name: Some("acme".into()), - ..Default::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(" work "), None).as_deref(), - Some("acme") - ); + let migrated = migrate_auth_store(store); + let key = oauth_slot_key("org_fake", "user@example.test"); + assert_eq!(migrated.profiles.len(), 1); + let profile = migrated.profiles.get(&key).expect("migrated profile"); + assert_eq!(profile.legacy_secret_key.as_deref(), Some("new")); } #[test] - fn default_login_org_name_falls_back_to_profile_name() { - let store = AuthStore::default(); + fn config_auth_context_returns_config_org() { + let base = make_base(); + let cfg = auth_config(Some("local-org")); - assert_eq!( - default_login_org_name(&store, Some(" acme "), None).as_deref(), - Some("acme") - ); + let org = config_auth_context_from_config(&base, &cfg); + + assert_eq!(org.as_deref(), Some("local-org")); } #[test] - fn default_login_org_name_ignores_profile_when_org_requested() { - let mut store = AuthStore::default(); - store.profiles.insert( - "work".into(), - AuthProfile { - org_name: Some("acme".into()), - ..Default::default() - }, - ); + 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); + } + + #[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_eq!( - default_login_org_name(&store, Some("work"), Some("other")), - None + #[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}" ); } @@ -4808,159 +5177,203 @@ mod tests { assert_eq!(orgs[1].name, "beta"); } + fn login_org(id: &str, name: &str) -> LoginOrgInfo { + LoginOrgInfo { + id: id.to_string(), + name: name.to_string(), + api_url: None, + } + } + + fn auth_source( + prefer_api_key: bool, + cli: Option<&str>, + env: Option<&str>, + oauth: Option<&str>, + api_key: Option<&str>, + ) -> AuthSource { + resolve_auth_source( + prefer_api_key, + cli.map(str::to_string), + || env.map(str::to_string), + || Ok(oauth.map(str::to_string)), + || Ok(api_key.map(str::to_string)), + ) + .expect("resolve auth source") + } + #[test] - fn 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() - }, + 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()) ); - 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() - }, + assert_eq!( + auth_source(true, Some("cli"), Some("env"), Some("oauth"), Some("ak")), + AuthSource::CliApiKey("cli".into()) ); + } - let jwt_id = JwtIdentity { - name: Some("Alice".into()), - email: Some("alice@example.com".into()), - }; - let (profile_name, should_confirm) = resolve_oauth_login_profile_name( - None, - Some("acme"), - "https://api.acme.example", - "https://www.acme.example", - &jwt_id, - &store, - ) - .expect("resolve"); - - assert_eq!(profile_name, "newer"); - assert!(!should_confirm); + #[test] + fn 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_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 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()) ); - let jwt_id = JwtIdentity { - name: Some("Test User".into()), - email: Some("user@test.example".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); + } - 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, + #[test] + fn auth_source_ambiguous_selection_stops_the_ladder() { + let err = resolve_auth_source( + false, + None, + || None, + || bail!("multiple oauth logins"), + || Ok(Some("ak".to_string())), ) - .expect("resolve"); - - assert_eq!(profile_name, "work"); - assert!(!should_confirm); + .expect_err("ambiguous oauth should stop the ladder"); + assert!(err.to_string().contains("multiple oauth logins")); } - #[test] - fn resolve_oauth_login_profile_name_confirms_explicit_different_target() { + fn login_filter_store() -> AuthStore { let mut store = AuthStore::default(); + for (slot, kind, suffix, hint) in [ + ("oauth-a", AuthKind::Oauth, "a", None), + ("key-a", AuthKind::ApiKey, "a", Some("sk-****aaaaa")), + ("key-b", AuthKind::ApiKey, "b", Some("sk-****bbbbb")), + ] { + store.profiles.insert( + slot.into(), + AuthProfile { + api_key_hint: hint.map(str::to_string), + ..org_profile( + kind, + &format!("org_test_{suffix}"), + &format!("test-org-{suffix}"), + ) + }, + ); + } store.profiles.insert( - "work".into(), + "cross".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()), + org_id: Some(String::new()), ..Default::default() }, ); - let jwt_id = JwtIdentity { - name: Some("Test User".into()), - email: Some("user@test.example".into()), - }; - - let (profile_name, should_confirm) = resolve_oauth_login_profile_name( - Some("work"), - Some("other-org"), - "https://api.test.example", - "https://app.test.example", - &jwt_id, - &store, - ) - .expect("resolve"); - - assert_eq!(profile_name, "work"); - assert!(should_confirm); + store } - fn login_org(id: &str, name: &str) -> LoginOrgInfo { - LoginOrgInfo { - id: id.to_string(), - name: name.to_string(), - api_url: None, - } + #[test] + fn login_and_logout_filters_compose() { + let store = login_filter_store(); + let matches = |org, kind, hint| { + filter_auth_store(&store, org, kind, hint) + .profiles + .into_keys() + .collect::>() + }; + assert_eq!( + matches(Some("test-org-a"), None, None), + ["key-a", "oauth-a"] + ); + assert_eq!(matches(Some("org_test_b"), None, None), ["key-b"]); + assert_eq!( + matches(Some("test-org-a"), Some(AuthKind::ApiKey), None), + ["key-a"] + ); + assert_eq!(matches(Some(""), None, None), ["cross"]); + assert!(matches(Some(""), Some(AuthKind::ApiKey), None).is_empty()); + assert_eq!(matches(None, None, None).len(), 4); + assert_eq!( + matches(Some("test-org-a"), Some(AuthKind::Oauth), None), + ["oauth-a"] + ); + assert_eq!(matches(None, None, Some("sk-****bbbbb")), ["key-b"]); + + let mut picker_store = store.clone(); + picker_store.profiles.insert( + "oauth-a-duplicate".into(), + picker_store.profiles["oauth-a"].clone(), + ); + assert_eq!(saved_login_names(&picker_store, true).len(), 4); + assert_eq!(saved_login_names(&picker_store, false).len(), 3); } #[tokio::test] - async fn persist_post_login_context_clears_stale_project_for_org_only_login() { + async fn post_login_context_preserves_only_same_org_projects() { 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 save = |org: &str| { + crate::config::save_global(&crate::config::Config { + org: Some(org.into()), + project: Some("test-project".into()), + project_id: Some("proj_test".into()), + ..Default::default() + }) + .unwrap(); + }; + let persist = |org: Option| async move { + persist_post_login_context( + &make_base(), + "test-credential", + "https://api.example.test", + "https://www.example.test", + org.as_ref(), + &config::ScopeArgs { + global: true, + local: false, + }, + ) + .await + .unwrap(); + crate::config::load_global().unwrap() + }; - 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")), - ) - .await - .expect("persist context"); - let cfg = crate::config::load_global().expect("load global config"); + save("old-org"); + let cfg = persist(Some(login_org("org_test", "test-org"))).await; + assert_eq!((cfg.org.as_deref(), cfg.project), (Some("test-org"), None)); + + save("test-org"); + let cfg = persist(Some(login_org("org_test", "test-org"))).await; + assert_eq!( + (cfg.project.as_deref(), cfg.project_id.as_deref()), + (Some("test-project"), Some("proj_test")) + ); - 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); + save(""); + let cfg = persist(None).await; + assert_eq!( + (cfg.org.as_deref(), cfg.project, cfg.project_id), + (Some(""), None, None) + ); } #[tokio::test] @@ -4984,186 +5397,230 @@ mod tests { } #[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); - } - - #[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")); - } - - #[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) - }, - ) - .expect("resolve"); - - assert_eq!(resolution, RequestedOrgResolution::SwitchToOauth); - } - - #[test] - fn resolve_requested_org_for_api_key_login_can_continue_with_api_key() { - let orgs = vec![login_org("org_1", "braintrustdata.com")]; - - let resolution = resolve_requested_org_for_api_key_login( + 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("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) - }, + Some("other-org"), + false, + no_prompt ) - .expect("resolve"); - - assert_eq!(resolution, RequestedOrgResolution::IgnoreRequestedOrg); - } - - #[test] - fn obscure_api_key_standard() { - assert_eq!(obscure_api_key("sk-LumEdp0BbLRzhJwO"), "sk-****zhJwO"); - } - - #[test] - fn obscure_api_key_short() { - assert_eq!(obscure_api_key("abc"), "****"); - } - - #[test] - fn obscure_api_key_no_dash() { - assert_eq!(obscure_api_key("abcdefghijklm"), "****ijklm"); - } - - #[test] - fn obscure_api_key_non_ascii() { - assert_eq!(obscure_api_key("sk-café-résumé-key"), "****"); - } + .unwrap_err() + .to_string() + .contains("org 'other-org' not found. Available: test-org")); - #[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")); + 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"), + ("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()), + org: org.map(str::to_string), + org_id: None, + user_name: None, + user_email: None, api_key_hint: 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 — oauth — org: acme — Alice (alice@example.com)" + 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_ok_with_api_key_hint() { - let v = ProfileVerification { - name: "work".into(), - auth: "api_key".into(), - org: Some("acme".into()), - user_name: None, - user_email: None, - api_key_hint: Some("sk-****zhJwO".into()), - status: "ok".into(), - error: None, - }; + fn saved_auth_logins_are_grouped_by_org() { + let mut store = AuthStore::default(); + for (name, org) in [ + ("profile-z", "test-org-a"), + ("profile-a", "test-org-b"), + ("profile-m", "test-org-a"), + ] { + store.profiles.insert( + name.into(), + AuthProfile { + org_name: Some(org.into()), + ..Default::default() + }, + ); + } + + let order = profiles_grouped_by_org(&store) + .into_iter() + .map(|(name, profile)| (profile_org(profile), name)) + .collect::>(); assert_eq!( - format_verification_line(&v), - "work — api_key — org: acme — sk-****zhJwO" + order, + vec![ + ("test-org-a", "profile-m"), + ("test-org-a", "profile-z"), + ("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), + 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, + ), + "test-org — 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), + "cross-org — 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] @@ -5191,80 +5648,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] @@ -5275,13 +5696,13 @@ 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 ctx = env - .login_read_only_probe(Some("acme")) + .login_read_only_probe(Some("test-org")) .await .expect("fast path should succeed"); - assert_eq!(ctx.login.org_name().as_deref(), Some("acme")); + assert_eq!(ctx.login.org_name().as_deref(), Some("test-org")); assert_eq!(ctx.login.org_id().as_deref(), Some("")); assert_eq!(ctx.api_url, "not-a-valid-url"); } @@ -5293,9 +5714,13 @@ mod tests { } #[tokio::test] - async fn login_read_only_cached_project_id_but_whitespace_org_falls_back_to_login() { + async fn login_read_only_cached_project_id_but_whitespace_org_is_cross_org() { let env = TestEnv::new(Some("proj_123"), None).await; - assert_invalid_api_url(env.login_read_only_probe(Some(" ")).await); + let err = match env.login_read_only_probe(Some(" ")).await { + Ok(_) => panic!("whitespace org should be canonical cross-org"), + Err(err) => err, + }; + assert!(err.to_string().contains("concrete org")); } #[tokio::test] @@ -5337,19 +5762,21 @@ 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()); 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.rs b/src/config.rs new file mode 100644 index 00000000..a3b09181 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,714 @@ +use anyhow::{anyhow, bail, Context, Result}; +use clap::Args; +use std::{ + env, fs, + io::{self, Write as _}, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; + +use crate::args::BaseArgs; +use crate::ui::{print_command_status, CommandStatus}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct Config { + pub org: Option, + pub project: Option, + pub project_id: Option, + #[serde(flatten)] + pub extra: serde_json::Map, +} + +impl Config { + pub(crate) fn set_context(&mut self, org: Option<&str>, project: Option<(&str, &str)>) { + self.org = org_option(org).map(str::to_string); + (self.project, self.project_id) = project + .map(|(name, id)| (name.to_string(), id.to_string())) + .unzip(); + } + + pub(crate) fn merge(&self, local: &Config) -> Config { + let mut extra = self.extra.clone(); + extra.extend(local.extra.clone()); + let global_id = self.project.as_ref().and(self.project_id.clone()); + let (org, project, project_id) = match (&local.org, &local.project) { + (Some(org), Some(project)) => ( + Some(org.clone()), + Some(project.clone()), + local.project_id.clone(), + ), + (Some(org), None) if self.org.as_ref() == Some(org) => { + (Some(org.clone()), self.project.clone(), global_id) + } + (Some(org), None) => (Some(org.clone()), None, None), + (None, Some(project)) => (None, Some(project.clone()), local.project_id.clone()), + (None, None) => (self.org.clone(), self.project.clone(), global_id), + }; + Config { + org, + project, + project_id, + extra, + } + } +} + +pub fn global_config_dir() -> Result { + if let Some(xdg) = env::var_os("XDG_CONFIG_HOME") { + return Ok(PathBuf::from(xdg).join("bt")); + } + dirs::home_dir() + .map(|path| path.join(".config").join("bt")) + .ok_or_else(|| anyhow!("$HOME not configured.")) +} + +pub fn global_path() -> Result { + Ok(global_config_dir()?.join("config.json")) +} + +pub fn load_file(path: &Path) -> Config { + let file_contents = match fs::read_to_string(path) { + Ok(c) => c, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Config::default(), + Err(e) => { + print_command_status( + CommandStatus::Error, + &format!("Warning: could not read {}: {e}", path.display()), + ); + return Config::default(); + } + }; + + let mut config: Config = match serde_json::from_str(&file_contents) { + Ok(c) => c, + Err(e) => { + print_command_status( + CommandStatus::Error, + &format!("Warning: could not read {}: {e}", path.display()), + ); + return Config::default(); + } + }; + + config.extra.remove("profile"); + + // Fold a literal "cross-org" to the canonical "" marker on load. + if let Some(org) = config.org.as_deref() { + let normalized = normalize_org(org); + if normalized != org { + config.org = Some(normalized.to_string()); + } + } + + for key in config.extra.keys() { + print_command_status( + CommandStatus::Error, + &format!("Warning: unknown config key {} in {}", key, path.display()), + ); + } + + config +} + +pub fn load_global() -> Result { + Ok(load_file(&global_path()?)) +} + +pub fn load() -> Result { + let global = load_global().unwrap_or_default(); + let local = match local_path() { + Some(p) => load_file(&p), + None => Config::default(), + }; + Ok(global.merge(&local)) +} + +pub fn configured_project_for_context( + base: &BaseArgs, + resolved_org: Option<&str>, +) -> Option { + load() + .ok() + .and_then(|cfg| project_from_config_for_context(base, &cfg, resolved_org)) +} + +pub fn configured_project_id_for_base(base: &BaseArgs) -> Option { + load().ok().and_then(|cfg| { + config_matches_context(base, &cfg, None) + .then(|| trimmed_option(cfg.project_id.as_deref()).map(str::to_string)) + .flatten() + }) +} + +pub(crate) fn project_from_config_for_context( + base: &BaseArgs, + cfg: &Config, + resolved_org: Option<&str>, +) -> Option { + config_matches_context(base, cfg, resolved_org) + .then(|| trimmed_option(cfg.project.as_deref()).map(str::to_string)) + .flatten() +} + +fn config_matches_context(base: &BaseArgs, cfg: &Config, resolved_org: Option<&str>) -> bool { + 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)) +} + +/// Trim an org while preserving the empty cross-org marker. +pub(crate) fn org_option(value: Option<&str>) -> Option<&str> { + value.map(str::trim) +} + +/// Human-facing spelling of the empty cross-org marker. +pub(crate) const CROSS_ORG_ALIAS: &str = "cross-org"; + +/// Trim an org and fold the [`CROSS_ORG_ALIAS`] to the canonical `""` marker. +pub(crate) fn normalize_org(value: &str) -> &str { + let trimmed = value.trim(); + if trimmed == CROSS_ORG_ALIAS { + "" + } else { + trimmed + } +} + +pub(crate) fn display_org(org: &str) -> &str { + if org.is_empty() { + CROSS_ORG_ALIAS + } else { + org + } +} + +pub(crate) fn trimmed_option(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|value| !value.is_empty()) +} + +pub fn save_file(path: &Path, config: &Config) -> Result<()> { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent)?; + + let json = serde_json::to_string_pretty(config)?; + let mut file = tempfile::NamedTempFile::new_in(parent)?; + file.write_all(json.as_bytes())?; + file.write_all(b"\n")?; + file.as_file().sync_all()?; + file.persist(path)?; + + Ok(()) +} + +pub fn save_global(config: &Config) -> Result<()> { + save_file(&global_path()?, config) +} + +pub fn find_local_config_dir() -> Option { + find_local_config_dir_from(std::env::current_dir().ok()?, dirs::home_dir().as_deref()) +} + +enum ProjectBoundary { + Bt(PathBuf), + Git(PathBuf), + Home, + Root, +} + +fn project_boundary(start: PathBuf, home: Option<&Path>) -> ProjectBoundary { + for dir in start.ancestors() { + if Some(dir) == home { + return ProjectBoundary::Home; + } + if dir.parent().is_none() { + return ProjectBoundary::Root; + } + let bt = dir.join(".bt"); + if bt.is_dir() { + return ProjectBoundary::Bt(bt); + } + if dir.join(".git").exists() { + return ProjectBoundary::Git(dir.to_path_buf()); + } + } + unreachable!("path ancestors always include a filesystem root") +} + +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 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 { + 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.") + } + 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 { + Ok(std::env::current_dir()?.join(".bt").join("config.json")) +} + +pub fn save_local(config: &Config, create_dir: bool) -> Result { + let path = local_save_path()?; + let dir = path.parent().expect(".bt parent directory"); + if create_dir && !dir.exists() { + fs::create_dir_all(dir)?; + } + save_file(&path, config)?; + Ok(path) +} + +// --- Config scope selection --- + +#[derive(Debug, Clone, Default, Args)] +pub struct ScopeArgs { + /// Use global config (~/.config/bt/config.json) + #[arg(long, short = 'g', conflicts_with = "local")] + pub(crate) global: bool, + + /// Use local config (.bt/config.json) + #[arg(long, short = 'l')] + 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") + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + 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 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 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(""), Some(""), None), + (Some(" "), Some(""), 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(Some(" test-org "), Some(("test-project", "proj_test"))); + assert_eq!(cfg.org.as_deref(), Some("test-org")); + assert_eq!(cfg.project.as_deref(), Some("test-project")); + assert_eq!(cfg.project_id.as_deref(), Some("proj_test")); + cfg.set_context(Some(""), None); + assert_eq!((cfg.org.as_deref(), cfg.project), (Some(""), None)); + } + + fn base_args() -> BaseArgs { + BaseArgs::default() + } + + fn config(org: Option<&str>, project: Option<&str>) -> Config { + Config { + org: org.map(str::to_string), + project: project.map(str::to_string), + ..Default::default() + } + } + + #[test] + 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(resolved_org)).as_deref(), + expected + ); + } + } + + #[test] + fn load_missing_file_returns_default() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("nonexistent.json"); + let config = load_file(&path); + assert_eq!(config.org, None); + assert_eq!(config.project, None); + } + + #[test] + fn load_invalid_json_returns_default() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("invalid.json"); + fs::write(&path, "not valid json {{{").unwrap(); + let config = load_file(&path); + assert_eq!(config.org, None); + } + + #[test] + fn save_load_roundtrip() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("config.json"); + + let original = Config { + org: Some("test-org".into()), + project: Some("test-project".into()), + ..Default::default() + }; + + save_file(&path, &original).unwrap(); + let loaded = load_file(&path); + + assert_eq!(loaded.org, original.org); + assert_eq!(loaded.project, original.project); + } + + #[test] + fn load_unknown_keys_still_returns_config() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("config.json"); + fs::write( + &path, + r#"{"org": "my-org", "unknown_field": "value", "another": 123}"#, + ) + .unwrap(); + + let config = load_file(&path); + assert_eq!(config.org, Some("my-org".into())); + assert!(config.extra.contains_key("unknown_field")); + 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_folds_cross_org_alias_to_empty_marker() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("config.json"); + // Literal "cross-org" must load identically to the "" marker. + for spelling in [ + r#"{"org":"cross-org"}"#, + r#"{"org":" cross-org "}"#, + r#"{"org":""}"#, + ] { + fs::write(&path, spelling).unwrap(); + assert_eq!(load_file(&path).org.as_deref(), Some(""), "{spelling}"); + } + + fs::write(&path, r#"{"org":"test-org"}"#).unwrap(); + assert_eq!(load_file(&path).org.as_deref(), Some("test-org")); + } + + #[test] + fn unknown_keys_roundtrip_through_save() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("config.json"); + fs::write( + &path, + r#"{"org": "my-org", "unknown_field": "value", "another": 123}"#, + ) + .unwrap(); + + let config = load_file(&path); + save_file(&path, &config).unwrap(); + let reloaded = load_file(&path); + + assert_eq!(reloaded.org, Some("my-org".into())); + assert!(reloaded.extra.contains_key("unknown_field")); + assert!(reloaded.extra.contains_key("another")); + } + + #[test] + fn save_creates_parent_dirs() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("nested").join("dir").join("config.json"); + + let config = Config { + org: Some("test".into()), + ..Default::default() + }; + + 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/config/get.rs b/src/config/get.rs deleted file mode 100644 index c7dcd1a2..00000000 --- a/src/config/get.rs +++ /dev/null @@ -1,30 +0,0 @@ -use anyhow::{bail, Result}; - -use crate::args::BaseArgs; - -pub fn run(base: BaseArgs, key: &str, global: bool, local: bool) -> Result<()> { - let cfg = if global { - super::load_global()? - } else if local { - match super::local_path() { - Some(p) => super::load_file(&p), - None => super::Config::default(), - } - } else { - super::load()? - }; - - match cfg.get_field(key) { - Some(value) => { - if base.json { - println!("{}", serde_json::to_string(value)?); - } else { - println!("{value}"); - } - Ok(()) - } - None => { - bail!("Config key '{key}' is not set.") - } - } -} diff --git a/src/config/list.rs b/src/config/list.rs deleted file mode 100644 index d21f39b0..00000000 --- a/src/config/list.rs +++ /dev/null @@ -1,214 +0,0 @@ -use anyhow::Result; -use serde_json::{Map, Value}; - -use crate::args::BaseArgs; - -pub fn run(base: BaseArgs, global: bool, local: bool, verbose: bool) -> Result<()> { - if verbose { - run_verbose(base, global, local) - } else { - run_resolved(base, global, local) - } -} - -fn run_resolved(base: BaseArgs, global: bool, local: bool) -> Result<()> { - let config = if global { - super::load_global()? - } else if local { - super::local_path() - .map(|p| super::load_file(&p)) - .unwrap_or_default() - } else { - super::load()? - }; - - let output = format_resolved(&config, base.json)?; - if !output.is_empty() { - println!("{output}"); - } - - Ok(()) -} - -fn format_resolved(config: &super::Config, json: bool) -> Result { - let fields = config.non_empty_fields(); - - if json { - let map: Map = fields - .iter() - .map(|(k, v)| (k.to_string(), Value::String(v.to_string()))) - .collect(); - Ok(serde_json::to_string(&map)?) - } else { - Ok(fields - .iter() - .map(|(k, v)| format!("{k}: {v}")) - .collect::>() - .join("\n")) - } -} - -fn run_verbose(base: BaseArgs, global: bool, local: bool) -> Result<()> { - let global_path = super::global_path().ok(); - let local_path = super::local_path(); - - let global_cfg = if !local { - global_path - .as_ref() - .map(|p| (p.display().to_string(), super::load_file(p))) - } else { - None - }; - - let local_cfg = if !global { - local_path.as_ref().map(|p| { - let display_path = std::env::current_dir() - .ok() - .and_then(|cwd| pathdiff::diff_paths(p, &cwd)) - .unwrap_or_else(|| p.clone()) - .display() - .to_string(); - (display_path, super::load_file(p)) - }) - } else { - None - }; - - let mut sources: Vec<(String, Vec<(&str, &str)>)> = Vec::new(); - - if let Some((path, ref cfg)) = global_cfg { - let fields = cfg.non_empty_fields(); - if !fields.is_empty() { - sources.push((path, fields)); - } - } - - if let Some((path, ref cfg)) = local_cfg { - let fields = cfg.non_empty_fields(); - if !fields.is_empty() { - sources.push((path, fields)); - } - } - - let output = format_verbose(&sources, base.json)?; - if !output.is_empty() { - println!("{output}"); - } - - Ok(()) -} - -fn format_verbose(sources: &[(String, Vec<(&str, &str)>)], json: bool) -> Result { - if json { - let mut map = Map::new(); - for (path, fields) in sources { - let o: Map = fields - .iter() - .map(|(k, v)| (k.to_string(), Value::String(v.to_string()))) - .collect(); - map.insert(path.clone(), Value::Object(o)); - } - Ok(serde_json::to_string(&map)?) - } else { - let mut parts = Vec::new(); - for (path, fields) in sources { - let mut group = String::from(path.as_str()); - for (key, value) in fields { - group.push_str(&format!("\n {key}: {value}")); - } - parts.push(group); - } - Ok(parts.join("\n\n")) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::Config; - - fn config_with(org: &str, project: &str) -> Config { - Config { - org: Some(org.into()), - project: Some(project.into()), - ..Default::default() - } - } - - // --- format_resolved tests --- - - #[test] - fn resolved_text_shows_merged() { - let config = config_with("acme", "widgets"); - let out = format_resolved(&config, false).unwrap(); - assert_eq!(out, "org: acme\nproject: widgets"); - } - - #[test] - fn resolved_text_empty_config() { - let out = format_resolved(&Config::default(), false).unwrap(); - assert_eq!(out, ""); - } - - #[test] - fn resolved_json_flat_object() { - let config = config_with("acme", "widgets"); - let out = format_resolved(&config, true).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&out).unwrap(); - assert_eq!(parsed["org"], "acme"); - assert_eq!(parsed["project"], "widgets"); - } - - #[test] - fn resolved_json_empty() { - let out = format_resolved(&Config::default(), true).unwrap(); - assert_eq!(out, "{}"); - } - - // --- format_verbose tests --- - - #[test] - fn verbose_text_two_sources() { - let sources: Vec<(String, Vec<(&str, &str)>)> = vec![ - ("~/.bt/config.json".into(), vec![("org", "global-org")]), - (".bt/config.json".into(), vec![("project", "local-proj")]), - ]; - let out = format_verbose(&sources, false).unwrap(); - assert_eq!( - out, - "~/.bt/config.json\n org: global-org\n\n.bt/config.json\n project: local-proj" - ); - } - - #[test] - fn verbose_text_single_sources() { - let sources: Vec<(String, Vec<(&str, &str)>)> = vec![( - ".bt/config.json".into(), - vec![("org", "global-org"), ("project", "local-proj")], - )]; - let out = format_verbose(&sources, false).unwrap(); - assert_eq!( - out, - ".bt/config.json\n org: global-org\n project: local-proj" - ); - } - - #[test] - fn verbose_json_nested_by_path() { - let sources: Vec<(String, Vec<(&str, &str)>)> = vec![ - ("~/.bt/config.json".into(), vec![("org", "global-org")]), - (".bt/config.json".into(), vec![("project", "local-proj")]), - ]; - let out = format_verbose(&sources, true).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&out).unwrap(); - assert_eq!(parsed["~/.bt/config.json"]["org"], "global-org"); - assert_eq!(parsed[".bt/config.json"]["project"], "local-proj"); - } - - #[test] - fn verbose_json_empty() { - let sources: Vec<(String, Vec<(&str, &str)>)> = vec![]; - let out = format_verbose(&sources, true).unwrap(); - assert_eq!(out, "{}"); - } -} diff --git a/src/config/mod.rs b/src/config/mod.rs deleted file mode 100644 index 779499d5..00000000 --- a/src/config/mod.rs +++ /dev/null @@ -1,574 +0,0 @@ -use anyhow::{anyhow, bail, Result}; -use clap::{Args, Subcommand}; -use std::{ - env, fs, - io::{self, Write as _}, - path::{Path, PathBuf}, -}; - -use serde::{Deserialize, Serialize}; - -use crate::args::BaseArgs; -use crate::ui::{print_command_status, CommandStatus}; - -mod get; -mod list; -mod set; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -#[serde(default)] -pub struct Config { - pub profile: Option, - pub org: Option, - pub project: Option, - pub project_id: Option, - #[serde(flatten)] - pub extra: serde_json::Map, -} - -pub const KNOWN_KEYS: &[&str] = &["profile", "org", "project", "project_id"]; - -impl Config { - pub fn get_field(&self, key: &str) -> Option<&str> { - match key { - "profile" => self.profile.as_deref(), - "org" => self.org.as_deref(), - "project" => self.project.as_deref(), - "project_id" => self.project_id.as_deref(), - _ => None, - } - } - - pub fn set_field(&mut self, key: &str, value: String) -> bool { - match key { - "profile" => self.profile = Some(value), - "org" => self.org = Some(value), - "project" => { - self.project = Some(value); - self.project_id = None; - } - "project_id" => self.project_id = Some(value), - _ => return false, - } - true - } - - pub fn unset_field(&mut self, key: &str) -> bool { - match key { - "profile" => self.profile = None, - "org" => self.org = None, - "project" => { - self.project = None; - self.project_id = None; - } - "project_id" => self.project_id = None, - _ => return false, - } - true - } - - pub fn non_empty_fields(&self) -> Vec<(&str, &str)> { - KNOWN_KEYS - .iter() - .filter_map(|&key| self.get_field(key).map(|v| (key, v))) - .collect() - } - - pub(crate) fn merge(&self, other: &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() - }; - Config { - profile: other.profile.clone().or_else(|| self.profile.clone()), - org: other.org.clone().or_else(|| self.org.clone()), - project, - project_id, - extra, - } - } -} - -pub fn global_config_dir() -> Result { - if let Some(xdg) = env::var_os("XDG_CONFIG_HOME") { - return Ok(PathBuf::from(xdg).join("bt")); - } - dirs::home_dir() - .map(|path| path.join(".config").join("bt")) - .ok_or_else(|| anyhow!("$HOME not configured.")) -} - -pub fn global_path() -> Result { - Ok(global_config_dir()?.join("config.json")) -} - -pub fn load_file(path: &Path) -> Config { - let file_contents = match fs::read_to_string(path) { - Ok(c) => c, - Err(e) if e.kind() == io::ErrorKind::NotFound => return Config::default(), - Err(e) => { - print_command_status( - CommandStatus::Error, - &format!("Warning: could not read {}: {e}", path.display()), - ); - return Config::default(); - } - }; - - let config: Config = match serde_json::from_str(&file_contents) { - Ok(c) => c, - Err(e) => { - print_command_status( - CommandStatus::Error, - &format!("Warning: could not read {}: {e}", path.display()), - ); - return Config::default(); - } - }; - - for key in config.extra.keys() { - print_command_status( - CommandStatus::Error, - &format!("Warning: unknown config key {} in {}", key, path.display()), - ); - } - - config -} - -pub fn load_global() -> Result { - Ok(load_file(&global_path()?)) -} - -pub fn load() -> Result { - let global = load_global().unwrap_or_default(); - let local = match local_path() { - Some(p) => load_file(&p), - None => Config::default(), - }; - Ok(global.merge(&local)) -} - -pub fn configured_project_for_context( - base: &BaseArgs, - resolved_org: Option<&str>, -) -> Option { - load() - .ok() - .and_then(|cfg| project_from_config_for_context(base, &cfg, resolved_org)) -} - -pub fn configured_project_id_for_base(base: &BaseArgs) -> Option { - load().ok().and_then(|cfg| { - config_matches_context(base, &cfg, None) - .then(|| trimmed_option(cfg.project_id.as_deref()).map(str::to_string)) - .flatten() - }) -} - -pub(crate) fn project_from_config_for_context( - base: &BaseArgs, - cfg: &Config, - resolved_org: Option<&str>, -) -> Option { - config_matches_context(base, cfg, resolved_org) - .then(|| trimmed_option(cfg.project.as_deref()).map(str::to_string)) - .flatten() -} - -fn config_matches_context(base: &BaseArgs, cfg: &Config, resolved_org: Option<&str>) -> bool { - let selected_profile = trimmed_option(base.profile.as_deref()); - let cfg_profile = trimmed_option(cfg.profile.as_deref()); - 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), - } -} - -pub(crate) fn trimmed_option(value: Option<&str>) -> Option<&str> { - value.map(str::trim).filter(|value| !value.is_empty()) -} - -pub fn save_file(path: &Path, config: &Config) -> Result<()> { - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - fs::create_dir_all(parent)?; - - let json = serde_json::to_string_pretty(config)?; - let mut file = tempfile::NamedTempFile::new_in(parent)?; - file.write_all(json.as_bytes())?; - file.write_all(b"\n")?; - file.as_file().sync_all()?; - file.persist(path)?; - - Ok(()) -} - -pub fn save_global(config: &Config) -> Result<()> { - save_file(&global_path()?, config) -} - -pub fn find_local_config_dir() -> Option { - let home = dirs::home_dir(); - let mut current_dir = std::env::current_dir().ok()?; - - loop { - if current_dir.join(".bt").is_dir() { - return Some(current_dir.join(".bt")); - } - if current_dir.join(".git").exists() { - return None; - } - if Some(¤t_dir) == home.as_ref() { - return None; - } - if !current_dir.pop() { - return None; - } - } -} - -pub fn local_path() -> Option { - find_local_config_dir().map(|dir| dir.join("config.json")) -} - -pub enum WriteTarget { - Global(PathBuf), - Local(PathBuf), -} - -pub fn write_target() -> Result { - match local_path() { - Some(p) => Ok(WriteTarget::Local(p)), - None => Ok(WriteTarget::Global(global_path()?)), - } -} - -/// 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.") - } - } - } else { - match write_target()? { - WriteTarget::Local(p) | WriteTarget::Global(p) => Ok(p), - } - } -} - -pub fn local_save_path() -> Result { - Ok(std::env::current_dir()?.join(".bt").join("config.json")) -} - -pub fn save_local(config: &Config, create_dir: bool) -> Result { - let path = local_save_path()?; - let dir = path.parent().expect(".bt parent directory"); - if create_dir && !dir.exists() { - fs::create_dir_all(dir)?; - } - save_file(&path, config)?; - Ok(path) -} - -// --- CLI commands --- - -#[derive(Debug, Clone, Args)] -pub struct ScopeArgs { - /// Apply to global config (~/.config/bt/config.json) - #[arg(long, short = 'g', conflicts_with = "local")] - global: bool, - - /// Apply to local config (.bt/config.json) - #[arg(long, short = 'l')] - local: bool, -} - -#[derive(Debug, Clone, Args)] -pub struct ConfigArgs { - #[command(subcommand)] - command: Option, -} - -#[derive(Debug, Clone, Subcommand)] -enum ConfigCommands { - /// List config values - List { - #[command(flatten)] - scope: ScopeArgs, - /// Show config values grouped by source - #[arg(long)] - verbose: bool, - }, - /// Get a config value - Get { - /// Config key (profile, org, project, project_id) - key: String, - #[command(flatten)] - scope: ScopeArgs, - }, - /// Set a config value - Set { - /// Config key (profile, org, project, project_id) - key: String, - /// Value to set - value: String, - #[command(flatten)] - scope: ScopeArgs, - }, - /// Remove a config value - Unset { - /// Config key (profile, org, project, project_id) - key: String, - #[command(flatten)] - scope: ScopeArgs, - }, -} - -fn validate_key(key: &str) -> Result<()> { - if !KNOWN_KEYS.contains(&key) { - bail!( - "Unknown config key: {key}\nValid keys: {}", - KNOWN_KEYS.join(", ") - ); - } - Ok(()) -} - -pub fn run(base: BaseArgs, args: ConfigArgs) -> Result<()> { - match args.command { - None => list::run(base, false, false, false), - Some(ConfigCommands::List { scope, verbose }) => { - list::run(base, scope.global, scope.local, verbose) - } - Some(ConfigCommands::Get { key, scope }) => { - validate_key(&key)?; - get::run(base, &key, scope.global, scope.local) - } - Some(ConfigCommands::Set { key, value, scope }) => { - validate_key(&key)?; - set::run(&key, &value, scope.global, scope.local) - } - Some(ConfigCommands::Unset { key, scope }) => { - validate_key(&key)?; - set::unset(&key, scope.global, scope.local) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - 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()), - ..Default::default() - }; - let merged = base.merge(&other); - assert_eq!(merged.org, Some("other-org".into())); - assert_eq!(merged.project, Some("other-proj".into())); - } - - #[test] - fn merge_self_fills_when_other_none() { - let base = Config { - org: Some("base-org".into()), - project: Some("base-proj".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())); - } - - #[test] - fn merge_both_none_stays_none() { - let base = Config::default(); - let other = Config::default(); - let merged = base.merge(&other); - assert_eq!(merged.org, None); - assert_eq!(merged.project, None); - } - - #[test] - fn merge_partial_fill() { - let base = Config { - org: Some("base-org".into()), - project: None, - ..Default::default() - }; - let other = Config { - org: None, - project: Some("other-proj".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, - } - } - - fn config(profile: Option<&str>, 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() - } - } - - #[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 { - assert_eq!( - project_from_config_for_context(&base, &cfg, Some("acme")).as_deref(), - expected - ); - } - } - - #[test] - fn load_missing_file_returns_default() { - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join("nonexistent.json"); - let config = load_file(&path); - assert_eq!(config.org, None); - assert_eq!(config.project, None); - } - - #[test] - fn load_invalid_json_returns_default() { - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join("invalid.json"); - fs::write(&path, "not valid json {{{").unwrap(); - let config = load_file(&path); - assert_eq!(config.org, None); - } - - #[test] - fn save_load_roundtrip() { - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join("config.json"); - - let original = Config { - org: Some("test-org".into()), - project: Some("test-project".into()), - ..Default::default() - }; - - save_file(&path, &original).unwrap(); - let loaded = load_file(&path); - - assert_eq!(loaded.org, original.org); - assert_eq!(loaded.project, original.project); - } - - #[test] - fn load_unknown_keys_still_returns_config() { - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join("config.json"); - fs::write( - &path, - r#"{"org": "my-org", "unknown_field": "value", "another": 123}"#, - ) - .unwrap(); - - let config = load_file(&path); - assert_eq!(config.org, Some("my-org".into())); - assert!(config.extra.contains_key("unknown_field")); - assert!(config.extra.contains_key("another")); - } - - #[test] - fn unknown_keys_roundtrip_through_save() { - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join("config.json"); - fs::write( - &path, - r#"{"org": "my-org", "unknown_field": "value", "another": 123}"#, - ) - .unwrap(); - - let config = load_file(&path); - save_file(&path, &config).unwrap(); - let reloaded = load_file(&path); - - assert_eq!(reloaded.org, Some("my-org".into())); - assert!(reloaded.extra.contains_key("unknown_field")); - assert!(reloaded.extra.contains_key("another")); - } - - #[test] - fn save_creates_parent_dirs() { - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join("nested").join("dir").join("config.json"); - - let config = Config { - org: Some("test".into()), - ..Default::default() - }; - - save_file(&path, &config).unwrap(); - assert!(path.exists()); - } -} diff --git a/src/config/set.rs b/src/config/set.rs deleted file mode 100644 index 981753da..00000000 --- a/src/config/set.rs +++ /dev/null @@ -1,27 +0,0 @@ -use anyhow::Result; - -use crate::ui::{print_command_status, CommandStatus}; - -pub fn run(key: &str, value: &str, global: bool, local: bool) -> Result<()> { - let path = super::resolve_write_path(global, local)?; - let mut cfg = super::load_file(&path); - - cfg.set_field(key, value.to_string()); - - super::save_file(&path, &cfg)?; - - print_command_status(CommandStatus::Success, &format!("Set {key} = {value}")); - Ok(()) -} - -pub fn unset(key: &str, global: bool, local: bool) -> Result<()> { - let path = super::resolve_write_path(global, local)?; - let mut cfg = super::load_file(&path); - - cfg.unset_field(key); - - super::save_file(&path, &cfg)?; - - print_command_status(CommandStatus::Success, &format!("Unset {key}")); - Ok(()) -} 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..337dd74e 100644 --- a/src/datasets/snapshots.rs +++ b/src/datasets/snapshots.rs @@ -8,11 +8,12 @@ use serde_json::json; use crate::{ args::BaseArgs, + auth, ui::{ apply_column_padding, header, is_interactive, print_command_status, print_with_pager, styled_table, truncate, with_spinner, with_spinner_visible, CommandStatus, }, - utils::{pluralize, profile_author_slug, resolve_profile_info, sanitize_name_segment}, + utils::{pluralize, profile_author_slug, sanitize_name_segment}, }; use super::{ @@ -1092,7 +1093,9 @@ fn resolve_default_snapshot_author(base: &BaseArgs, ctx: &ResolvedContext) -> Op return None; } - let profile = resolve_profile_info(base.profile.as_deref(), Some(ctx.client.org_name()))?; + let profile = auth::active_auth_info(base, Some(ctx.client.org_name())) + .ok() + .flatten()?; profile_author_slug(&profile) } @@ -1102,11 +1105,13 @@ fn default_snapshot_name(author: &str, now: DateTime) -> String { } fn api_key_override_active(base: &BaseArgs) -> bool { - !base.prefer_profile - && base - .api_key - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) + matches!( + base.api_key_source, + Some(crate::args::ArgValueSource::CommandLine) + ) && base + .api_key + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) } #[cfg(test)] diff --git a/src/eval.rs b/src/eval.rs index ebc4f4dc..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..341ec84d 100644 --- a/src/experiments/mod.rs +++ b/src/experiments/mod.rs @@ -219,17 +219,12 @@ fn apply_experiment_url_hints_to_base( } } - let has_profile_override = base - .profile - .as_deref() - .map(str::trim) - .is_some_and(|v| !v.is_empty()); let has_org_override = base .org_name .as_deref() .map(str::trim) .is_some_and(|v| !v.is_empty()); - if !has_profile_override && !has_org_override { + if !has_org_override { if let Some(org) = parsed_url .org .as_deref() diff --git a/src/functions/push.rs b/src/functions/push.rs index 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..ae57e137 100644 --- a/src/init.rs +++ b/src/init.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Result}; +use anyhow::{bail, Context, Result}; use clap::Args; use crate::{ @@ -6,88 +6,90 @@ use crate::{ auth::{self, login}, config, http::ApiClient, - ui::{is_interactive, print_command_status, select_project, CommandStatus, ProjectSelectMode}, + ui::{print_command_status, select_or_create_project, 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(()); + /// Overwrite an existing .bt/config.json. Does not change discovery. + #[arg(long, short = 'f')] + force: bool, +} + +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 mut login_base = base.clone(); + login_base.project = None; + login_base.project_source = None; + if login_base.org_name.is_none() + && !auth::select_saved_login(&mut login_base, current_cfg.org.as_deref(), false)? + { + bail!("no saved concrete-org login is available; run `bt auth login --org `"); } - eprintln!("Link to a Braintrust project..."); + let ctx = login(&login_base).await?; + let client = ApiClient::new(&ctx)?; + let org = client.org_name().to_string(); + if org.is_empty() { + bail!( + "cross-org mode has no project; `bt init` is project-scoped. Rerun with --org --project " + ); + } - 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"); - } 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 project = select_or_create_project( + &client, + base.project.as_deref(), + None, + Some("Link to project"), + ) + .await?; + let mut cfg = config::Config::default(); + cfg.set_context( + Some(&org), + Some((project.name.as_str(), project.id.as_str())), + ); - let org = client.org_name().to_string(); - let project = select_project( - &client, - None, - Some("Link to project"), - ProjectSelectMode::ExistingOnly, + 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() ) - .await? - .name; - - (org, project) - }; - - let cfg = config::Config { - org: Some(org.clone()), - project: Some(project.clone()), - ..Default::default() - }; - - let written_path = config::save_local(&cfg, true)?; + })?; if base.json { let payload = serde_json::json!({ "initialized": true, "status": "created", "org": org, - "project": project, - "path": written_path.display().to_string(), + "project": project.name, + "project_id": project.id, + "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}/{}", 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..25abbf87 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,7 +35,7 @@ mod ui; mod util_cmd; mod utils; -use crate::args::{has_explicit_profile_arg, ArgValueSource, BaseArgs, CLIArgs}; +use crate::args::{ArgValueSource, BaseArgs, CLIArgs}; const DEFAULT_CANARY_VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), "-canary.dev"); pub(crate) const CLI_VERSION: &str = match option_env!("BT_VERSION_STRING") { @@ -84,9 +84,9 @@ Additional update Update bt in-place Flags - --profile Use a saved login profile [env: BRAINTRUST_PROFILE] -o, --org Override active org [env: BRAINTRUST_ORG_NAME] - -p, --project Override active project [env: BRAINTRUST_DEFAULT_PROJECT] + -p, --project Override active project [env: BRAINTRUST_DEFAULT_PROJECT] + --prefer-api-key Prefer API key credentials for the selected org [env: BRAINTRUST_PREFER_API_KEY=] --json Output as JSON -v, --verbose Increase output verbosity [env: BRAINTRUST_VERBOSE=] -q, --quiet Reduce interactive UI output [env: BRAINTRUST_QUIET=] @@ -165,8 +165,6 @@ enum Commands { Switch(CLIArgs), /// Show current org and project context Status(CLIArgs), - // /// View and modify config - // Config(CLIArgs), } impl Commands { @@ -296,7 +294,6 @@ fn try_main() -> Result<()> { let matches = Cli::command().get_matches_from(&argv); let mut cli = Cli::from_arg_matches(&matches).expect("clap matches should parse"); apply_base_arg_sources(&matches, cli.command.base_mut()); - cli.command.base_mut().profile_explicit = has_explicit_profile_arg(&argv); apply_base_output_defaults(&mut cli.command); configure_output(cli.command.base()); apply_runtime_env_overrides(cli.command.base()); @@ -347,6 +344,8 @@ fn try_main() -> Result<()> { fn apply_base_arg_sources(matches: &ArgMatches, base: &mut BaseArgs) { base.verbose_source = find_value_source(matches, "verbose").and_then(map_value_source); base.quiet_source = find_value_source(matches, "quiet").and_then(map_value_source); + base.org_name_source = find_value_source(matches, "org_name").and_then(map_value_source); + base.project_source = find_value_source(matches, "project").and_then(map_value_source); base.api_key_source = find_value_source(matches, "api_key").and_then(map_value_source); } @@ -490,17 +489,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 auth logins` and `bt status` to inspect auth status."); } if code == ExitCode::Error { eprintln!("If this seems like a bug, file an issue at https://github.com/braintrustdata/bt/issues/new and include `bt --version`, `bt status --json`, and the command you ran."); @@ -547,6 +551,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..17937d3e 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -1401,7 +1401,6 @@ async fn poll_setup_wizard_completion( async fn run_setup_browser_auth( base: &mut BaseArgs, - profile_name: Option<&str>, _project_name: Option<&str>, _project_was_explicit: bool, _requested_org: Option<&str>, @@ -1467,21 +1466,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 +1487,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 +1559,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 +1673,89 @@ 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)); + + 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 +1774,7 @@ async fn ensure_profile_or_setup_browser_auth_context( async fn ensure_setup_auth( base: &mut BaseArgs, - prompt_for_profile_choice: bool, + prompt_for_org_choice: bool, needs_api_key: bool, ) -> Result { let project_was_explicit = base @@ -1863,7 +1790,6 @@ async fn ensure_setup_auth( .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string); - let stored_profiles = auth::list_stored_profiles()?; let mut project_name = base .project .as_deref() @@ -1876,12 +1802,6 @@ async fn ensure_setup_auth( .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string); - let profile_name = base - .profile - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); if let Some(api_key) = explicit_api_key.as_deref() { let app_url = base @@ -1889,7 +1809,7 @@ async fn ensure_setup_auth( .clone() .unwrap_or_else(|| DEFAULT_APP_URL.to_string()); let available_orgs = list_available_orgs_for_setup(api_key, &app_url).await?; - if project_name.is_some() && org_name.is_none() && profile_name.is_none() { + if project_name.is_some() && org_name.is_none() { let name = project_name .as_deref() .expect("project name exists when probing all orgs"); @@ -1899,80 +1819,7 @@ async fn ensure_setup_auth( } } - if let Some(profile_name) = profile_name.as_deref() { - let profile = stored_profiles - .iter() - .find(|profile| profile.name == profile_name) - .ok_or_else(|| anyhow!("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 +1833,9 @@ async fn ensure_setup_auth( return build_setup_auth_context(base, client, false, needs_api_key, None).await; } - let login = ensure_profile_or_setup_browser_auth( + let login = ensure_org_or_setup_browser_auth( base, - prompt_for_profile_choice, + prompt_for_org_choice, project_name.as_deref(), project_was_explicit, Some(org_name), @@ -2017,92 +1864,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 +1871,9 @@ async fn ensure_setup_auth( None => available_orgs.clone(), }; if candidate_orgs.is_empty() { - return ensure_profile_or_setup_browser_auth_context( + return ensure_org_or_setup_browser_auth_context( base, - prompt_for_profile_choice, + prompt_for_org_choice, needs_api_key, project_name.as_deref(), project_was_explicit, @@ -2126,9 +1887,9 @@ async fn ensure_setup_auth( return build_setup_auth_context(base, client, false, needs_api_key, None).await; } - ensure_profile_or_setup_browser_auth_context( + ensure_org_or_setup_browser_auth_context( base, - prompt_for_profile_choice, + prompt_for_org_choice, needs_api_key, project_name.as_deref(), project_was_explicit, @@ -5426,26 +5187,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 +5515,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 +5534,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..756089ff 100644 --- a/src/status.rs +++ b/src/status.rs @@ -4,7 +4,7 @@ use serde::Serialize; use crate::args::BaseArgs; use crate::auth; -use crate::{config, utils::resolve_profile_info}; +use crate::config; #[derive(Debug, Clone, Args)] #[command(after_help = "\ @@ -19,25 +19,25 @@ pub struct StatusArgs {} struct StatusOutput { org: Option, project: Option, - profile: Option, #[serde(skip_serializing_if = "Option::is_none")] user_name: Option, #[serde(skip_serializing_if = "Option::is_none")] user_email: Option, #[serde(skip_serializing_if = "Option::is_none")] api_key_hint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + auth_method: Option, source: Option, } -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<()> { @@ -49,38 +49,16 @@ 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 (org, mut project, 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 - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - }); - let profile_info = resolve_profile_info(selected_profile.as_deref(), org.as_deref()); - - if selected_profile.is_some() && org.as_deref().map(str::trim).is_none_or(str::is_empty) { - if let Some(profile_org) = profile_info.as_ref().and_then(|p| p.org_name.clone()) { - org = Some(profile_org); - } - } + let auth_info = auth::active_auth_info(&base, org.as_deref())?; if base .project @@ -88,27 +66,18 @@ pub async fn run(base: BaseArgs, _args: StatusArgs) -> Result<()> { .map(str::trim) .is_none_or(str::is_empty) { - let mut project_base = base.clone(); - if project_base - .profile - .as_deref() - .map(str::trim) - .is_none_or(str::is_empty) - { - project_base.profile = selected_profile.clone(); - } - project = - config::project_from_config_for_context(&project_base, &merged_cfg, org.as_deref()); + project = config::project_from_config_for_context(&base, &merged_cfg, org.as_deref()); } + let display_org = org.as_deref().map(config::display_org); if base.json { let output = StatusOutput { - org, + org: display_org.map(str::to_string), project, - profile: profile_info.as_ref().map(|p| p.name.clone()), - user_name: profile_info.as_ref().and_then(|p| p.user_name.clone()), - user_email: profile_info.as_ref().and_then(|p| p.email.clone()), - api_key_hint: profile_info.as_ref().and_then(|p| p.api_key_hint.clone()), + user_name: auth_info.as_ref().and_then(|p| p.user_name.clone()), + user_email: auth_info.as_ref().and_then(|p| p.email.clone()), + api_key_hint: auth_info.as_ref().and_then(|p| p.api_key_hint.clone()), + auth_method: auth_info.as_ref().map(|p| p.auth_method.clone()), source, }; println!("{}", serde_json::to_string(&output)?); @@ -116,32 +85,34 @@ pub async fn run(base: BaseArgs, _args: StatusArgs) -> Result<()> { } if base.verbose { - println!("org: {}", org.as_deref().unwrap_or("(unset)")); + println!("org: {}", display_org.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(ref p) = auth_info { + println!("auth: {}", format_auth(p)); } 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!(), + } else if let Some(org) = display_org { + let scope = match (org, project.as_deref()) { + ("cross-org", _) => org.to_string(), + (org, Some(project)) => format!("{org}/{project}"), + (org, None) => org.to_string(), }; println!("{scope}"); - let profile_line = match &profile_info { - Some(p) => match format_identity(p) { - Some(id) => format!(" profile: {} — {id}", p.name), - None => format!(" profile: {}", p.name), - }, - None => " profile: (none)".to_string(), + let auth_line = match &auth_info { + Some(p) => format!(" auth: {}", format_auth(p)), + None => " auth: (none)".to_string(), }; - println!("{profile_line}"); + println!("{auth_line}"); + } else if auth_info + .as_ref() + .is_some_and(|p| p.auth_method == "oauth" && p.org_name.is_none()) + { + println!("cross-org"); + if let Some(p) = &auth_info { + println!(" auth: {}", format_auth(p)); + } } else { println!("No org/project configured. Run `bt switch` to set one."); } @@ -149,33 +120,65 @@ pub async fn run(base: BaseArgs, _args: StatusArgs) -> Result<()> { Ok(()) } -/// Precedence (clig.dev): CLI flag > env var > local config > global config. -pub(crate) fn resolve_config( +#[derive(Default)] +pub(crate) struct ConfigOverrides { cli_org: Option, + env_org: Option, cli_project: Option, + env_project: Option, +} + +impl ConfigOverrides { + fn from_base(base: &BaseArgs) -> Self { + use crate::args::ArgValueSource; + + let (cli_org, env_org) = match base.org_name_source { + Some(ArgValueSource::CommandLine) => (base.org_name.clone(), None), + Some(ArgValueSource::EnvVariable) => (None, base.org_name.clone()), + None => (None, None), + }; + let (cli_project, env_project) = match base.project_source { + Some(ArgValueSource::CommandLine) => (base.project.clone(), None), + Some(ArgValueSource::EnvVariable) => (None, base.project.clone()), + None => (None, None), + }; + + Self { + cli_org, + env_org, + cli_project, + env_project, + } + } +} + +/// Precedence (clig.dev): CLI flag > env var > local config > global config. +pub(crate) fn resolve_config( + overrides: ConfigOverrides, global: &config::Config, local: &config::Config, local_path: &Option, global_path: &Option, ) -> (Option, Option, Option) { - let env_org = std::env::var("BRAINTRUST_ORG_NAME") - .ok() - .filter(|s| !s.is_empty()); - let env_project = std::env::var("BRAINTRUST_DEFAULT_PROJECT") - .ok() - .filter(|s| !s.is_empty()); - + let ConfigOverrides { + cli_org, + env_org, + cli_project, + env_project, + } = overrides; + // `Some("")` is the canonical cross-org marker for both CLI and env + // sources, so org overrides must not filter empty strings. + let env_project = env_project.filter(|s| !s.is_empty()); + let merged = global.merge(local); let org = cli_org .clone() .or_else(|| env_org.clone()) - .or_else(|| local.org.clone()) - .or_else(|| global.org.clone()); + .or_else(|| merged.org.clone()); let project = cli_project .clone() .or_else(|| env_project.clone()) - .or_else(|| local.project.clone()) - .or_else(|| global.project.clone()); + .or_else(|| merged.project.clone()); let source = if cli_org.is_some() || cli_project.is_some() { Some("cli".to_string()) @@ -192,32 +195,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 +213,108 @@ 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")); - 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")); + 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), + ), + ( + "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")), + ), + ( + "local cross-org", + ConfigOverrides::default(), + both(), + config(Some(""), None), + (Some(""), None, Some("/project/.bt/config.json")), + ), + ( + "env cross-org", + ConfigOverrides { + env_org: s(""), + ..Default::default() + }, + both(), + config(None, None), + (Some(""), Some("global-proj"), Some("env")), + ), + ]; 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..05419919 100644 --- a/src/switch.rs +++ b/src/switch.rs @@ -1,30 +1,24 @@ 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::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 + bt switch --org cross-org ")] 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, @@ -51,120 +45,70 @@ impl SwitchArgs { } pub async fn run(base: BaseArgs, args: SwitchArgs) -> Result<()> { - let current_cfg = config::load().unwrap_or_default(); + args.scope.preflight(can_prompt())?; + let current_cfg = if args.scope.global { + config::load_global().unwrap_or_default() + } else { + config::load().unwrap_or_default() + }; let (resolved_org, resolved_project) = args.resolve_target(&base); - let mut interactive = false; - let has_api_key_override = base - .api_key - .as_ref() - .is_some_and(|value| !value.trim().is_empty()); + let bare_switch = resolved_org.is_none() && resolved_project.is_none(); - let profile_name = match &resolved_org { - Some(org_or_profile) => { - if base.profile.is_some() { - None - } else { - let profiles = auth::list_profiles()?; - Some(auth::resolve_org_to_profile(org_or_profile, &profiles)?) - } - } - None => resolve_profile_for_switch( - has_api_key_override, - resolved_project.is_none(), - is_interactive(), - || auth::select_profile_interactive(current_cfg.org.as_deref()), - &mut interactive, - )?, - }; + let mut login_base = base.clone(); + login_base.org_name = resolved_org.clone(); + login_base.project = None; + login_base.project_source = None; - // 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 - } - }; + if login_base.org_name.is_none() && !bare_switch { + login_base.org_name = current_cfg.org.clone(); + } + if login_base.org_name.is_none() + && !auth::select_saved_login(&mut login_base, current_cfg.org.as_deref(), true)? + { + bail!("no saved auth logins found; run `bt auth login` to create one"); + } + + if login_base.org_name.as_deref() == Some("") && resolved_project.is_some() { + bail!( + "cross-org mode cannot have a default project; rerun with --org --project " + ); + } 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( + let project = if org_name.is_empty() { + None + } else { + Some( + select_or_create_project( &client, + resolved_project.as_deref(), + current_cfg.project.as_deref(), None, - None, - crate::ui::ProjectSelectMode::ExistingOnly, ) - .await? - } - }; - - 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", + .await?, ) - } else if args.global { - (config::global_path()?, "global") - } else if interactive && config::local_path().is_some() { - select_scope()? - } else { - (config::global_path()?, "global") }; + // Scope is prompted last, after org and project. + 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(), + cfg.set_context( Some(&org_name), - Some(&project), + project + .as_ref() + .map(|project| (project.name.as_str(), project.id.as_str())), ); 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, - "project": project.name, - "project_id": project.id, - "profile": config_profile, + "org": config::display_org(&org_name), + "project": project.as_ref().map(|p| p.name.clone()), + "project_id": project.as_ref().map(|p| p.id.clone()), "scope": scope, "path": path.display().to_string(), }); @@ -172,7 +116,10 @@ pub async fn run(base: BaseArgs, args: SwitchArgs) -> Result<()> { return Ok(()); } - let display = format!("{org_name}/{}", project.name); + let display = project + .as_ref() + .map(|project| format!("{org_name}/{}", project.name)) + .unwrap_or_else(|| config::display_org(&org_name).to_string()); print_command_status(CommandStatus::Success, &format!("Switched to {display}")); if base.verbose { eprintln!("Wrote to {}", path.display()); @@ -181,461 +128,75 @@ 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")) - }; - 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")); - } - - #[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 resolve_target_combines_positionals_and_flags() { + for (target, org, project, expected) in [ + (None, None, None, (None, None)), + ( + Some("test-org/test-project"), + None, + None, + (Some("test-org"), Some("test-project")), + ), + ( + Some("test-project"), + None, + None, + (None, Some("test-project")), + ), + ( + Some("/test-project"), + None, + None, + (None, Some("test-project")), + ), + (Some("test-org/"), None, None, (Some("test-org"), None)), + (None, Some("test-org"), None, (Some("test-org"), None)), + ( + None, + None, + Some("test-project"), + (None, Some("test-project")), + ), + ( + None, + Some("test-org"), + Some("test-project"), + (Some("test-org"), Some("test-project")), + ), + ( + Some("old-org/old-project"), + None, + Some("test-project"), + (Some("old-org"), Some("test-project")), + ), + ( + Some("old-project"), + Some("test-org"), + None, + (Some("test-org"), Some("old-project")), + ), + ( + Some("old-org/old-project"), + Some("test-org"), + Some("test-project"), + (Some("test-org"), Some("test-project")), + ), + ] { + let args = SwitchArgs { + scope: config::ScopeArgs::default(), + target: target.map(str::to_string), + }; + let base = BaseArgs { + org_name: org.map(str::to_string), + project: project.map(str::to_string), + ..Default::default() + }; + let actual = args.resolve_target(&base); + assert_eq!((actual.0.as_deref(), actual.1.as_deref()), expected); + } } } diff --git a/src/traces.rs b/src/traces.rs index 6ef7ff1c..97a881c9 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,23 @@ where return base; }; - let has_profile_override = base - .profile - .as_deref() - .map(str::trim) - .is_some_and(|v| !v.is_empty()); let has_org_override = base .org_name .as_deref() .map(str::trim) .is_some_and(|v| !v.is_empty()); - if !has_org_override && !has_profile_override { + if !has_org_override { base.org_name = Some(url_org.to_string()); } - if !has_profile_override && !has_org_override { - if let Some(profile_name) = resolve_profile_for_org(url_org) { - base.profile = Some(profile_name); - } - } base } +#[cfg(test)] +fn apply_url_hints_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 +6701,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 +6925,24 @@ mod tests { } #[test] - fn apply_url_hints_infers_profile_from_url_org() { + fn apply_url_hints_infers_org_from_url() { let base = base_args(); let parsed = parsed_url_with_org("Lovable"); - 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")); } #[test] - fn apply_url_hints_preserves_explicit_profile() { + fn apply_url_hints_preserves_explicit_org() { let mut base = base_args(); - base.profile = Some("explicit-profile".to_string()); + base.org_name = Some("explicit-org".to_string()); let parsed = parsed_url_with_org("Lovable"); - let updated = apply_url_hints_with_profile_resolver(base, Some(&parsed), |_| { - Some("other".to_string()) - }); + 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..613710a0 100644 --- a/src/utils/profile.rs +++ b/src/utils/profile.rs @@ -1,60 +1,10 @@ -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; pub(crate) fn profile_author_slug(profile: &ProfileInfo) -> Option { [ profile.user_name.as_deref(), profile.email.as_deref().and_then(email_local_part), - Some(profile.name.as_str()), + profile.org_name.as_deref(), ] .into_iter() .flatten() @@ -98,13 +48,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 +66,9 @@ mod tests { } } - #[test] - fn resolve_profile_info_prefers_explicit_profile() { - let profile = resolve_profile_info_from_profiles( - Some("work"), - Some("other-org"), - vec![ - profile_info("other", Some("other-org"), None, None), - profile_info("work", Some("work-org"), None, None), - ], - ) - .expect("profile"); - - assert_eq!(profile.name, "work"); - } - - #[test] - fn resolve_profile_info_finds_profile_by_org_name() { - let profile = resolve_profile_info_from_profiles( - None, - Some("work-org"), - vec![profile_info("work", Some("work-org"), None, None)], - ) - .expect("profile"); - - assert_eq!(profile.name, "work"); - } - #[test] fn profile_author_slug_prefers_user_name() { - let profile = profile_info("work", None, Some("Alice Smith"), Some("alice@example.com")); + let profile = profile_info(None, Some("Alice Smith"), Some("alice@example.com")); assert_eq!( profile_author_slug(&profile).as_deref(), Some("alice-smith") @@ -150,17 +77,14 @@ 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_or_org() { + let profile = profile_info(None, 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/functions.rs b/tests/functions.rs index 03f70300..7467d0d5 100644 --- a/tests/functions.rs +++ b/tests/functions.rs @@ -190,8 +190,8 @@ fn sanitized_env_keys() -> &'static [&'static str] { ] } -fn auth_profiles_command(cwd: &Path, config_dir: &Path) -> Command { - auth_sub_command(cwd, config_dir, &["profiles"]) +fn auth_logins_command(cwd: &Path, config_dir: &Path) -> Command { + auth_sub_command(cwd, config_dir, &["logins"]) } #[derive(Debug, Clone)] @@ -669,15 +669,17 @@ fn functions_push_rejects_invalid_language() { #[test] fn functions_push_requires_app_url_with_custom_api_url() { + let config_dir = tempdir().expect("config tempdir"); let output = Command::new(bt_binary_path()) .arg("functions") .arg("--json") .arg("push") + .env("XDG_CONFIG_HOME", config_dir.path()) + .env("APPDATA", config_dir.path()) .env("BRAINTRUST_API_KEY", "test-key") .env("BRAINTRUST_API_URL", "http://127.0.0.1:1") .env_remove("BRAINTRUST_APP_URL") .env_remove("BRAINTRUST_ORG_NAME") - .env_remove("BRAINTRUST_PROFILE") .output() .expect("run push with custom API URL and no app URL"); @@ -702,79 +704,55 @@ fn functions_help_lists_push_and_pull() { } #[test] -fn auth_profiles_ignores_api_key_env_override() { +fn auth_logins_ignores_api_key_env_override() { let cwd = tempdir().expect("create temp cwd"); let config_dir = tempdir().expect("create temp config dir"); - let output = auth_profiles_command(cwd.path(), config_dir.path()) + let output = auth_logins_command(cwd.path(), config_dir.path()) .env("BRAINTRUST_API_KEY", "test-key") .output() - .expect("run bt auth profiles with api key env"); + .expect("run bt auth logins with api key env"); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stdout.contains("No saved profiles. Run `bt auth login` to create one.")); + assert!(stdout.contains("No saved auth logins. Run `bt auth login` to create one.")); assert!(!stdout.contains("Auth source: --api-key/BRAINTRUST_API_KEY override")); - assert!(!stderr.contains("pass --prefer-profile or unset BRAINTRUST_API_KEY")); } #[test] -fn auth_profiles_ignores_api_key_from_dotenv() { +fn auth_logins_ignores_api_key_from_dotenv() { let cwd = tempdir().expect("create temp cwd"); let config_dir = tempdir().expect("create temp config dir"); fs::write(cwd.path().join(".env"), "BRAINTRUST_API_KEY=test-key\n").expect("write .env"); - let output = auth_profiles_command(cwd.path(), config_dir.path()) + let output = auth_logins_command(cwd.path(), config_dir.path()) .env_remove("BRAINTRUST_API_KEY") .output() - .expect("run bt auth profiles with dotenv api key"); + .expect("run bt auth logins with dotenv api key"); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stdout.contains("No saved profiles. Run `bt auth login` to create one.")); + assert!(stdout.contains("No saved auth logins. Run `bt auth login` to create one.")); assert!(!stdout.contains("Auth source: --api-key/BRAINTRUST_API_KEY override")); - assert!(!stderr.contains("pass --prefer-profile or unset BRAINTRUST_API_KEY")); } #[test] -fn auth_profiles_json_with_no_profiles_emits_empty_array() { +fn auth_logins_json_with_no_profiles_emits_empty_array() { let cwd = tempdir().expect("create temp cwd"); let config_dir = tempdir().expect("create temp config dir"); - let output = auth_profiles_command(cwd.path(), config_dir.path()) + let output = auth_logins_command(cwd.path(), config_dir.path()) .arg("--json") .output() - .expect("run bt auth profiles --json"); + .expect("run bt auth logins --json"); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); assert_eq!(stdout, "[]"); } -#[test] -fn auth_profiles_profile_not_found_is_actionable() { - let cwd = tempdir().expect("create temp cwd"); - let config_dir = tempdir().expect("create temp config dir"); - - let output = auth_profiles_command(cwd.path(), config_dir.path()) - .arg("--profile") - .arg("test-profile") - .output() - .expect("run bt auth profiles --profile test-profile"); - - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("profile 'test-profile' not found")); - assert!( - stderr.contains("run `bt auth profiles` to see available profiles"), - "expected actionable hint, got: {stderr}" - ); -} - -/// Seed a synthetic api_key `test-profile` so verification reports "missing" -/// without touching the network or keychain. +/// Seed a synthetic API-key auth login so refresh can fail before touching the +/// network or keychain. fn seed_api_key_profile(config_dir: &Path) { fs::create_dir_all(config_dir.join("bt")).expect("create bt config dir"); fs::write( @@ -786,7 +764,7 @@ fn seed_api_key_profile(config_dir: &Path) { fn auth_sub_command(cwd: &Path, config_dir: &Path, sub: &[&str]) -> Command { // Shared builder for `bt auth ` so each auth subcommand inherits the - // same isolated config dir / env scrubbing as `auth profiles`. + // same isolated config dir / env scrubbing as `auth logins`. let mut cmd = Command::new(bt_binary_path()); cmd.arg("auth") .args(sub) @@ -794,7 +772,6 @@ fn auth_sub_command(cwd: &Path, config_dir: &Path, sub: &[&str]) -> Command { .env("XDG_CONFIG_HOME", config_dir) .env("APPDATA", config_dir) .env("BRAINTRUST_NO_COLOR", "1") - .env_remove("BRAINTRUST_PROFILE") .env_remove("BRAINTRUST_ORG_NAME") .env_remove("BRAINTRUST_API_URL") .env_remove("BRAINTRUST_APP_URL") @@ -817,9 +794,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 +854,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 +1903,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 +2091,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 +2224,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 +2370,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 +2432,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 +2506,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 +2589,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 +2679,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 +2797,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 +2876,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");