diff --git a/crates/turborepo-filewatch/src/package_watcher.rs b/crates/turborepo-filewatch/src/package_watcher.rs index 6fc4b3df7ed48..9f2980a6fa10e 100644 --- a/crates/turborepo-filewatch/src/package_watcher.rs +++ b/crates/turborepo-filewatch/src/package_watcher.rs @@ -449,13 +449,15 @@ impl Subscriber { tracing::debug!("handling change to workspace {path_workspace}"); let package_json = path_workspace.join_component("package.json"); let turbo_json = path_workspace.join_component("turbo.json"); + let turbo_jsonc = path_workspace.join_component("turbo.jsonc"); - let (package_exists, turbo_exists) = join!( + let (package_exists, turbo_json_exists, turbo_jsonc_exists) = join!( // It's possible that an IO error could occur other than the file not existing, but // we will treat it like the file doesn't exist. It's possible we'll need to // revisit this, depending on what kind of errors occur. tokio::fs::try_exists(&package_json).map(|result| result.unwrap_or(false)), - tokio::fs::try_exists(&turbo_json) + tokio::fs::try_exists(&turbo_json), + tokio::fs::try_exists(&turbo_jsonc) ); changed |= if package_exists { @@ -464,7 +466,14 @@ impl Subscriber { path_workspace.to_owned(), WorkspaceData { package_json, - turbo_json: turbo_exists.unwrap_or_default().then_some(turbo_json), + turbo_json: turbo_json_exists + .unwrap_or_default() + .then_some(turbo_json) + .or_else(|| { + turbo_jsonc_exists + .unwrap_or_default() + .then_some(turbo_jsonc) + }), }, ) .is_none() diff --git a/crates/turborepo-lib/src/commands/login/manual.rs b/crates/turborepo-lib/src/commands/login/manual.rs index e1d8a842ff37f..e350921540ca6 100644 --- a/crates/turborepo-lib/src/commands/login/manual.rs +++ b/crates/turborepo-lib/src/commands/login/manual.rs @@ -44,11 +44,8 @@ pub async fn login_manual(base: &mut CommandBase, force: bool) -> Result<(), Err // update global config with token write_token(base, token)?; // ensure api url & team id/slug are present in turbo.json - write_remote( - &base.root_turbo_json_path(), - api_client.base_url(), - team_identifier, - )?; + let turbo_json_path = base.root_turbo_json_path()?; + write_remote(&turbo_json_path, api_client.base_url(), team_identifier)?; Ok(()) } diff --git a/crates/turborepo-lib/src/commands/mod.rs b/crates/turborepo-lib/src/commands/mod.rs index 4acee04c53f0b..078122187ed8f 100644 --- a/crates/turborepo-lib/src/commands/mod.rs +++ b/crates/turborepo-lib/src/commands/mod.rs @@ -10,6 +10,7 @@ use crate::{ cli, config::{ConfigurationOptions, Error as ConfigError, TurborepoConfigBuilder}, opts::Opts, + turbo_json::{CONFIG_FILE, CONFIG_FILE_JSONC}, Args, }; @@ -139,8 +140,27 @@ impl CommandBase { fn root_package_json_path(&self) -> AbsoluteSystemPathBuf { self.repo_root.join_component("package.json") } - fn root_turbo_json_path(&self) -> AbsoluteSystemPathBuf { - self.repo_root.join_component("turbo.json") + fn root_turbo_json_path(&self) -> Result { + let turbo_json_path = self.repo_root.join_component(CONFIG_FILE); + let turbo_jsonc_path = self.repo_root.join_component(CONFIG_FILE_JSONC); + + let turbo_json_exists = turbo_json_path.exists(); + let turbo_jsonc_exists = turbo_jsonc_path.exists(); + + if turbo_json_exists && turbo_jsonc_exists { + return Err(ConfigError::MultipleTurboConfigs { + directory: self.repo_root.to_string(), + }); + } + + if turbo_json_exists { + Ok(turbo_json_path) + } else if turbo_jsonc_exists { + Ok(turbo_jsonc_path) + } else { + Ok(turbo_json_path) // Default to turbo.json path even if it doesn't + // exist + } } pub fn api_auth(&self) -> Result, ConfigError> { diff --git a/crates/turborepo-lib/src/config/mod.rs b/crates/turborepo-lib/src/config/mod.rs index 522d35d0503c0..79109f1c24808 100644 --- a/crates/turborepo-lib/src/config/mod.rs +++ b/crates/turborepo-lib/src/config/mod.rs @@ -26,7 +26,7 @@ use turborepo_repository::package_graph::PackageName; pub use crate::turbo_json::{RawTurboJson, UIMode}; use crate::{ cli::{EnvMode, LogOrder}, - turbo_json::CONFIG_FILE, + turbo_json::{CONFIG_FILE, CONFIG_FILE_JSONC}, }; #[derive(Debug, Error, Diagnostic)] @@ -74,10 +74,15 @@ pub enum Error { #[error(transparent)] PackageJson(#[from] turborepo_repository::package_json::Error), #[error( - "Could not find turbo.json.\nFollow directions at https://turbo.build/repo/docs to create \ + "Could not find turbo.json or turbo.jsonc.\nFollow directions at https://turbo.build/repo/docs to create \ one." )] NoTurboJSON, + #[error( + "Found both turbo.json and turbo.jsonc in the same directory: {directory}\nRemove either \ + turbo.json or turbo.jsonc so there is only one." + )] + MultipleTurboConfigs { directory: String }, #[error(transparent)] SerdeJson(#[from] serde_json::Error), #[error(transparent)] @@ -396,10 +401,29 @@ impl ConfigurationOptions { self.run_summary.unwrap_or_default() } - pub fn root_turbo_json_path(&self, repo_root: &AbsoluteSystemPath) -> AbsoluteSystemPathBuf { - self.root_turbo_json_path - .clone() - .unwrap_or_else(|| repo_root.join_component(CONFIG_FILE)) + pub fn root_turbo_json_path( + &self, + repo_root: &AbsoluteSystemPath, + ) -> Result { + if let Some(path) = &self.root_turbo_json_path { + return Ok(path.clone()); + } + + // Check if both files exist + let turbo_json_path = repo_root.join_component(CONFIG_FILE); + let turbo_jsonc_path = repo_root.join_component(CONFIG_FILE_JSONC); + let turbo_json_exists = turbo_json_path.try_exists()?; + let turbo_jsonc_exists = turbo_jsonc_path.try_exists()?; + + match (turbo_json_exists, turbo_jsonc_exists) { + (true, true) => Err(Error::MultipleTurboConfigs { + directory: repo_root.to_string(), + }), + (true, false) => Ok(turbo_json_path), + (false, true) => Ok(turbo_jsonc_path), + // Default to turbo.json if neither exists + (false, false) => Ok(turbo_json_path), + } } pub fn allow_no_turbo_json(&self) -> bool { @@ -450,16 +474,6 @@ impl TurborepoConfigBuilder { self } - // Getting all of the paths. - #[allow(dead_code)] - fn root_package_json_path(&self) -> AbsoluteSystemPathBuf { - self.repo_root.join_component("package.json") - } - #[allow(dead_code)] - fn root_turbo_json_path(&self) -> AbsoluteSystemPathBuf { - self.repo_root.join_component("turbo.json") - } - fn get_environment(&self) -> HashMap { self.environment .clone() @@ -517,9 +531,12 @@ mod test { use tempfile::TempDir; use turbopath::{AbsoluteSystemPath, AbsoluteSystemPathBuf}; - use crate::config::{ - ConfigurationOptions, TurborepoConfigBuilder, DEFAULT_API_URL, DEFAULT_LOGIN_URL, - DEFAULT_TIMEOUT, + use crate::{ + config::{ + ConfigurationOptions, TurborepoConfigBuilder, DEFAULT_API_URL, DEFAULT_LOGIN_URL, + DEFAULT_TIMEOUT, + }, + turbo_json::{CONFIG_FILE, CONFIG_FILE_JSONC}, }; #[test] @@ -542,7 +559,7 @@ mod test { }) .unwrap(); assert_eq!( - defaults.root_turbo_json_path(repo_root), + defaults.root_turbo_json_path(repo_root).unwrap(), repo_root.join_component("turbo.json") ) } @@ -635,4 +652,66 @@ mod test { assert!(!config.preflight()); assert_eq!(config.timeout(), 123); } + + #[test] + fn test_multiple_turbo_configs() { + let tmp_dir = TempDir::new().unwrap(); + let repo_root = AbsoluteSystemPath::from_std_path(tmp_dir.path()).unwrap(); + + // Create both turbo.json and turbo.jsonc + let turbo_json_path = repo_root.join_component(CONFIG_FILE); + let turbo_jsonc_path = repo_root.join_component(CONFIG_FILE_JSONC); + + turbo_json_path.create_with_contents("{}").unwrap(); + turbo_jsonc_path.create_with_contents("{}").unwrap(); + + // Test ConfigurationOptions.root_turbo_json_path + let config = ConfigurationOptions::default(); + let result = config.root_turbo_json_path(repo_root); + assert!(result.is_err()); + } + + #[test] + fn test_only_turbo_json() { + let tmp_dir = TempDir::new().unwrap(); + let repo_root = AbsoluteSystemPath::from_std_path(tmp_dir.path()).unwrap(); + + // Create only turbo.json + let turbo_json_path = repo_root.join_component(CONFIG_FILE); + turbo_json_path.create_with_contents("{}").unwrap(); + + // Test ConfigurationOptions.root_turbo_json_path + let config = ConfigurationOptions::default(); + let result = config.root_turbo_json_path(repo_root); + + assert_eq!(result.unwrap(), turbo_json_path); + } + + #[test] + fn test_only_turbo_jsonc() { + let tmp_dir = TempDir::new().unwrap(); + let repo_root = AbsoluteSystemPath::from_std_path(tmp_dir.path()).unwrap(); + + // Create only turbo.jsonc + let turbo_jsonc_path = repo_root.join_component(CONFIG_FILE_JSONC); + turbo_jsonc_path.create_with_contents("{}").unwrap(); + + // Test ConfigurationOptions.root_turbo_json_path + let config = ConfigurationOptions::default(); + let result = config.root_turbo_json_path(repo_root); + + assert_eq!(result.unwrap(), turbo_jsonc_path); + } + + #[test] + fn test_no_turbo_config() { + let tmp_dir = TempDir::new().unwrap(); + let repo_root = AbsoluteSystemPath::from_std_path(tmp_dir.path()).unwrap(); + + // Test ConfigurationOptions.root_turbo_json_path + let config = ConfigurationOptions::default(); + let result = config.root_turbo_json_path(repo_root); + + assert_eq!(result.unwrap(), repo_root.join_component(CONFIG_FILE)); + } } diff --git a/crates/turborepo-lib/src/config/turbo_json.rs b/crates/turborepo-lib/src/config/turbo_json.rs index dd05b086814e9..f75a83752e1ff 100644 --- a/crates/turborepo-lib/src/config/turbo_json.rs +++ b/crates/turborepo-lib/src/config/turbo_json.rs @@ -52,7 +52,7 @@ impl<'a> ResolvedConfigurationOptions for TurboJsonReader<'a> { &self, existing_config: &ConfigurationOptions, ) -> Result { - let turbo_json_path = existing_config.root_turbo_json_path(self.repo_root); + let turbo_json_path = existing_config.root_turbo_json_path(self.repo_root)?; let turbo_json = RawTurboJson::read(self.repo_root, &turbo_json_path).or_else(|e| { if let Error::Io(e) = &e { if matches!(e.kind(), std::io::ErrorKind::NotFound) { @@ -72,6 +72,7 @@ mod test { use tempfile::tempdir; use super::*; + use crate::turbo_json::CONFIG_FILE; #[test] fn test_reads_from_default() { @@ -82,7 +83,7 @@ mod test { ..Default::default() }; repo_root - .join_component("turbo.json") + .join_component(CONFIG_FILE) .create_with_contents( serde_json::to_string_pretty(&serde_json::json!({ "daemon": false diff --git a/crates/turborepo-lib/src/opts.rs b/crates/turborepo-lib/src/opts.rs index 0998fbd42bb1f..b72eb7aa8a96c 100644 --- a/crates/turborepo-lib/src/opts.rs +++ b/crates/turborepo-lib/src/opts.rs @@ -14,7 +14,7 @@ use crate::{ }, config::ConfigurationOptions, run::task_id::TaskId, - turbo_json::UIMode, + turbo_json::{UIMode, CONFIG_FILE}, Args, }; @@ -287,7 +287,10 @@ pub enum ResolvedLogPrefix { impl<'a> From> for RepoOpts { fn from(inputs: OptsInputs<'a>) -> Self { - let root_turbo_json_path = inputs.config.root_turbo_json_path(inputs.repo_root); + let root_turbo_json_path = inputs + .config + .root_turbo_json_path(inputs.repo_root) + .unwrap_or_else(|_| inputs.repo_root.join_component(CONFIG_FILE)); let allow_no_package_manager = inputs.config.allow_no_package_manager(); let allow_no_turbo_json = inputs.config.allow_no_turbo_json(); @@ -553,7 +556,7 @@ mod test { commands::CommandBase, config::ConfigurationOptions, opts::{Opts, RunCacheOpts, ScopeOpts}, - turbo_json::UIMode, + turbo_json::{UIMode, CONFIG_FILE}, Args, }; @@ -694,7 +697,9 @@ mod test { .map(|(base, head)| (Some(base), Some(head))), }; let config = ConfigurationOptions::default(); - let root_turbo_json_path = config.root_turbo_json_path(&AbsoluteSystemPathBuf::default()); + let root_turbo_json_path = config + .root_turbo_json_path(&AbsoluteSystemPathBuf::default()) + .unwrap_or_else(|_| AbsoluteSystemPathBuf::default().join_component(CONFIG_FILE)); let opts = Opts { repo_opts: RepoOpts { @@ -801,11 +806,11 @@ mod test { let tmpdir = TempDir::new()?; let repo_root = AbsoluteSystemPathBuf::try_from(tmpdir.path())?; - repo_root - .join_component("turbo.json") - .create_with_contents(serde_json::to_string_pretty(&serde_json::json!({ + repo_root.join_component(CONFIG_FILE).create_with_contents( + serde_json::to_string_pretty(&serde_json::json!({ "remoteCache": { "enabled": true } - }))?)?; + }))?, + )?; let mut args = Args::default(); args.command = Some(Command::Run { diff --git a/crates/turborepo-lib/src/run/builder.rs b/crates/turborepo-lib/src/run/builder.rs index ec7935f97116f..daa48333c61e5 100644 --- a/crates/turborepo-lib/src/run/builder.rs +++ b/crates/turborepo-lib/src/run/builder.rs @@ -383,19 +383,21 @@ impl RunBuilder { let task_access = TaskAccess::new(self.repo_root.clone(), async_cache.clone(), &scm); task_access.restore_config().await; + let root_turbo_json_path = self.opts.repo_opts.root_turbo_json_path.clone(); + let turbo_json_loader = if task_access.is_enabled() { TurboJsonLoader::task_access( self.repo_root.clone(), - self.opts.repo_opts.root_turbo_json_path.clone(), + root_turbo_json_path.clone(), root_package_json.clone(), ) } else if is_single_package { TurboJsonLoader::single_package( self.repo_root.clone(), - self.opts.repo_opts.root_turbo_json_path.clone(), + root_turbo_json_path.clone(), root_package_json.clone(), ) - } else if !self.opts.repo_opts.root_turbo_json_path.exists() && + } else if !root_turbo_json_path.exists() && // Infer a turbo.json if allowing no turbo.json is explicitly allowed or if MFE configs are discovered (self.opts.repo_opts.allow_no_turbo_json || micro_frontend_configs.is_some()) { @@ -407,14 +409,14 @@ impl RunBuilder { } else if let Some(micro_frontends) = µ_frontend_configs { TurboJsonLoader::workspace_with_microfrontends( self.repo_root.clone(), - self.opts.repo_opts.root_turbo_json_path.clone(), + root_turbo_json_path.clone(), pkg_dep_graph.packages(), micro_frontends.clone(), ) } else { TurboJsonLoader::workspace( self.repo_root.clone(), - self.opts.repo_opts.root_turbo_json_path.clone(), + root_turbo_json_path.clone(), pkg_dep_graph.packages(), ) }; diff --git a/crates/turborepo-lib/src/run/watch.rs b/crates/turborepo-lib/src/run/watch.rs index cecb617288d32..c4d06dcb925d6 100644 --- a/crates/turborepo-lib/src/run/watch.rs +++ b/crates/turborepo-lib/src/run/watch.rs @@ -120,7 +120,9 @@ impl WatchClient { let signal = get_signal()?; let handler = SignalHandler::new(signal); - if base.opts.repo_opts.root_turbo_json_path != base.repo_root.join_component(CONFIG_FILE) { + // Check if the turbo.json path is the standard one + let standard_path = base.repo_root.join_component(CONFIG_FILE); + if base.opts.repo_opts.root_turbo_json_path != standard_path { return Err(Error::NonStandardTurboJsonPath( base.opts.repo_opts.root_turbo_json_path.to_string(), )); diff --git a/crates/turborepo-lib/src/turbo_json/loader.rs b/crates/turborepo-lib/src/turbo_json/loader.rs index 9e8edd2d21f92..ac312babf03d4 100644 --- a/crates/turborepo-lib/src/turbo_json/loader.rs +++ b/crates/turborepo-lib/src/turbo_json/loader.rs @@ -9,7 +9,7 @@ use turborepo_repository::{ package_json::PackageJson, }; -use super::{Pipeline, RawTaskDefinition, TurboJson, CONFIG_FILE}; +use super::{Pipeline, RawTaskDefinition, TurboJson, CONFIG_FILE, CONFIG_FILE_JSONC}; use crate::{ cli::EnvMode, config::Error, @@ -57,7 +57,7 @@ impl TurboJsonLoader { root_turbo_json_path: AbsoluteSystemPathBuf, packages: impl Iterator, ) -> Self { - let packages = package_turbo_jsons(&repo_root, root_turbo_json_path, packages); + let packages = package_turbo_json_dirs(&repo_root, root_turbo_json_path, packages); Self { repo_root, cache: FixedMap::new(packages.keys().cloned()), @@ -75,7 +75,7 @@ impl TurboJsonLoader { packages: impl Iterator, micro_frontends_configs: MicrofrontendsConfigs, ) -> Self { - let packages = package_turbo_jsons(&repo_root, root_turbo_json_path, packages); + let packages = package_turbo_json_dirs(&repo_root, root_turbo_json_path, packages); Self { repo_root, cache: FixedMap::new(packages.keys().cloned()), @@ -184,8 +184,15 @@ impl TurboJsonLoader { packages, micro_frontends_configs, } => { - let path = packages.get(package).ok_or_else(|| Error::NoTurboJSON)?; - let turbo_json = load_from_file(&self.repo_root, path); + let turbo_json_path = packages.get(package).ok_or_else(|| Error::NoTurboJSON)?; + let turbo_json = load_from_file( + &self.repo_root, + if package == &PackageName::Root { + LoadTurboJsonPath::File(turbo_json_path) + } else { + LoadTurboJsonPath::Dir(turbo_json_path) + }, + ); if let Some(mfe_configs) = micro_frontends_configs { mfe_configs.update_turbo_json(package, turbo_json) } else { @@ -227,8 +234,9 @@ impl TurboJsonLoader { } } -/// Map all packages in the package graph to their turbo.json path -fn package_turbo_jsons<'a>( +/// Map all packages in the package graph to their dirs that contain a +/// turbo.json +fn package_turbo_json_dirs<'a>( repo_root: &AbsoluteSystemPath, root_turbo_json_path: AbsoluteSystemPathBuf, packages: impl Iterator, @@ -239,12 +247,7 @@ fn package_turbo_jsons<'a>( if pkg == &PackageName::Root { None } else { - Some(( - pkg.clone(), - repo_root - .resolve(info.package_path()) - .join_component(CONFIG_FILE), - )) + Some((pkg.clone(), repo_root.resolve(info.package_path()))) } })); package_turbo_jsons @@ -264,15 +267,45 @@ fn workspace_package_scripts<'a>( .collect() } +enum LoadTurboJsonPath<'a> { + // Look for a turbo.json in this directory + Dir(&'a AbsoluteSystemPath), + // Only use this path as a source for turbo.json + // Does not need to have filename of turbo.json + File(&'a AbsoluteSystemPath), +} + fn load_from_file( repo_root: &AbsoluteSystemPath, - turbo_json_path: &AbsoluteSystemPath, + turbo_json_path: LoadTurboJsonPath, ) -> Result { - match TurboJson::read(repo_root, turbo_json_path) { + let result = match turbo_json_path { + LoadTurboJsonPath::Dir(turbo_json_dir_path) => { + let turbo_json_path = turbo_json_dir_path.join_component(CONFIG_FILE); + let turbo_jsonc_path = turbo_json_dir_path.join_component(CONFIG_FILE_JSONC); + + // Load both turbo.json and turbo.jsonc + let turbo_json = TurboJson::read(repo_root, &turbo_json_path); + let turbo_jsonc = TurboJson::read(repo_root, &turbo_jsonc_path); + + // If both are present, error as we don't know which to use + if turbo_json.is_ok() && turbo_jsonc.is_ok() { + return Err(Error::MultipleTurboConfigs { + directory: turbo_json_dir_path.to_string(), + }); + } + + // Attempt to use the turbo.json that was successfully parsed + turbo_json.or(turbo_jsonc) + } + LoadTurboJsonPath::File(turbo_json_path) => TurboJson::read(repo_root, turbo_json_path), + }; + + // Handle errors or success + match result { // If the file didn't exist, throw a custom error here instead of propagating Err(Error::Io(_)) => Err(Error::NoTurboJSON), // There was an error, and we don't have any chance of recovering - // because we aren't synthesizing anything Err(e) => Err(e), // We're not synthesizing anything and there was no error, we're done Ok(turbo) => Ok(turbo), @@ -398,12 +431,13 @@ mod test { use std::{collections::BTreeMap, fs}; use anyhow::Result; + use insta::assert_snapshot; use tempfile::tempdir; use test_case::test_case; use turborepo_unescape::UnescapedString; use super::*; - use crate::{task_graph::TaskDefinition, turbo_json::CONFIG_FILE}; + use crate::{config::Error, task_graph::TaskDefinition}; #[test_case(r"{}", TurboJson::default() ; "empty")] #[test_case(r#"{ "globalDependencies": ["tsconfig.json", "jest.config.ts"] }"#, @@ -432,7 +466,7 @@ mod test { repo_root: repo_root.to_owned(), cache: FixedMap::new(Some(PackageName::Root).into_iter()), strategy: Strategy::Workspace { - packages: vec![(PackageName::Root, root_turbo_json)] + packages: vec![(PackageName::Root, root_turbo_json.to_owned())] .into_iter() .collect(), micro_frontends_configs: None, @@ -625,9 +659,12 @@ mod test { let repo_root = AbsoluteSystemPath::from_std_path(root_dir.path()).unwrap(); let a_turbo_json = repo_root.join_components(&["packages", "a", "turbo.json"]); a_turbo_json.ensure_dir().unwrap(); - let packages = vec![(PackageName::from("a"), a_turbo_json.clone())] - .into_iter() - .collect(); + let packages = vec![( + PackageName::from("a"), + a_turbo_json.parent().unwrap().to_owned(), + )] + .into_iter() + .collect(); let loader = TurboJsonLoader { repo_root: repo_root.to_owned(), @@ -657,9 +694,12 @@ mod test { let repo_root = AbsoluteSystemPath::from_std_path(root_dir.path()).unwrap(); let a_turbo_json = repo_root.join_components(&["packages", "a", "turbo.json"]); a_turbo_json.ensure_dir().unwrap(); - let packages = vec![(PackageName::from("a"), a_turbo_json.clone())] - .into_iter() - .collect(); + let packages = vec![( + PackageName::from("a"), + a_turbo_json.parent().unwrap().to_owned(), + )] + .into_iter() + .collect(); let loader = TurboJsonLoader { repo_root: repo_root.to_owned(), @@ -831,4 +871,68 @@ mod test { } } } + + #[test] + fn test_load_from_file_with_both_files() -> Result<()> { + let tmp_dir = tempdir()?; + let repo_root = AbsoluteSystemPath::from_std_path(tmp_dir.path())?; + + // Create both turbo.json and turbo.jsonc + let turbo_json_path = repo_root.join_component(CONFIG_FILE); + let turbo_jsonc_path = repo_root.join_component(CONFIG_FILE_JSONC); + + turbo_json_path.create_with_contents("{}")?; + turbo_jsonc_path.create_with_contents("{}")?; + + // Test load_from_file with turbo.json path + let result = load_from_file(repo_root, LoadTurboJsonPath::Dir(repo_root)); + + // The function should return an error when both files exist + assert!(result.is_err()); + let mut err = result.unwrap_err(); + // Override tmpdir so we can snapshot the error message + if let Error::MultipleTurboConfigs { directory } = &mut err { + *directory = "some-dir".to_owned() + } + assert_snapshot!(err, @r" + Found both turbo.json and turbo.jsonc in the same directory: some-dir + Remove either turbo.json or turbo.jsonc so there is only one. + "); + + Ok(()) + } + + #[test] + fn test_load_from_file_with_only_turbo_json() -> Result<()> { + let tmp_dir = tempdir()?; + let repo_root = AbsoluteSystemPath::from_std_path(tmp_dir.path())?; + + // Create only turbo.json + let turbo_json_path = repo_root.join_component(CONFIG_FILE); + turbo_json_path.create_with_contents("{}")?; + + // Test load_from_file + let result = load_from_file(repo_root, LoadTurboJsonPath::Dir(repo_root)); + + assert!(result.is_ok()); + + Ok(()) + } + + #[test] + fn test_load_from_file_with_only_turbo_jsonc() -> Result<()> { + let tmp_dir = tempdir()?; + let repo_root = AbsoluteSystemPath::from_std_path(tmp_dir.path())?; + + // Create only turbo.jsonc + let turbo_jsonc_path = repo_root.join_component(CONFIG_FILE_JSONC); + turbo_jsonc_path.create_with_contents("{}")?; + + // Test load_from_file + let result = load_from_file(repo_root, LoadTurboJsonPath::Dir(repo_root)); + + assert!(result.is_ok()); + + Ok(()) + } } diff --git a/crates/turborepo-lib/src/turbo_json/mod.rs b/crates/turborepo-lib/src/turbo_json/mod.rs index 63b5c799918e2..d7a3a5c84043c 100644 --- a/crates/turborepo-lib/src/turbo_json/mod.rs +++ b/crates/turborepo-lib/src/turbo_json/mod.rs @@ -297,6 +297,7 @@ impl RawTaskDefinition { } pub const CONFIG_FILE: &str = "turbo.json"; +pub const CONFIG_FILE_JSONC: &str = "turbo.jsonc"; const ENV_PIPELINE_DELIMITER: &str = "$"; const TOPOLOGICAL_PIPELINE_DELIMITER: &str = "^"; diff --git a/crates/turborepo-scm/src/package_deps.rs b/crates/turborepo-scm/src/package_deps.rs index 85fba9051d467..8ca96cfd5e64d 100644 --- a/crates/turborepo-scm/src/package_deps.rs +++ b/crates/turborepo-scm/src/package_deps.rs @@ -204,6 +204,7 @@ impl Git { // downstream. inputs.push("package.json".to_string()); inputs.push("turbo.json".to_string()); + inputs.push("turbo.jsonc".to_string()); } // The input patterns are relative to the package. diff --git a/packages/turbo-utils/__fixtures__/common/both-configs/package.json b/packages/turbo-utils/__fixtures__/common/both-configs/package.json new file mode 100644 index 0000000000000..4da053e7f9e33 --- /dev/null +++ b/packages/turbo-utils/__fixtures__/common/both-configs/package.json @@ -0,0 +1,3 @@ +{ + "name": "both-configs" +} diff --git a/packages/turbo-utils/__fixtures__/common/both-configs/turbo.json b/packages/turbo-utils/__fixtures__/common/both-configs/turbo.json new file mode 100644 index 0000000000000..4ae73ca088edb --- /dev/null +++ b/packages/turbo-utils/__fixtures__/common/both-configs/turbo.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://turbo.build/schema.json", + "globalEnv": ["UNORDERED", "CI"], + "tasks": { + "build": { + "dependsOn": ["^build"] + }, + "test": { + "dependsOn": ["build"], + "outputs": [], + "inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts", "test/**/*.tsx"] + } + } +} diff --git a/packages/turbo-utils/__fixtures__/common/both-configs/turbo.jsonc b/packages/turbo-utils/__fixtures__/common/both-configs/turbo.jsonc new file mode 100644 index 0000000000000..c14c9ef8e2045 --- /dev/null +++ b/packages/turbo-utils/__fixtures__/common/both-configs/turbo.jsonc @@ -0,0 +1,15 @@ +{ + // This is a comment in turbo.jsonc + "$schema": "https://turbo.build/schema.json", + "globalEnv": ["UNORDERED", "CI"], + "tasks": { + "build": { + // A different comment + "dependsOn": ["^build"] + }, + "lint": { + // This task is only in the jsonc file + "outputs": [] + } + } +} diff --git a/packages/turbo-utils/__fixtures__/common/jsonc-package/package.json b/packages/turbo-utils/__fixtures__/common/jsonc-package/package.json new file mode 100644 index 0000000000000..770f76b6e07ff --- /dev/null +++ b/packages/turbo-utils/__fixtures__/common/jsonc-package/package.json @@ -0,0 +1,3 @@ +{ + "name": "jsonc-package" +} diff --git a/packages/turbo-utils/__fixtures__/common/jsonc-package/turbo.jsonc b/packages/turbo-utils/__fixtures__/common/jsonc-package/turbo.jsonc new file mode 100644 index 0000000000000..f496708b05bdc --- /dev/null +++ b/packages/turbo-utils/__fixtures__/common/jsonc-package/turbo.jsonc @@ -0,0 +1,35 @@ +{ + // This is a comment in turbo.jsonc + "$schema": "https://turbo.build/schema.json", + "globalEnv": ["UNORDERED", "CI"], // Another comment + "tasks": { + "build": { + // A workspace's `build` task depends on that workspace's + // topological dependencies' and devDependencies' + // `build` tasks being completed first. The `^` symbol + // indicates an upstream dependency. + "dependsOn": ["^build"] + }, + "test": { + // A workspace's `test` task depends on that workspace's + // own `build` task being completed first. + "dependsOn": ["build"], + "outputs": [], + // A workspace's `test` task should only be rerun when + // either a `.tsx` or `.ts` file has changed. + "inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts", "test/**/*.tsx"] + }, + "lint": { + // A workspace's `lint` task has no dependencies and + // can be run whenever. + "outputs": [] + }, + "deploy": { + // A workspace's `deploy` task depends on the `build`, + // `test`, and `lint` tasks of the same workspace + // being completed. + "dependsOn": ["build", "test", "lint"], + "outputs": [] + } + } +} diff --git a/packages/turbo-utils/__fixtures__/common/jsonc-workspace-configs/apps/web/package.json b/packages/turbo-utils/__fixtures__/common/jsonc-workspace-configs/apps/web/package.json new file mode 100644 index 0000000000000..81fca2803c019 --- /dev/null +++ b/packages/turbo-utils/__fixtures__/common/jsonc-workspace-configs/apps/web/package.json @@ -0,0 +1,3 @@ +{ + "name": "web" +} diff --git a/packages/turbo-utils/__fixtures__/common/jsonc-workspace-configs/apps/web/turbo.jsonc b/packages/turbo-utils/__fixtures__/common/jsonc-workspace-configs/apps/web/turbo.jsonc new file mode 100644 index 0000000000000..47d73f32d7e68 --- /dev/null +++ b/packages/turbo-utils/__fixtures__/common/jsonc-workspace-configs/apps/web/turbo.jsonc @@ -0,0 +1,11 @@ +{ + // Web app turbo.jsonc file + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + // Web-specific environment variables + "env": ["ENV_2"] + } + } +} diff --git a/packages/turbo-utils/__fixtures__/common/jsonc-workspace-configs/package.json b/packages/turbo-utils/__fixtures__/common/jsonc-workspace-configs/package.json new file mode 100644 index 0000000000000..e3ac2698f6c98 --- /dev/null +++ b/packages/turbo-utils/__fixtures__/common/jsonc-workspace-configs/package.json @@ -0,0 +1,7 @@ +{ + "name": "jsonc-workspace-configs", + "workspaces": [ + "apps/*", + "packages/*" + ] +} diff --git a/packages/turbo-utils/__fixtures__/common/jsonc-workspace-configs/turbo.jsonc b/packages/turbo-utils/__fixtures__/common/jsonc-workspace-configs/turbo.jsonc new file mode 100644 index 0000000000000..3a1dabc2b66dc --- /dev/null +++ b/packages/turbo-utils/__fixtures__/common/jsonc-workspace-configs/turbo.jsonc @@ -0,0 +1,11 @@ +{ + // Root turbo.jsonc file + "$schema": "https://turbo.build/schema.json", + "globalEnv": ["CI"], + "tasks": { + "build": { + // Environment variables for build + "env": ["ENV_1"] + } + } +} diff --git a/packages/turbo-utils/__tests__/getTurboConfigs.test.ts b/packages/turbo-utils/__tests__/getTurboConfigs.test.ts index de4bc8a0d3885..26f0b4dc50a67 100644 --- a/packages/turbo-utils/__tests__/getTurboConfigs.test.ts +++ b/packages/turbo-utils/__tests__/getTurboConfigs.test.ts @@ -1,6 +1,8 @@ import path from "node:path"; import { setupTestFixtures } from "@turbo/test-utils"; import { describe, it, expect } from "@jest/globals"; +import JSON5 from "json5"; +import type { TurboConfigs } from "../src/getTurboConfigs"; import { getTurboConfigs } from "../src/getTurboConfigs"; describe("getTurboConfigs", () => { @@ -11,7 +13,8 @@ describe("getTurboConfigs", () => { it("supports single-package repos", () => { const { root } = useFixture({ fixture: `single-package` }); - const configs = getTurboConfigs(root); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- We know it's in the fixture. + const configs = getTurboConfigs(root)!; expect(configs).toHaveLength(1); expect(configs[0].isRootConfig).toBe(true); expect(configs[0].config).toMatchInlineSnapshot(` @@ -57,7 +60,8 @@ describe("getTurboConfigs", () => { it("supports repos using workspace configs", () => { const { root } = useFixture({ fixture: `workspace-configs` }); - const configs = getTurboConfigs(root); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- We know it's in the fixture. + const configs = getTurboConfigs(root)!; expect(configs).toHaveLength(3); expect(configs[0].isRootConfig).toBe(true); @@ -113,7 +117,8 @@ describe("getTurboConfigs", () => { it("supports repos with old workspace configuration format", () => { const { root } = useFixture({ fixture: `old-workspace-config` }); - const configs = getTurboConfigs(root); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- We know it's in the fixture. + const configs = getTurboConfigs(root)!; expect(configs).toHaveLength(1); expect(configs[0].isRootConfig).toBe(true); @@ -140,3 +145,61 @@ describe("getTurboConfigs", () => { `); }); }); + +// Test JSON5 parsing functionality directly +describe("JSON5 parsing for turbo.jsonc", () => { + it("correctly parses turbo.jsonc with comments", () => { + const turboJsoncContent = `{ + // This is a comment in turbo.jsonc + "$schema": "https://turbo.build/schema.json", + "globalEnv": ["UNORDERED", "CI"], // Another comment + "tasks": { + "build": { + // A workspace's build task depends on dependencies + "dependsOn": ["^build"] + }, + "test": { + "dependsOn": ["build"], + "outputs": [], + "inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts", "test/**/*.tsx"] + }, + "lint": { + "outputs": [] + }, + "deploy": { + "dependsOn": ["build", "test", "lint"], + "outputs": [] + } + } + }`; + + const parsed: TurboConfigs = JSON5.parse(turboJsoncContent); + + expect(parsed).toMatchObject({ + $schema: "https://turbo.build/schema.json", + globalEnv: ["UNORDERED", "CI"], + tasks: { + build: { + dependsOn: ["^build"], + }, + test: { + dependsOn: ["build"], + outputs: [], + inputs: [ + "src/**/*.tsx", + "src/**/*.ts", + "test/**/*.ts", + "test/**/*.tsx", + ], + }, + lint: { + outputs: [], + }, + deploy: { + dependsOn: ["build", "test", "lint"], + outputs: [], + }, + }, + }); + }); +}); diff --git a/packages/turbo-utils/src/getTurboConfigs.ts b/packages/turbo-utils/src/getTurboConfigs.ts index 0f76ab2234598..329a7481f4564 100644 --- a/packages/turbo-utils/src/getTurboConfigs.ts +++ b/packages/turbo-utils/src/getTurboConfigs.ts @@ -14,7 +14,7 @@ import * as logger from "./logger"; import { getTurboRoot } from "./getTurboRoot"; import type { PackageJson, PNPMWorkspaceConfig } from "./types"; -const ROOT_GLOB = "turbo.json"; +const ROOT_GLOB = "{turbo.json,turbo.jsonc}"; const ROOT_WORKSPACE_GLOB = "package.json"; export interface WorkspaceConfig { @@ -83,7 +83,7 @@ export function getTurboConfigs(cwd?: string, opts?: Options): TurboConfigs { if (turboRoot) { const workspaceGlobs = getWorkspaceGlobs(turboRoot); const workspaceConfigGlobs = workspaceGlobs.map( - (glob) => `${glob}/turbo.json` + (glob) => `${glob}/${ROOT_GLOB}` ); const configPaths = sync([ROOT_GLOB, ...workspaceConfigGlobs], { @@ -94,21 +94,43 @@ export function getTurboConfigs(cwd?: string, opts?: Options): TurboConfigs { suppressErrors: true, }).map((configPath) => path.join(turboRoot, configPath)); - configPaths.forEach((configPath) => { + // Check for both turbo.json and turbo.jsonc in the same directory + const configPathsByDir: Record> = {}; + + // Group config paths by directory + for (const configPath of configPaths) { + const dir = path.dirname(configPath); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- configPathsByDir[dir] can be undefined + if (!configPathsByDir[dir]) { + configPathsByDir[dir] = []; + } + configPathsByDir[dir].push(configPath); + } + + // Process each directory + for (const [dir, dirConfigPaths] of Object.entries(configPathsByDir)) { + // If both turbo.json and turbo.jsonc exist in the same directory, throw an error + if (dirConfigPaths.length > 1) { + const errorMessage = `Found both turbo.json and turbo.jsonc in the same directory: ${dir}\nPlease use either turbo.json or turbo.jsonc, but not both.`; + logger.error(errorMessage); + throw new Error(errorMessage); + } + + const configPath = dirConfigPaths[0]; try { const raw = fs.readFileSync(configPath, "utf8"); - // eslint-disable-next-line import/no-named-as-default-member -- json5 exports different objects depending on if you're using esm or cjs (https://github.com/json5/json5/issues/240) + const turboJsonContent: SchemaV1 = JSON5.parse(raw); // basic config validation const isRootConfig = path.dirname(configPath) === turboRoot; if (isRootConfig) { // invalid - root config with extends if ("extends" in turboJsonContent) { - return; + continue; } } else if (!("extends" in turboJsonContent)) { // invalid - workspace config with no extends - return; + continue; } configs.push({ config: turboJsonContent, @@ -120,7 +142,7 @@ export function getTurboConfigs(cwd?: string, opts?: Options): TurboConfigs { // if we can't read or parse the config, just ignore it with a warning logger.warn(e); } - }); + } } if (cacheEnabled && cwd) { @@ -157,7 +179,7 @@ export function getWorkspaceConfigs( suppressErrors: true, }).map((configPath) => path.join(turboRoot, configPath)); - configPaths.forEach((configPath) => { + for (const configPath of configPaths) { try { const rawPackageJson = fs.readFileSync(configPath, "utf8"); const packageJsonContent = JSON.parse(rawPackageJson) as PackageJson; @@ -166,29 +188,48 @@ export function getWorkspaceConfigs( const workspacePath = path.dirname(configPath); const isWorkspaceRoot = workspacePath === turboRoot; - // Try and get turbo.json + // Try and get turbo.json or turbo.jsonc const turboJsonPath = path.join(workspacePath, "turbo.json"); + const turboJsoncPath = path.join(workspacePath, "turbo.jsonc"); + + // Check if both files exist + const turboJsonExists = fs.existsSync(turboJsonPath); + const turboJsoncExists = fs.existsSync(turboJsoncPath); + + if (turboJsonExists && turboJsoncExists) { + const errorMessage = `Found both turbo.json and turbo.jsonc in the same directory: ${workspacePath}\nPlease use either turbo.json or turbo.jsonc, but not both.`; + logger.error(errorMessage); + throw new Error(errorMessage); + } + let rawTurboJson = null; let turboConfig: SchemaV1 | undefined; + try { - rawTurboJson = fs.readFileSync(turboJsonPath, "utf8"); - // eslint-disable-next-line import/no-named-as-default-member -- json5 exports different objects depending on if you're using esm or cjs (https://github.com/json5/json5/issues/240) - turboConfig = JSON5.parse(rawTurboJson); - - if (turboConfig) { - // basic config validation - if (isWorkspaceRoot) { - // invalid - root config with extends - if ("extends" in turboConfig) { - return; + if (turboJsonExists) { + rawTurboJson = fs.readFileSync(turboJsonPath, "utf8"); + } else if (turboJsoncExists) { + rawTurboJson = fs.readFileSync(turboJsoncPath, "utf8"); + } + + if (rawTurboJson) { + turboConfig = JSON5.parse(rawTurboJson); + + if (turboConfig) { + // basic config validation + if (isWorkspaceRoot) { + // invalid - root config with extends + if ("extends" in turboConfig) { + continue; + } + } else if (!("extends" in turboConfig)) { + // invalid - workspace config with no extends + continue; } - } else if (!("extends" in turboConfig)) { - // invalid - workspace config with no extends - return; } } } catch (e) { - // It is fine for there to not be a turbo.json. + // It is fine for there to not be a turbo.json or turbo.jsonc. } configs.push({ @@ -201,7 +242,7 @@ export function getWorkspaceConfigs( // if we can't read or parse the config, just ignore it with a warning logger.warn(e); } - }); + } } if (cacheEnabled && cwd) { diff --git a/turborepo-tests/integration/fixtures/turbo-configs/basic-with-extends.json b/turborepo-tests/integration/fixtures/turbo-configs/basic-with-extends.json new file mode 100644 index 0000000000000..23d7cbfa7177a --- /dev/null +++ b/turborepo-tests/integration/fixtures/turbo-configs/basic-with-extends.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": [] + }, + "test": { + "dependsOn": ["build"], + "outputs": [] + } + } +} diff --git a/turborepo-tests/integration/tests/jsonc/turbo-jsonc.t b/turborepo-tests/integration/tests/jsonc/turbo-jsonc.t new file mode 100644 index 0000000000000..75882ffa731c0 --- /dev/null +++ b/turborepo-tests/integration/tests/jsonc/turbo-jsonc.t @@ -0,0 +1,72 @@ +Setup + $ . ${TESTDIR}/../../../helpers/setup_integration_test.sh + +# Test 1: Error when both turbo.json and turbo.jsonc exist in the same directory +Create both turbo.json and turbo.jsonc in the root + $ cp turbo.json turbo.jsonc + +Run turbo build with both files present + $ ${TURBO} build 2> error.txt + [1] + $ tail -n2 error.txt + | Remove either turbo.json or turbo.jsonc so there is only one. + + +# Test 2: Using turbo.jsonc in the root +Remove turbo.json and use only turbo.jsonc + $ rm turbo.json + +Run turbo build with only turbo.jsonc + $ ${TURBO} build --output-logs=none + • Packages in scope: another, my-app, util + • Running build in 3 packages + • Remote caching disabled + + Tasks: 2 successful, 2 total + Cached: 0 cached, 2 total + Time:\s*[\.0-9]+m?s (re) + + WARNING no output files found for task my-app#build. Please check your `outputs` key in `turbo.json` + +# Test 3: Using turbo.json in the root and turbo.jsonc in a package +Setup turbo.json in root and turbo.jsonc in a package + $ mv turbo.jsonc turbo.json + $ cp ${TESTDIR}/../../../integration/fixtures/turbo-configs/basic-with-extends.json apps/my-app/turbo.jsonc + +Run turbo build with root turbo.json and package turbo.jsonc + $ ${TURBO} build --output-logs=none + • Packages in scope: another, my-app, util (esc) + • Running build in 3 packages (esc) + • Remote caching disabled + + Tasks: 2 successful, 2 total + Cached: 1 cached, 2 total + Time:\s*[\.0-9]+m?s (re) + + +# Test 4: Using turbo.jsonc in the root and turbo.json in a package +Setup turbo.jsonc in root and turbo.json in a package + $ mv turbo.json turbo.jsonc + $ mv apps/my-app/turbo.jsonc apps/my-app/turbo.json + +Run turbo build with root turbo.jsonc and package turbo.json + $ ${TURBO} build --output-logs=none + • Packages in scope: another, my-app, util (esc) + • Running build in 3 packages (esc) + • Remote caching disabled + + Tasks: 2 successful, 2 total + Cached: 1 cached, 2 total + Time:\s*[\.0-9]+m?s (re) + + +# Test 5: Error when both turbo.json and turbo.jsonc exist in a package +Setup both turbo.json and turbo.jsonc in a package + $ cp apps/my-app/turbo.json apps/my-app/turbo.jsonc + +Run turbo build with both files in a package + $ ${TURBO} build 2> error.txt + [1] + $ tail -n2 error.txt + | Remove either turbo.json or turbo.jsonc so there is only one. + diff --git a/turborepo-tests/integration/tests/run/allow-no-root-turbo.t b/turborepo-tests/integration/tests/run/allow-no-root-turbo.t index f5057463c7b8e..a64ab9a540bda 100644 --- a/turborepo-tests/integration/tests/run/allow-no-root-turbo.t +++ b/turborepo-tests/integration/tests/run/allow-no-root-turbo.t @@ -3,7 +3,7 @@ Setup Run fails if not configured to allow missing turbo.json $ ${TURBO} test - x Could not find turbo.json. + x Could not find turbo.json or turbo.jsonc. | Follow directions at https://turbo.build/repo/docs to create one. [1] diff --git a/turborepo-tests/integration/tests/run/no-root-turbo.t b/turborepo-tests/integration/tests/run/no-root-turbo.t index 928d0362626ae..3f6aeba1ead08 100644 --- a/turborepo-tests/integration/tests/run/no-root-turbo.t +++ b/turborepo-tests/integration/tests/run/no-root-turbo.t @@ -4,7 +4,7 @@ Setup Run without --root-turbo-json should fail $ ${TURBO} build - x Could not find turbo.json. + x Could not find turbo.json or turbo.jsonc. | Follow directions at https://turbo.build/repo/docs to create one. [1]