diff --git a/src/config/mod.rs b/src/config/mod.rs index adc9246..779499d 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -273,12 +273,18 @@ pub fn resolve_write_path(global: bool, local: bool) -> Result { } } -pub fn save_local(config: &Config, create_dir: bool) -> Result<()> { - let dir = std::env::current_dir()?.join(".bt"); +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)?; + fs::create_dir_all(dir)?; } - save_file(&dir.join("config.json"), config) + save_file(&path, config)?; + Ok(path) } // --- CLI commands --- diff --git a/src/init.rs b/src/init.rs index 9f1e3b4..e9976c1 100644 --- a/src/init.rs +++ b/src/init.rs @@ -18,9 +18,21 @@ Examples: pub struct InitArgs {} pub async fn run(base: BaseArgs, _args: InitArgs) -> Result<()> { - let bt_dir = std::env::current_dir()?.join(".bt"); - if bt_dir.join("config.json").exists() { - print_command_status(CommandStatus::Warning, "Already Initialized"); + 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(()); } @@ -59,13 +71,24 @@ pub async fn run(base: BaseArgs, _args: InitArgs) -> Result<()> { ..Default::default() }; - config::save_local(&cfg, true)?; + let written_path = config::save_local(&cfg, true)?; - print_command_status( - CommandStatus::Success, - &format!("Project linked to {org}/{project}"), - ); - print_command_status(CommandStatus::Success, "Created .bt/config.json"); + if base.json { + let payload = serde_json::json!({ + "initialized": true, + "status": "created", + "org": org, + "project": project, + "path": written_path.display().to_string(), + }); + println!("{}", serde_json::to_string(&payload)?); + } else { + print_command_status( + CommandStatus::Success, + &format!("Project linked to {org}/{project}"), + ); + print_command_status(CommandStatus::Success, "Created .bt/config.json"); + } Ok(()) } diff --git a/src/main.rs b/src/main.rs index 8de7269..68807d6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,7 +38,7 @@ mod utils; use crate::args::{has_explicit_profile_arg, ArgValueSource, BaseArgs, CLIArgs}; const DEFAULT_CANARY_VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), "-canary.dev"); -const CLI_VERSION: &str = match option_env!("BT_VERSION_STRING") { +pub(crate) const CLI_VERSION: &str = match option_env!("BT_VERSION_STRING") { Some(version) => version, None => DEFAULT_CANARY_VERSION, }; @@ -247,6 +247,30 @@ fn main() { std::process::exit(exit_code as i32); } +fn handle_version_json(argv: &[OsString]) -> Result { + use clap::{Arg, ArgAction, Command}; + let preflight = Command::new("bt-version-preflight") + .ignore_errors(true) + .disable_help_flag(true) + .disable_version_flag(true) + .arg( + Arg::new("version") + .long("version") + .short('V') + .action(ArgAction::Count), + ) + .arg(Arg::new("json").long("json").action(ArgAction::Count)); + let Ok(matches) = preflight.try_get_matches_from(argv) else { + return Ok(false); + }; + if matches.get_count("version") == 0 || matches.get_count("json") == 0 { + return Ok(false); + } + let payload = serde_json::json!({ "version": CLI_VERSION }); + println!("{}", serde_json::to_string(&payload)?); + Ok(true) +} + fn apply_runtime_env_overrides(base: &BaseArgs) { // Apply the CLI-owned override once so reqwest and inherited child // commands consistently observe BRAINTRUST_CA_CERT/--ca-cert precedence @@ -260,6 +284,10 @@ fn try_main() -> Result<()> { let argv: Vec = std::env::args_os().collect(); env::bootstrap_from_args(&argv)?; + if handle_version_json(&argv)? { + return Ok(()); + } + 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()); @@ -588,4 +616,32 @@ mod tests { assert!(!cli.command.base().quiet); assert!(cli.command.base().verbose); } + + fn argv(parts: &[&str]) -> Vec { + parts.iter().map(OsString::from).collect() + } + + #[test] + fn handle_version_json_detects_long_form() { + assert!(handle_version_json(&argv(&["bt", "--version", "--json"])).unwrap()); + assert!(handle_version_json(&argv(&["bt", "--json", "--version"])).unwrap()); + } + + #[test] + fn handle_version_json_detects_short_form() { + assert!(handle_version_json(&argv(&["bt", "-V", "--json"])).unwrap()); + } + + #[test] + fn handle_version_json_requires_both_flags() { + assert!(!handle_version_json(&argv(&["bt", "--version"])).unwrap()); + assert!(!handle_version_json(&argv(&["bt", "--json", "status"])).unwrap()); + } + + #[test] + fn handle_version_json_ignores_args_after_double_dash() { + assert!( + !handle_version_json(&argv(&["bt", "eval", "--", "--version", "--json",])).unwrap() + ); + } } diff --git a/src/self_update.rs b/src/self_update.rs index bbac175..f12f1f9 100644 --- a/src/self_update.rs +++ b/src/self_update.rs @@ -80,6 +80,8 @@ const BUILD_UPDATE_CHANNEL: Option<&str> = option_env!("BT_UPDATE_CHANNEL"); #[derive(Debug, Deserialize)] struct GitHubRelease { tag_name: String, + #[serde(default)] + target_commitish: Option, } pub async fn run(base: BaseArgs, args: SelfArgs) -> Result<()> { @@ -102,9 +104,8 @@ async fn run_update(base: &BaseArgs, args: UpdateArgs) -> Result<()> { if channel == UpdateChannel::Stable { match fetch_release(base, channel).await { Ok(release) => { - let current = env!("CARGO_PKG_VERSION"); - if stable_is_up_to_date(current, &release.tag_name) { - println!("{}", stable_check_message(current, &release.tag_name)); + if stable_is_up_to_date(env!("CARGO_PKG_VERSION"), &release.tag_name) { + print_check(base, channel, &release)?; return Ok(()); } } @@ -116,7 +117,14 @@ async fn run_update(base: &BaseArgs, args: UpdateArgs) -> Result<()> { } } - run_installer(channel)?; + run_installer(base, channel)?; + if base.json { + let payload = serde_json::json!({ + "channel": channel.name(), + "status": "completed", + }); + println!("{}", serde_json::to_string(&payload)?); + } Ok(()) } @@ -137,17 +145,42 @@ fn ensure_installer_managed_install() -> Result<()> { async fn check_for_update(base: &BaseArgs, channel: UpdateChannel) -> Result<()> { let release = fetch_release(base, channel).await?; - let current = env!("CARGO_PKG_VERSION"); + print_check(base, channel, &release) +} - match channel { +fn print_check(base: &BaseArgs, channel: UpdateChannel, release: &GitHubRelease) -> Result<()> { + let (current, latest, up_to_date, message) = match channel { UpdateChannel::Stable => { - println!("{}", stable_check_message(current, &release.tag_name)); + let current = env!("CARGO_PKG_VERSION").to_string(); + let latest = release.tag_name.clone(); + let up_to_date = stable_is_up_to_date(¤t, &latest); + let message = stable_check_message(¤t, &latest); + (current, latest, up_to_date, message) } UpdateChannel::Canary => { - println!("{}", canary_check_message(&release.tag_name)); + // `current` is built by build.rs as `{CARGO_PKG_VERSION}-canary.{short_sha}`. + // Construct `latest` in the same shape so the two are comparable. + // The canary tag itself is always literally "canary"; the meaningful + // identifier is the commit it points at (target_commitish). + let current = crate::CLI_VERSION.to_string(); + let latest = format_canary_version(release.target_commitish.as_deref()); + let up_to_date = current == latest; + let message = canary_check_message(&latest); + (current, latest, up_to_date, message) } + }; + + if base.json { + let payload = serde_json::json!({ + "channel": channel.name(), + "current": current, + "latest": latest, + "up_to_date": up_to_date, + }); + println!("{}", serde_json::to_string(&payload)?); + } else { + println!("{message}"); } - Ok(()) } @@ -185,12 +218,21 @@ async fn fetch_release(_base: &BaseArgs, channel: UpdateChannel) -> Result Result<()> { +fn run_installer(base: &BaseArgs, channel: UpdateChannel) -> Result<()> { + let status_line = |msg: &str| { + if base.json { + eprintln!("{msg}"); + } else { + println!("{msg}"); + } + }; + let redirect_stdout = if base.json { " 1>&2" } else { "" }; + #[cfg(not(windows))] { let installer_url = channel.installer_url(); - println!("updating bt from {} channel...", channel.name()); - let cmd = format!("curl -fsSL '{installer_url}' | sh"); + status_line(&format!("updating bt from {} channel...", channel.name())); + let cmd = format!("curl -fsSL '{installer_url}' | sh{redirect_stdout}"); let mut command = Command::new("sh"); command.arg("-c").arg(cmd); let status = command.status().context("failed to execute installer")?; @@ -199,7 +241,7 @@ fn run_installer(channel: UpdateChannel) -> Result<()> { anyhow::bail!("installer exited with status {status}"); } - println!("update completed"); + status_line("update completed"); Ok(()) } @@ -213,7 +255,8 @@ fn run_installer(channel: UpdateChannel) -> Result<()> { "https://github.com/braintrustdata/bt/releases/download/canary/bt-installer.ps1" } }; - let script = format!("irm {installer_url} | iex"); + status_line(&format!("updating bt from {} channel...", channel.name())); + let script = format!("irm {installer_url} | iex{redirect_stdout}"); let status = Command::new("powershell") .args([ "-NoProfile", @@ -228,7 +271,7 @@ fn run_installer(channel: UpdateChannel) -> Result<()> { anyhow::bail!("installer exited with status {status}"); } - println!("update completed"); + status_line("update completed"); return Ok(()); } } @@ -329,15 +372,24 @@ fn stable_check_message(current: &str, release_tag: &str) -> String { format!("update available on stable channel: current={current}, latest={release_tag}") } +/// Format a canary version string to match the shape that `build.rs` bakes into +/// `CLI_VERSION`: `{CARGO_PKG_VERSION}-canary.{short_sha}`. Falls back to a +/// `dev`-style suffix when `target_commitish` is missing so an unparseable +/// release never accidentally compares equal to a local canary build. +fn format_canary_version(target_commitish: Option<&str>) -> String { + let short_sha = target_commitish + .map(|sha| &sha[..sha.len().min(12)]) + .unwrap_or("unknown"); + format!("{}-canary.{}", env!("CARGO_PKG_VERSION"), short_sha) +} + fn stable_is_up_to_date(current: &str, release_tag: &str) -> bool { let latest = release_tag.trim_start_matches('v'); latest == current } -fn canary_check_message(release_tag: &str) -> String { - format!( - "latest canary release tag: {release_tag}\nrun `bt self update --channel canary` to install it" - ) +fn canary_check_message(latest: &str) -> String { + format!("latest canary release: {latest}\nrun `bt self update --channel canary` to install it") } fn parse_update_channel(raw: Option<&str>) -> Option { @@ -432,10 +484,23 @@ mod tests { assert!(msg.contains("latest=v0.2.0")); } + #[test] + fn format_canary_version_matches_cli_version_shape() { + let pkg = env!("CARGO_PKG_VERSION"); + let formatted = format_canary_version(Some("abc123def456789012345678901234567890aaaa")); + assert_eq!(formatted, format!("{pkg}-canary.abc123def456")); + } + + #[test] + fn format_canary_version_falls_back_when_commitish_missing() { + let pkg = env!("CARGO_PKG_VERSION"); + assert_eq!(format_canary_version(None), format!("{pkg}-canary.unknown")); + } + #[test] fn canary_check_message_contains_guidance() { - let msg = canary_check_message("canary-deadbeef"); - assert!(msg.contains("canary-deadbeef")); + let msg = canary_check_message("0.10.0-canary.abc123def456"); + assert!(msg.contains("0.10.0-canary.abc123def456")); assert!(msg.contains("bt self update --channel canary")); } diff --git a/src/setup/mod.rs b/src/setup/mod.rs index 8ca28d6..7934899 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -2352,7 +2352,7 @@ async fn select_project_for_auth_context( } fn maybe_init(org: &str, project: &crate::projects::api::Project) -> Result<()> { - let config_path = std::env::current_dir()?.join(".bt").join("config.json"); + let config_path = config::local_save_path()?; let mut cfg = if config_path.exists() { let existing = config::load_file(&config_path); let matches = existing.org.as_deref() == Some(org) diff --git a/src/switch.rs b/src/switch.rs index 127455c..2b34046 100644 --- a/src/switch.rs +++ b/src/switch.rs @@ -129,18 +129,21 @@ pub async fn run(base: BaseArgs, args: SwitchArgs) -> Result<()> { } }; - let path = if args.local { - config::local_path().ok_or_else(|| { - anyhow::anyhow!( - "No local .bt directory found. Use bt init to initialize this directory." - ) - })? + let (path, scope) = if args.local { + ( + config::local_path().ok_or_else(|| { + anyhow::anyhow!( + "No local .bt directory found. Use bt init to initialize this directory." + ) + })?, + "local", + ) } else if args.global { - config::global_path()? + (config::global_path()?, "global") } else if interactive && config::local_path().is_some() { select_scope()? } else { - config::global_path()? + (config::global_path()?, "global") }; let mut cfg = config::load_file(&path); @@ -151,6 +154,19 @@ pub async fn run(base: BaseArgs, args: SwitchArgs) -> Result<()> { config::save_file(&path, &cfg) .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, + "scope": scope, + "path": path.display().to_string(), + }); + println!("{}", serde_json::to_string(&payload)?); + return Ok(()); + } + let display = format!("{org_name}/{}", project.name); print_command_status(CommandStatus::Success, &format!("Switched to {display}")); if base.verbose { @@ -160,7 +176,7 @@ pub async fn run(base: BaseArgs, args: SwitchArgs) -> Result<()> { Ok(()) } -fn select_scope() -> Result { +fn select_scope() -> Result<(std::path::PathBuf, &'static str)> { let global = config::global_path()?; let local = config::local_path().unwrap(); let options = [ @@ -203,9 +219,9 @@ fn select_scope() -> Result { .default(1) .interact()?; if idx == 0 { - Ok(global) + Ok((global, "global")) } else { - Ok(local) + Ok((local, "local")) } }