diff --git a/crates/turborepo-cli/src/cli/mod.rs b/crates/turborepo-cli/src/cli/mod.rs index 86ff992495a0e..ede53203b5800 100644 --- a/crates/turborepo-cli/src/cli/mod.rs +++ b/crates/turborepo-cli/src/cli/mod.rs @@ -134,41 +134,6 @@ fn initialize_deferred_telemetry_client( } } -#[derive(PartialEq)] -enum PrintVersionState { - Enabled, - Disabled, -} - -fn get_print_version_state() -> PrintVersionState { - env::var("TURBO_PRINT_VERSION_DISABLED") - .map(|var| match var.as_str() { - "1" | "true" => PrintVersionState::Disabled, - _ => PrintVersionState::Enabled, - }) - .unwrap_or(PrintVersionState::Enabled) -} - -#[derive(PartialEq)] -enum CIState { - Inside, - Outside, -} - -fn get_ci_state() -> CIState { - match turborepo_ci::is_ci() { - true => CIState::Inside, - _ => CIState::Outside, - } -} - -fn should_print_version() -> bool { - let print_version_state = get_print_version_state(); - let ci_state = get_ci_state(); - - print_version_state == PrintVersionState::Enabled && ci_state == CIState::Outside -} - fn set_run_flags<'a>( command: &'a mut Command, repo_state: &'a Option, @@ -340,14 +305,27 @@ async fn run_main( let mut command = get_command(&mut cli_args)?; - // Suppress the version banner in --json mode — all output on stdout - // must be machine-readable NDJSON. - let is_json_mode = matches!( - &command, - Command::Run { execution_args, .. } | Command::Watch { execution_args, .. } - if execution_args.json - ); - if should_print_version() && !is_json_mode { + // Skip the standalone version banner when the command reports the version + // itself: `run`/`watch` print it as the first bullet of the run prelude + // (and omit it entirely in --json mode), and `turbo query` executions that + // print a JSON document embed it in that document. + let reports_own_version = matches!(&command, Command::Run { .. } | Command::Watch { .. }) + || matches!( + &command, + Command::Query { + subcommand: Some(QuerySubcommand::Affected(_)), + .. + } | Command::Query { + subcommand: None, + query: Some(_), + .. + } | Command::Query { + subcommand: None, + schema: true, + .. + } + ); + if turborepo_run::should_print_version() && !reports_own_version { eprintln!("{}", GREY.apply_to(format!("• turbo {}", get_version()))); } diff --git a/crates/turborepo-cli/src/commands/query.rs b/crates/turborepo-cli/src/commands/query.rs index 05baf7cde25c0..738fdc9443689 100644 --- a/crates/turborepo-cli/src/commands/query.rs +++ b/crates/turborepo-cli/src/commands/query.rs @@ -60,24 +60,43 @@ async fn execute_query_and_print( query_server: &dyn QueryServer, query: &str, variables_json: Option<&str>, + version: &str, ) -> Result<(i32, String), cli::Error> { execute_query_and_write( run, query_server, query, variables_json, + version, &mut std::io::stdout(), &mut std::io::stderr(), ) .await } +/// Prepend a `version` key to a JSON object so the output stays a single +/// parseable document (the version banner is suppressed for query output). +/// Non-object or unparseable input is returned unchanged. +fn with_version_key(result_json: &str, version: &str) -> String { + match serde_json::from_str::(result_json) { + Ok(serde_json::Value::Object(fields)) => { + let mut output = serde_json::Map::with_capacity(fields.len() + 1); + output.insert("version".to_string(), version.into()); + output.extend(fields.into_iter().filter(|(key, _)| key != "version")); + serde_json::to_string_pretty(&serde_json::Value::Object(output)) + .unwrap_or_else(|_| result_json.to_string()) + } + _ => result_json.to_string(), + } +} + /// The same query adapter with injectable output streams for in-process tests. async fn execute_query_and_write( run: Arc, query_server: &dyn QueryServer, query: &str, variables_json: Option<&str>, + version: &str, stdout: &mut (impl IoWrite + Send), stderr: &mut (impl IoWrite + Send), ) -> Result<(i32, String), cli::Error> { @@ -85,7 +104,7 @@ async fn execute_query_and_write( .execute_query(run, query, variables_json) .await?; - writeln!(stdout, "{}", result.result_json)?; + writeln!(stdout, "{}", with_version_key(&result.result_json, version))?; if !result.errors.is_empty() { for error in result.errors { let error = QueryError::from_query_error(error, query.to_string()); @@ -119,6 +138,7 @@ pub async fn run( return Ok(0); } + let version = base.version(); let signal = get_signal()?; let handler = SignalHandler::new(signal); @@ -146,7 +166,7 @@ pub async fn run( }; let query = build_affected_query(&input); let (exit_code, result_json) = - execute_query_and_print(run, query_server, &query, None).await?; + execute_query_and_print(run, query_server, &query, None, version).await?; if exit_code != 0 { return Ok(exit_code); @@ -198,7 +218,8 @@ pub async fn run( .map_err(turborepo_query_api::Error::Server)?; let (exit_code, _) = - execute_query_and_print(run, query_server, query, variables_json.as_deref()).await?; + execute_query_and_print(run, query_server, query, variables_json.as_deref(), version) + .await?; Ok(exit_code) } else { query_server.run_query_server(run, handler).await?; @@ -385,6 +406,7 @@ mod tests { &server, "query { version }", Some(r#"{"name":"app"}"#), + "1.2.3", &mut stdout, &mut stderr, ) @@ -392,7 +414,11 @@ mod tests { .unwrap(); assert_eq!(exit, expected_exit); assert_eq!(json, r#"{"data":{"version":"fixture"}}"#); - assert_eq!(String::from_utf8(stdout).unwrap(), format!("{json}\n")); + assert_eq!( + String::from_utf8(stdout).unwrap(), + "{\n \"version\": \"1.2.3\",\n \"data\": {\n \"version\": \"fixture\"\n \ + }\n}\n" + ); let diagnostic = String::from_utf8(stderr).unwrap(); if fail { assert!(diagnostic.contains("fixture error"), "{diagnostic}"); diff --git a/crates/turborepo-run/src/lib.rs b/crates/turborepo-run/src/lib.rs index 4fdd183679deb..d2676222622c8 100644 --- a/crates/turborepo-run/src/lib.rs +++ b/crates/turborepo-run/src/lib.rs @@ -64,6 +64,14 @@ use turborepo_ui::{ColorConfig, LIGHT_GREY, TerminalSink, sender::UISender, tui, pub use crate::error::Error; +/// Whether the `• turbo ` line should be shown. Disabled in CI and +/// when `TURBO_PRINT_VERSION_DISABLED` is `1` or `true`. +pub fn should_print_version() -> bool { + let disabled = std::env::var("TURBO_PRINT_VERSION_DISABLED") + .is_ok_and(|var| matches!(var.as_str(), "1" | "true")); + !disabled && !turborepo_ci::is_ci() +} + /// Live status of the remote cache, determined by a preflight API check /// that runs concurrently with graph building. #[derive(Debug, Clone, Copy)] @@ -426,6 +434,14 @@ impl Run { ) .emit(); + if should_print_version() { + turborepo_log::info( + turborepo_log::Source::turbo(turborepo_log::Subsystem::Run), + format!("{pad}• turbo {}", self.repo.version), + ) + .emit(); + } + let targets_list = self.execution.opts.run_opts.tasks.join(", "); if self.execution.opts.run_opts.single_package { turborepo_log::info( diff --git a/crates/turborepo/tests/common/mod.rs b/crates/turborepo/tests/common/mod.rs index ec9d8c94dac22..2e1176aca73e0 100644 --- a/crates/turborepo/tests/common/mod.rs +++ b/crates/turborepo/tests/common/mod.rs @@ -467,7 +467,25 @@ macro_rules! check_json_output { stderr, ); - let query_output: serde_json::Value = serde_json::from_str(&stdout)?; + let mut query_output: serde_json::Value = serde_json::from_str(&stdout)?; + // `turbo query` embeds the CLI version as the first key. Verify + // it, then drop it so snapshots stay stable across releases. + if $command == "query" { + let object = query_output + .as_object_mut() + .expect("turbo query output should be a JSON object"); + assert_eq!( + object.keys().next().map(String::as_str), + Some("version"), + "turbo query output should start with a version key:\n{}", + stdout, + ); + let version = object.shift_remove("version").unwrap(); + assert!( + version.as_str().is_some_and(|v| !v.is_empty()), + "version should be a non-empty string: {version}" + ); + } let test_name = format!( "{}_{}_({})", $fixture,