Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,12 +273,18 @@ pub fn resolve_write_path(global: bool, local: bool) -> Result<PathBuf> {
}
}

pub fn save_local(config: &Config, create_dir: bool) -> Result<()> {
let dir = std::env::current_dir()?.join(".bt");
pub fn local_save_path() -> Result<PathBuf> {
Ok(std::env::current_dir()?.join(".bt").join("config.json"))
}

pub fn save_local(config: &Config, create_dir: bool) -> Result<PathBuf> {
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 ---
Expand Down
41 changes: 32 additions & 9 deletions src/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(());
}

Expand Down Expand Up @@ -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(())
}
58 changes: 57 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -247,6 +247,30 @@ fn main() {
std::process::exit(exit_code as i32);
}

fn handle_version_json(argv: &[OsString]) -> Result<bool> {
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
Expand All @@ -260,6 +284,10 @@ fn try_main() -> Result<()> {
let argv: Vec<OsString> = 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());
Expand Down Expand Up @@ -588,4 +616,32 @@ mod tests {
assert!(!cli.command.base().quiet);
assert!(cli.command.base().verbose);
}

fn argv(parts: &[&str]) -> Vec<OsString> {
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()
);
}
}
107 changes: 86 additions & 21 deletions src/self_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

pub async fn run(base: BaseArgs, args: SelfArgs) -> Result<()> {
Expand All @@ -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(());
}
}
Expand All @@ -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(())
}

Expand All @@ -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(&current, &latest);
let message = stable_check_message(&current, &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(())
}

Expand Down Expand Up @@ -185,12 +218,21 @@ async fn fetch_release(_base: &BaseArgs, channel: UpdateChannel) -> Result<GitHu
.context("failed to parse GitHub release response")
}

fn run_installer(channel: UpdateChannel) -> 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")?;
Expand All @@ -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(())
}

Expand All @@ -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",
Expand All @@ -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(());
}
}
Expand Down Expand Up @@ -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<UpdateChannel> {
Expand Down Expand Up @@ -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"));
}

Expand Down
2 changes: 1 addition & 1 deletion src/setup/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading