diff --git a/crates/turborepo-lib/src/cli/mod.rs b/crates/turborepo-lib/src/cli/mod.rs index ed1484433bb98..01ab48a2796b7 100644 --- a/crates/turborepo-lib/src/cli/mod.rs +++ b/crates/turborepo-lib/src/cli/mod.rs @@ -222,6 +222,10 @@ pub struct Args { /// should be used. #[clap(long, global = true)] pub dangerously_disable_package_manager_check: bool, + /// Use the `turbo.json` located at the provided path instead of one at the + /// root of the repository. + #[clap(long, global = true)] + pub root_turbo_json: Option, #[clap(flatten, next_help_heading = "Run Arguments")] pub run_args: Option, // This should be inside `RunArgs` but clap currently has a bug diff --git a/crates/turborepo-lib/src/commands/link.rs b/crates/turborepo-lib/src/commands/link.rs index 836e43a28d1f4..698d0eeb36ba1 100644 --- a/crates/turborepo-lib/src/commands/link.rs +++ b/crates/turborepo-lib/src/commands/link.rs @@ -569,7 +569,7 @@ mod test { use anyhow::Result; use tempfile::{NamedTempFile, TempDir}; - use turbopath::{AbsoluteSystemPathBuf, AnchoredSystemPath}; + use turbopath::AbsoluteSystemPathBuf; use turborepo_ui::ColorConfig; use turborepo_vercel_api_mock::start_test_server; @@ -609,7 +609,7 @@ mod test { let port = port_scanner::request_open_port().unwrap(); let handle = tokio::spawn(start_test_server(port)); let mut base = CommandBase { - global_config_path: Some( + override_global_config_path: Some( AbsoluteSystemPathBuf::try_from(user_config_file.path().to_path_buf()).unwrap(), ), repo_root: repo_root.clone(), @@ -675,7 +675,7 @@ mod test { let port = port_scanner::request_open_port().unwrap(); let handle = tokio::spawn(start_test_server(port)); let mut base = CommandBase { - global_config_path: Some( + override_global_config_path: Some( AbsoluteSystemPathBuf::try_from(user_config_file.path().to_path_buf()).unwrap(), ), repo_root: repo_root.clone(), @@ -712,11 +712,7 @@ mod test { // verify space id is added to turbo.json let turbo_json_contents = fs::read_to_string(&turbo_json_file).unwrap(); - let turbo_json = RawTurboJson::parse( - &turbo_json_contents, - AnchoredSystemPath::new("turbo.json").unwrap(), - ) - .unwrap(); + let turbo_json = RawTurboJson::parse(&turbo_json_contents, "turbo.json").unwrap(); assert_eq!( turbo_json.experimental_spaces.unwrap().id.unwrap(), turborepo_vercel_api_mock::EXPECTED_SPACE_ID.into() diff --git a/crates/turborepo-lib/src/commands/mod.rs b/crates/turborepo-lib/src/commands/mod.rs index 12d8c43958c72..9398170b28ec8 100644 --- a/crates/turborepo-lib/src/commands/mod.rs +++ b/crates/turborepo-lib/src/commands/mod.rs @@ -32,8 +32,7 @@ pub(crate) mod unlink; pub struct CommandBase { pub repo_root: AbsoluteSystemPathBuf, pub color_config: ColorConfig, - #[cfg(test)] - pub global_config_path: Option, + pub override_global_config_path: Option, config: OnceCell, args: Args, version: &'static str, @@ -50,16 +49,14 @@ impl CommandBase { repo_root, color_config, args, - #[cfg(test)] - global_config_path: None, + override_global_config_path: None, config: OnceCell::new(), version, } } - #[cfg(test)] - pub fn with_global_config_path(mut self, path: AbsoluteSystemPathBuf) -> Self { - self.global_config_path = Some(path); + pub fn with_override_global_config_path(mut self, path: AbsoluteSystemPathBuf) -> Self { + self.override_global_config_path = Some(path); self } @@ -119,6 +116,13 @@ impl CommandBase { .and_then(|args| args.cache_dir.clone()) }), ) + .with_root_turbo_json_path( + self.args + .root_turbo_json + .clone() + .map(AbsoluteSystemPathBuf::from_cwd) + .transpose()?, + ) .build() } @@ -129,7 +133,7 @@ impl CommandBase { // Getting all of the paths. fn global_config_path(&self) -> Result { #[cfg(test)] - if let Some(global_config_path) = self.global_config_path.clone() { + if let Some(global_config_path) = self.override_global_config_path.clone() { return Ok(global_config_path); } diff --git a/crates/turborepo-lib/src/commands/prune.rs b/crates/turborepo-lib/src/commands/prune.rs index 40648b20c9a3a..0f2069767b293 100644 --- a/crates/turborepo-lib/src/commands/prune.rs +++ b/crates/turborepo-lib/src/commands/prune.rs @@ -449,7 +449,7 @@ impl<'a> Prune<'a> { Err(e) => return Err(e.into()), }; - let turbo_json = RawTurboJson::parse(&turbo_json_contents, anchored_turbo_path)?; + let turbo_json = RawTurboJson::parse(&turbo_json_contents, anchored_turbo_path.as_str())?; let pruned_turbo_json = turbo_json.prune_tasks(workspaces); new_turbo_path.create_with_contents(serde_json::to_string_pretty(&pruned_turbo_json)?)?; diff --git a/crates/turborepo-lib/src/config.rs b/crates/turborepo-lib/src/config.rs deleted file mode 100644 index 9b7d74298617d..0000000000000 --- a/crates/turborepo-lib/src/config.rs +++ /dev/null @@ -1,1099 +0,0 @@ -use std::{ - collections::HashMap, - ffi::{OsStr, OsString}, - io, -}; - -use camino::{Utf8Path, Utf8PathBuf}; -use convert_case::{Case, Casing}; -use miette::{Diagnostic, NamedSource, SourceSpan}; -use serde::Deserialize; -use struct_iterable::Iterable; -use thiserror::Error; -use turbopath::{AbsoluteSystemPathBuf, AnchoredSystemPath, RelativeUnixPath}; -use turborepo_auth::{TURBO_TOKEN_DIR, TURBO_TOKEN_FILE, VERCEL_TOKEN_DIR, VERCEL_TOKEN_FILE}; -use turborepo_dirs::{config_dir, vercel_config_dir}; -use turborepo_errors::TURBO_SITE; - -pub use crate::turbo_json::{RawTurboJson, UIMode}; -use crate::{cli::EnvMode, commands::CommandBase, turbo_json}; - -#[derive(Debug, Error, Diagnostic)] -#[error("Environment variables should not be prefixed with \"{env_pipeline_delimiter}\"")] -#[diagnostic( - code(invalid_env_prefix), - url("{}/messages/{}", TURBO_SITE, self.code().unwrap().to_string().to_case(Case::Kebab)) -)] -pub struct InvalidEnvPrefixError { - pub value: String, - pub key: String, - #[source_code] - pub text: NamedSource, - #[label("variable with invalid prefix declared here")] - pub span: Option, - pub env_pipeline_delimiter: &'static str, -} - -#[allow(clippy::enum_variant_names)] -#[derive(Debug, Error, Diagnostic)] -pub enum Error { - #[error("Authentication error: {0}")] - Auth(#[from] turborepo_auth::Error), - #[error("Global config path not found")] - NoGlobalConfigPath, - #[error("Global auth file path not found")] - NoGlobalAuthFilePath, - #[error("Global config directory not found")] - NoGlobalConfigDir, - #[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 \ - one" - )] - NoTurboJSON, - #[error(transparent)] - SerdeJson(#[from] serde_json::Error), - #[error(transparent)] - Io(#[from] io::Error), - #[error(transparent)] - Camino(#[from] camino::FromPathBufError), - #[error(transparent)] - Reqwest(#[from] reqwest::Error), - #[error("Encountered an IO error while attempting to read {config_path}: {error}")] - FailedToReadConfig { - config_path: AbsoluteSystemPathBuf, - error: io::Error, - }, - #[error("Encountered an IO error while attempting to set {config_path}: {error}")] - FailedToSetConfig { - config_path: AbsoluteSystemPathBuf, - error: io::Error, - }, - #[error( - "Package tasks (#) are not allowed in single-package repositories: found \ - {task_id}" - )] - #[diagnostic(code(package_task_in_single_package_mode), url("{}/messages/{}", TURBO_SITE, self.code().unwrap().to_string().to_case(Case::Kebab)))] - PackageTaskInSinglePackageMode { - task_id: String, - #[source_code] - text: NamedSource, - #[label("package task found here")] - span: Option, - }, - #[error(transparent)] - #[diagnostic(transparent)] - InvalidEnvPrefix(Box), - #[error(transparent)] - PathError(#[from] turbopath::PathError), - #[diagnostic( - code(unnecessary_package_task_syntax), - url("{}/messages/{}", TURBO_SITE, self.code().unwrap().to_string().to_case(Case::Kebab)) - )] - #[error("\"{actual}\". Use \"{wanted}\" instead")] - UnnecessaryPackageTaskSyntax { - actual: String, - wanted: String, - #[label("unnecessary package syntax found here")] - span: Option, - #[source_code] - text: NamedSource, - }, - #[error("You can only extend from the root workspace")] - ExtendFromNonRoot { - #[label("non-root workspace found here")] - span: Option, - #[source_code] - text: NamedSource, - }, - #[error("`{field}` cannot contain an environment variable")] - InvalidDependsOnValue { - field: &'static str, - #[label("environment variable found here")] - span: Option, - #[source_code] - text: NamedSource, - }, - #[error("`{field}` cannot contain an absolute path")] - AbsolutePathInConfig { - field: &'static str, - #[label("absolute path found here")] - span: Option, - #[source_code] - text: NamedSource, - }, - #[error("No \"extends\" key found")] - NoExtends { - #[label("add extends key here")] - span: Option, - #[source_code] - text: NamedSource, - }, - #[error("Tasks cannot be marked as interactive and cacheable")] - InteractiveNoCacheable { - #[label("marked interactive here")] - span: Option, - #[source_code] - text: NamedSource, - }, - #[error("found `pipeline` field instead of `tasks`")] - #[diagnostic(help("changed in 2.0: `pipeline` has been renamed to `tasks`"))] - PipelineField { - #[label("rename `pipeline` field to `tasks`")] - span: Option, - #[source_code] - text: NamedSource, - }, - #[error("Failed to create APIClient: {0}")] - ApiClient(#[source] turborepo_api_client::Error), - #[error("{0} is not UTF8.")] - Encoding(String), - #[error("TURBO_SIGNATURE should be either 1 or 0.")] - InvalidSignature, - #[error("TURBO_REMOTE_CACHE_ENABLED should be either 1 or 0.")] - InvalidRemoteCacheEnabled, - #[error("TURBO_REMOTE_CACHE_TIMEOUT: error parsing timeout.")] - InvalidRemoteCacheTimeout(#[source] std::num::ParseIntError), - #[error("TURBO_REMOTE_CACHE_UPLOAD_TIMEOUT: error parsing timeout.")] - InvalidUploadTimeout(#[source] std::num::ParseIntError), - #[error("TURBO_PREFLIGHT should be either 1 or 0.")] - InvalidPreflight, - #[error(transparent)] - #[diagnostic(transparent)] - TurboJsonParseError(#[from] turbo_json::parser::Error), - #[error("found absolute path in `cacheDir`")] - #[diagnostic(help("if absolute paths are required, use `--cache-dir` or `TURBO_CACHE_DIR`"))] - AbsoluteCacheDir { - #[label("make `cacheDir` value a relative unix path")] - span: Option, - #[source_code] - text: NamedSource, - }, -} - -macro_rules! create_builder { - ($func_name:ident, $property_name:ident, $type:ty) => { - pub fn $func_name(mut self, value: $type) -> Self { - self.override_config.$property_name = value; - self - } - }; -} - -const DEFAULT_API_URL: &str = "https://vercel.com/api"; -const DEFAULT_LOGIN_URL: &str = "https://vercel.com"; -const DEFAULT_TIMEOUT: u64 = 30; -const DEFAULT_UPLOAD_TIMEOUT: u64 = 60; - -// We intentionally don't derive Serialize so that different parts -// of the code that want to display the config can tune how they -// want to display and what fields they want to include. -#[derive(Deserialize, Default, Debug, PartialEq, Eq, Clone, Iterable)] -#[serde(rename_all = "camelCase")] -pub struct ConfigurationOptions { - #[serde(alias = "apiurl")] - #[serde(alias = "ApiUrl")] - #[serde(alias = "APIURL")] - pub(crate) api_url: Option, - #[serde(alias = "loginurl")] - #[serde(alias = "LoginUrl")] - #[serde(alias = "LOGINURL")] - pub(crate) login_url: Option, - #[serde(alias = "teamslug")] - #[serde(alias = "TeamSlug")] - #[serde(alias = "TEAMSLUG")] - pub(crate) team_slug: Option, - #[serde(alias = "teamid")] - #[serde(alias = "TeamId")] - #[serde(alias = "TEAMID")] - pub(crate) team_id: Option, - pub(crate) token: Option, - pub(crate) signature: Option, - pub(crate) preflight: Option, - pub(crate) timeout: Option, - pub(crate) upload_timeout: Option, - pub(crate) enabled: Option, - pub(crate) spaces_id: Option, - #[serde(rename = "ui")] - pub(crate) ui: Option, - #[serde(rename = "dangerouslyDisablePackageManagerCheck")] - pub(crate) allow_no_package_manager: Option, - pub(crate) daemon: Option, - #[serde(rename = "envMode")] - pub(crate) env_mode: Option, - pub(crate) scm_base: Option, - pub(crate) scm_head: Option, - #[serde(rename = "cacheDir")] - pub(crate) cache_dir: Option, -} - -#[derive(Default)] -pub struct TurborepoConfigBuilder { - repo_root: AbsoluteSystemPathBuf, - override_config: ConfigurationOptions, - - #[cfg(test)] - global_config_path: Option, - #[cfg(test)] - environment: HashMap, -} - -// Getters -impl ConfigurationOptions { - pub fn api_url(&self) -> &str { - non_empty_str(self.api_url.as_deref()).unwrap_or(DEFAULT_API_URL) - } - - pub fn login_url(&self) -> &str { - non_empty_str(self.login_url.as_deref()).unwrap_or(DEFAULT_LOGIN_URL) - } - - pub fn team_slug(&self) -> Option<&str> { - self.team_slug - .as_deref() - .and_then(|slug| (!slug.is_empty()).then_some(slug)) - } - - pub fn team_id(&self) -> Option<&str> { - non_empty_str(self.team_id.as_deref()) - } - - pub fn token(&self) -> Option<&str> { - non_empty_str(self.token.as_deref()) - } - - pub fn signature(&self) -> bool { - self.signature.unwrap_or_default() - } - - pub fn enabled(&self) -> bool { - self.enabled.unwrap_or(true) - } - - pub fn preflight(&self) -> bool { - self.preflight.unwrap_or_default() - } - - /// Note: 0 implies no timeout - pub fn timeout(&self) -> u64 { - self.timeout.unwrap_or(DEFAULT_TIMEOUT) - } - - /// Note: 0 implies no timeout - pub fn upload_timeout(&self) -> u64 { - self.upload_timeout.unwrap_or(DEFAULT_UPLOAD_TIMEOUT) - } - - pub fn spaces_id(&self) -> Option<&str> { - self.spaces_id.as_deref() - } - - pub fn ui(&self) -> UIMode { - // If we aren't hooked up to a TTY, then do not use TUI - if !atty::is(atty::Stream::Stdout) { - return UIMode::Stream; - } - - self.ui.unwrap_or(UIMode::Stream) - } - - pub fn scm_base(&self) -> Option<&str> { - non_empty_str(self.scm_base.as_deref()) - } - - pub fn scm_head(&self) -> &str { - non_empty_str(self.scm_head.as_deref()).unwrap_or("HEAD") - } - - pub fn allow_no_package_manager(&self) -> bool { - self.allow_no_package_manager.unwrap_or_default() - } - - pub fn daemon(&self) -> Option { - self.daemon - } - - pub fn env_mode(&self) -> EnvMode { - self.env_mode.unwrap_or_default() - } - - pub fn cache_dir(&self) -> &Utf8Path { - self.cache_dir.as_deref().unwrap_or_else(|| { - Utf8Path::new(if cfg!(windows) { - ".turbo\\cache" - } else { - ".turbo/cache" - }) - }) - } -} - -// Maps Some("") to None to emulate how Go handles empty strings -fn non_empty_str(s: Option<&str>) -> Option<&str> { - s.filter(|s| !s.is_empty()) -} - -fn truth_env_var(s: &str) -> Option { - match s { - "true" | "1" => Some(true), - "false" | "0" => Some(false), - _ => None, - } -} - -trait ResolvedConfigurationOptions { - fn get_configuration_options(self) -> Result; -} - -impl ResolvedConfigurationOptions for RawTurboJson { - fn get_configuration_options(self) -> Result { - let mut opts = if let Some(remote_cache_options) = &self.remote_cache { - remote_cache_options.into() - } else { - ConfigurationOptions::default() - }; - - let cache_dir = if let Some(cache_dir) = self.cache_dir { - let cache_dir_str: &str = &cache_dir; - let cache_dir_unix = RelativeUnixPath::new(cache_dir_str).map_err(|_| { - let (span, text) = cache_dir.span_and_text("turbo.json"); - Error::AbsoluteCacheDir { span, text } - })?; - // Convert the relative unix path to an anchored system path - // For unix/macos this is a no-op - let cache_dir_system = cache_dir_unix.to_anchored_system_path_buf(); - Some(Utf8PathBuf::from(cache_dir_system.to_string())) - } else { - None - }; - - // Don't allow token to be set for shared config. - opts.token = None; - opts.spaces_id = self - .experimental_spaces - .and_then(|spaces| spaces.id) - .map(|spaces_id| spaces_id.into()); - opts.ui = self.ui; - opts.allow_no_package_manager = self.allow_no_package_manager; - opts.daemon = self.daemon.map(|daemon| *daemon.as_inner()); - opts.env_mode = self.env_mode; - opts.cache_dir = cache_dir; - Ok(opts) - } -} - -// Used for global config and local config. -impl ResolvedConfigurationOptions for ConfigurationOptions { - fn get_configuration_options(self) -> Result { - Ok(self) - } -} - -fn get_lowercased_env_vars() -> HashMap { - std::env::vars_os() - .map(|(k, v)| (k.to_ascii_lowercase(), v)) - .collect() -} - -fn get_env_var_config( - environment: &HashMap, -) -> Result { - let mut turbo_mapping = HashMap::new(); - turbo_mapping.insert(OsString::from("turbo_api"), "api_url"); - turbo_mapping.insert(OsString::from("turbo_login"), "login_url"); - turbo_mapping.insert(OsString::from("turbo_team"), "team_slug"); - turbo_mapping.insert(OsString::from("turbo_teamid"), "team_id"); - turbo_mapping.insert(OsString::from("turbo_token"), "token"); - turbo_mapping.insert(OsString::from("turbo_remote_cache_timeout"), "timeout"); - turbo_mapping.insert( - OsString::from("turbo_remote_cache_upload_timeout"), - "upload_timeout", - ); - turbo_mapping.insert(OsString::from("turbo_ui"), "ui"); - turbo_mapping.insert( - OsString::from("turbo_dangerously_disable_package_manager_check"), - "allow_no_package_manager", - ); - turbo_mapping.insert(OsString::from("turbo_daemon"), "daemon"); - turbo_mapping.insert(OsString::from("turbo_env_mode"), "env_mode"); - turbo_mapping.insert(OsString::from("turbo_cache_dir"), "cache_dir"); - turbo_mapping.insert(OsString::from("turbo_preflight"), "preflight"); - turbo_mapping.insert(OsString::from("turbo_scm_base"), "scm_base"); - turbo_mapping.insert(OsString::from("turbo_scm_head"), "scm_head"); - - // We do not enable new config sources: - // turbo_mapping.insert(String::from("turbo_signature"), "signature"); // new - // turbo_mapping.insert(String::from("turbo_remote_cache_enabled"), "enabled"); - - let mut output_map = HashMap::new(); - - turbo_mapping.into_iter().try_for_each( - |(mapping_key, mapped_property)| -> Result<(), Error> { - if let Some(value) = environment.get(&mapping_key) { - let converted = value.to_str().ok_or_else(|| { - Error::Encoding( - // CORRECTNESS: the mapping_key is hardcoded above. - mapping_key.to_ascii_uppercase().into_string().unwrap(), - ) - })?; - output_map.insert(mapped_property, converted.to_owned()); - Ok(()) - } else { - Ok(()) - } - }, - )?; - - // Process signature - let signature = if let Some(signature) = output_map.get("signature") { - match signature.as_str() { - "0" => Some(false), - "1" => Some(true), - _ => return Err(Error::InvalidSignature), - } - } else { - None - }; - - // Process preflight - let preflight = if let Some(preflight) = output_map.get("preflight") { - match preflight.as_str() { - "0" | "false" => Some(false), - "1" | "true" => Some(true), - "" => None, - _ => return Err(Error::InvalidPreflight), - } - } else { - None - }; - - // Process enabled - let enabled = if let Some(enabled) = output_map.get("enabled") { - match enabled.as_str() { - "0" => Some(false), - "1" => Some(true), - _ => return Err(Error::InvalidRemoteCacheEnabled), - } - } else { - None - }; - - // Process timeout - let timeout = if let Some(timeout) = output_map.get("timeout") { - Some( - timeout - .parse::() - .map_err(Error::InvalidRemoteCacheTimeout)?, - ) - } else { - None - }; - - let upload_timeout = if let Some(upload_timeout) = output_map.get("upload_timeout") { - Some( - upload_timeout - .parse::() - .map_err(Error::InvalidUploadTimeout)?, - ) - } else { - None - }; - - // Process experimentalUI - let ui = output_map - .get("ui") - .map(|s| s.as_str()) - .and_then(truth_env_var) - .map(|ui| if ui { UIMode::Tui } else { UIMode::Stream }); - - let allow_no_package_manager = output_map - .get("allow_no_package_manager") - .map(|s| s.as_str()) - .and_then(truth_env_var); - - // Process daemon - let daemon = output_map.get("daemon").and_then(|val| match val.as_str() { - "1" | "true" => Some(true), - "0" | "false" => Some(false), - _ => None, - }); - - let env_mode = output_map - .get("env_mode") - .map(|s| s.as_str()) - .and_then(|s| match s { - "strict" => Some(EnvMode::Strict), - "loose" => Some(EnvMode::Loose), - _ => None, - }); - - let cache_dir = output_map.get("cache_dir").map(|s| s.clone().into()); - - // We currently don't pick up a Spaces ID via env var, we likely won't - // continue using the Spaces name, we can add an env var when we have the - // name we want to stick with. - let spaces_id = None; - - let output = ConfigurationOptions { - api_url: output_map.get("api_url").cloned(), - login_url: output_map.get("login_url").cloned(), - team_slug: output_map.get("team_slug").cloned(), - team_id: output_map.get("team_id").cloned(), - token: output_map.get("token").cloned(), - scm_base: output_map.get("scm_base").cloned(), - scm_head: output_map.get("scm_head").cloned(), - - // Processed booleans - signature, - preflight, - enabled, - ui, - allow_no_package_manager, - daemon, - - // Processed numbers - timeout, - upload_timeout, - spaces_id, - env_mode, - cache_dir, - }; - - Ok(output) -} - -fn get_override_env_var_config( - environment: &HashMap, -) -> Result { - let mut vercel_artifacts_mapping = HashMap::new(); - vercel_artifacts_mapping.insert(OsString::from("vercel_artifacts_token"), "token"); - vercel_artifacts_mapping.insert(OsString::from("vercel_artifacts_owner"), "team_id"); - - let mut output_map = HashMap::new(); - - // Process the VERCEL_ARTIFACTS_* next. - vercel_artifacts_mapping.into_iter().try_for_each( - |(mapping_key, mapped_property)| -> Result<(), Error> { - if let Some(value) = environment.get(&mapping_key) { - let converted = value.to_str().ok_or_else(|| { - Error::Encoding( - // CORRECTNESS: the mapping_key is hardcoded above. - mapping_key.to_ascii_uppercase().into_string().unwrap(), - ) - })?; - output_map.insert(mapped_property, converted.to_owned()); - Ok(()) - } else { - Ok(()) - } - }, - )?; - - let ui = environment - .get(OsStr::new("ci")) - .or_else(|| environment.get(OsStr::new("no_color"))) - .and_then(|value| { - // If either of these are truthy, then we disable the TUI - if value == "true" || value == "1" { - Some(UIMode::Stream) - } else { - None - } - }); - - let output = ConfigurationOptions { - api_url: None, - login_url: None, - team_slug: None, - team_id: output_map.get("team_id").cloned(), - token: output_map.get("token").cloned(), - scm_base: None, - scm_head: None, - - signature: None, - preflight: None, - enabled: None, - ui, - daemon: None, - timeout: None, - upload_timeout: None, - spaces_id: None, - allow_no_package_manager: None, - env_mode: None, - cache_dir: None, - }; - - Ok(output) -} - -impl TurborepoConfigBuilder { - pub fn new(base: &CommandBase) -> Self { - Self { - repo_root: base.repo_root.to_owned(), - override_config: Default::default(), - #[cfg(test)] - global_config_path: base.global_config_path.clone(), - #[cfg(test)] - environment: Default::default(), - } - } - - // Getting all of the paths. - fn global_config_path(&self) -> Result { - #[cfg(test)] - if let Some(global_config_path) = self.global_config_path.clone() { - return Ok(global_config_path); - } - - let config_dir = config_dir()?.ok_or(Error::NoGlobalConfigPath)?; - - Ok(config_dir.join_components(&[TURBO_TOKEN_DIR, TURBO_TOKEN_FILE])) - } - fn global_auth_path(&self) -> Result { - #[cfg(test)] - if let Some(global_config_path) = self.global_config_path.clone() { - return Ok(global_config_path); - } - - let vercel_config_dir = vercel_config_dir()?.ok_or(Error::NoGlobalConfigDir)?; - // Check for both Vercel and Turbo paths. Vercel takes priority. - let vercel_path = vercel_config_dir.join_components(&[VERCEL_TOKEN_DIR, VERCEL_TOKEN_FILE]); - if vercel_path.exists() { - return Ok(vercel_path); - } - - let turbo_config_dir = config_dir()?.ok_or(Error::NoGlobalConfigDir)?; - - Ok(turbo_config_dir.join_components(&[TURBO_TOKEN_DIR, TURBO_TOKEN_FILE])) - } - fn local_config_path(&self) -> AbsoluteSystemPathBuf { - self.repo_root.join_components(&[".turbo", "config.json"]) - } - - #[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") - } - - #[cfg(test)] - fn get_environment(&self) -> HashMap { - self.environment.clone() - } - - #[cfg(not(test))] - fn get_environment(&self) -> HashMap { - get_lowercased_env_vars() - } - - fn get_global_config(&self) -> Result { - let global_config_path = self.global_config_path()?; - let mut contents = global_config_path - .read_existing_to_string_or(Ok("{}")) - .map_err(|error| Error::FailedToReadConfig { - config_path: global_config_path.clone(), - error, - })?; - if contents.is_empty() { - contents = String::from("{}"); - } - let global_config: ConfigurationOptions = serde_json::from_str(&contents)?; - Ok(global_config) - } - - fn get_local_config(&self) -> Result { - let local_config_path = self.local_config_path(); - let mut contents = local_config_path - .read_existing_to_string_or(Ok("{}")) - .map_err(|error| Error::FailedToReadConfig { - config_path: local_config_path.clone(), - error, - })?; - if contents.is_empty() { - contents = String::from("{}"); - } - let local_config: ConfigurationOptions = serde_json::from_str(&contents)?; - Ok(local_config) - } - - fn get_global_auth(&self) -> Result { - let global_auth_path = self.global_auth_path()?; - let token = match turborepo_auth::Token::from_file(&global_auth_path) { - Ok(token) => token, - // Multiple ways this can go wrong. Don't error out if we can't find the token - it - // just might not be there. - Err(e) => { - if matches!(e, turborepo_auth::Error::TokenNotFound) { - return Ok(ConfigurationOptions::default()); - } - - return Err(e.into()); - } - }; - - // No auth token found in either Vercel or Turbo config. - if token.into_inner().is_empty() { - return Ok(ConfigurationOptions::default()); - } - - let global_auth: ConfigurationOptions = ConfigurationOptions { - token: Some(token.into_inner().to_owned()), - ..Default::default() - }; - Ok(global_auth) - } - - create_builder!(with_api_url, api_url, Option); - create_builder!(with_login_url, login_url, Option); - create_builder!(with_team_slug, team_slug, Option); - create_builder!(with_team_id, team_id, Option); - create_builder!(with_token, token, Option); - create_builder!(with_signature, signature, Option); - create_builder!(with_enabled, enabled, Option); - create_builder!(with_preflight, preflight, Option); - create_builder!(with_timeout, timeout, Option); - create_builder!(with_ui, ui, Option); - create_builder!( - with_allow_no_package_manager, - allow_no_package_manager, - Option - ); - create_builder!(with_daemon, daemon, Option); - create_builder!(with_env_mode, env_mode, Option); - create_builder!(with_cache_dir, cache_dir, Option); - - pub fn build(&self) -> Result { - // Priority, from least significant to most significant: - // - shared configuration (turbo.json) - // - global configuration (~/.turbo/config.json) - // - local configuration (/.turbo/config.json) - // - environment variables - // - CLI arguments - // - builder pattern overrides. - - let turbo_json = RawTurboJson::read( - &self.repo_root, - AnchoredSystemPath::new("turbo.json").unwrap(), - ) - .or_else(|e| { - if let Error::Io(e) = &e { - if matches!(e.kind(), std::io::ErrorKind::NotFound) { - return Ok(Default::default()); - } - } - - Err(e) - })?; - let global_config = self.get_global_config()?; - let global_auth = self.get_global_auth()?; - let local_config = self.get_local_config()?; - let env_vars = self.get_environment(); - let env_var_config = get_env_var_config(&env_vars)?; - let override_env_var_config = get_override_env_var_config(&env_vars)?; - - let sources = [ - turbo_json.get_configuration_options(), - global_config.get_configuration_options(), - global_auth.get_configuration_options(), - local_config.get_configuration_options(), - env_var_config.get_configuration_options(), - Ok(self.override_config.clone()), - override_env_var_config.get_configuration_options(), - ]; - - sources.into_iter().try_fold( - ConfigurationOptions::default(), - |mut acc, current_source| { - current_source.map(|current_source_config| { - if let Some(api_url) = current_source_config.api_url.clone() { - acc.api_url = Some(api_url); - } - if let Some(login_url) = current_source_config.login_url.clone() { - acc.login_url = Some(login_url); - } - if let Some(team_slug) = current_source_config.team_slug.clone() { - acc.team_slug = Some(team_slug); - } - if let Some(team_id) = current_source_config.team_id.clone() { - acc.team_id = Some(team_id); - } - if let Some(token) = current_source_config.token.clone() { - acc.token = Some(token); - } - if let Some(signature) = current_source_config.signature { - acc.signature = Some(signature); - } - if let Some(enabled) = current_source_config.enabled { - acc.enabled = Some(enabled); - } - if let Some(preflight) = current_source_config.preflight { - acc.preflight = Some(preflight); - } - if let Some(timeout) = current_source_config.timeout { - acc.timeout = Some(timeout); - } - if let Some(spaces_id) = current_source_config.spaces_id { - acc.spaces_id = Some(spaces_id); - } - if let Some(ui) = current_source_config.ui { - acc.ui = Some(ui); - } - if let Some(allow_no_package_manager) = - current_source_config.allow_no_package_manager - { - acc.allow_no_package_manager = Some(allow_no_package_manager); - } - if let Some(daemon) = current_source_config.daemon { - acc.daemon = Some(daemon); - } - if let Some(env_mode) = current_source_config.env_mode { - acc.env_mode = Some(env_mode); - } - if let Some(scm_base) = current_source_config.scm_base { - acc.scm_base = Some(scm_base); - } - if let Some(scm_head) = current_source_config.scm_head { - acc.scm_head = Some(scm_head); - } - if let Some(cache_dir) = current_source_config.cache_dir { - acc.cache_dir = Some(cache_dir); - } - - acc - }) - }, - ) - } -} - -#[cfg(test)] -mod test { - use std::{collections::HashMap, ffi::OsString}; - - use camino::Utf8PathBuf; - use tempfile::TempDir; - use turbopath::AbsoluteSystemPathBuf; - - use crate::{ - cli::EnvMode, - config::{ - get_env_var_config, get_override_env_var_config, ConfigurationOptions, - TurborepoConfigBuilder, DEFAULT_API_URL, DEFAULT_LOGIN_URL, DEFAULT_TIMEOUT, - }, - turbo_json::UIMode, - }; - - #[test] - fn test_defaults() { - let defaults: ConfigurationOptions = Default::default(); - assert_eq!(defaults.api_url(), DEFAULT_API_URL); - assert_eq!(defaults.login_url(), DEFAULT_LOGIN_URL); - assert_eq!(defaults.team_slug(), None); - assert_eq!(defaults.team_id(), None); - assert_eq!(defaults.token(), None); - assert!(!defaults.signature()); - assert!(defaults.enabled()); - assert!(!defaults.preflight()); - assert_eq!(defaults.timeout(), DEFAULT_TIMEOUT); - assert_eq!(defaults.spaces_id(), None); - assert!(!defaults.allow_no_package_manager()); - } - - #[test] - fn test_env_setting() { - let mut env: HashMap = HashMap::new(); - - let turbo_api = "https://example.com/api"; - let turbo_login = "https://example.com/login"; - let turbo_team = "vercel"; - let turbo_teamid = "team_nLlpyC6REAqxydlFKbrMDlud"; - let turbo_token = "abcdef1234567890abcdef"; - let cache_dir = Utf8PathBuf::from("nebulo9"); - let turbo_remote_cache_timeout = 200; - - env.insert("turbo_api".into(), turbo_api.into()); - env.insert("turbo_login".into(), turbo_login.into()); - env.insert("turbo_team".into(), turbo_team.into()); - env.insert("turbo_teamid".into(), turbo_teamid.into()); - env.insert("turbo_token".into(), turbo_token.into()); - env.insert( - "turbo_remote_cache_timeout".into(), - turbo_remote_cache_timeout.to_string().into(), - ); - env.insert("turbo_ui".into(), "true".into()); - env.insert( - "turbo_dangerously_disable_package_manager_check".into(), - "true".into(), - ); - env.insert("turbo_daemon".into(), "true".into()); - env.insert("turbo_preflight".into(), "true".into()); - env.insert("turbo_env_mode".into(), "strict".into()); - env.insert("turbo_cache_dir".into(), cache_dir.clone().into()); - - let config = get_env_var_config(&env).unwrap(); - assert!(config.preflight()); - assert_eq!(turbo_api, config.api_url.unwrap()); - assert_eq!(turbo_login, config.login_url.unwrap()); - assert_eq!(turbo_team, config.team_slug.unwrap()); - assert_eq!(turbo_teamid, config.team_id.unwrap()); - assert_eq!(turbo_token, config.token.unwrap()); - assert_eq!(turbo_remote_cache_timeout, config.timeout.unwrap()); - assert_eq!(Some(UIMode::Tui), config.ui); - assert_eq!(Some(true), config.allow_no_package_manager); - assert_eq!(Some(true), config.daemon); - assert_eq!(Some(EnvMode::Strict), config.env_mode); - assert_eq!(cache_dir, config.cache_dir.unwrap()); - } - - #[test] - fn test_empty_env_setting() { - let mut env: HashMap = HashMap::new(); - env.insert("turbo_api".into(), "".into()); - env.insert("turbo_login".into(), "".into()); - env.insert("turbo_team".into(), "".into()); - env.insert("turbo_teamid".into(), "".into()); - env.insert("turbo_token".into(), "".into()); - env.insert("turbo_ui".into(), "".into()); - env.insert("turbo_daemon".into(), "".into()); - env.insert("turbo_env_mode".into(), "".into()); - env.insert("turbo_preflight".into(), "".into()); - env.insert("turbo_scm_head".into(), "".into()); - env.insert("turbo_scm_base".into(), "".into()); - - let config = get_env_var_config(&env).unwrap(); - assert_eq!(config.api_url(), DEFAULT_API_URL); - assert_eq!(config.login_url(), DEFAULT_LOGIN_URL); - assert_eq!(config.team_slug(), None); - assert_eq!(config.team_id(), None); - assert_eq!(config.token(), None); - assert_eq!(config.ui, None); - assert_eq!(config.daemon, None); - assert_eq!(config.env_mode, None); - assert!(!config.preflight()); - assert_eq!(config.scm_base(), None); - assert_eq!(config.scm_head(), "HEAD"); - } - - #[test] - fn test_override_env_setting() { - let mut env: HashMap = HashMap::new(); - - let vercel_artifacts_token = "correct-horse-battery-staple"; - let vercel_artifacts_owner = "bobby_tables"; - - env.insert( - "vercel_artifacts_token".into(), - vercel_artifacts_token.into(), - ); - env.insert( - "vercel_artifacts_owner".into(), - vercel_artifacts_owner.into(), - ); - env.insert("ci".into(), "1".into()); - - let config = get_override_env_var_config(&env).unwrap(); - assert_eq!(vercel_artifacts_token, config.token.unwrap()); - assert_eq!(vercel_artifacts_owner, config.team_id.unwrap()); - assert_eq!(Some(UIMode::Stream), config.ui); - } - - #[test] - fn test_env_layering() { - let tmp_dir = TempDir::new().unwrap(); - let repo_root = AbsoluteSystemPathBuf::try_from(tmp_dir.path()).unwrap(); - let global_config_path = AbsoluteSystemPathBuf::try_from( - TempDir::new().unwrap().path().join("nonexistent.json"), - ) - .unwrap(); - - repo_root - .join_component("turbo.json") - .create_with_contents(r#"{"experimentalSpaces": {"id": "my-spaces-id"}}"#) - .unwrap(); - - let turbo_teamid = "team_nLlpyC6REAqxydlFKbrMDlud"; - let turbo_token = "abcdef1234567890abcdef"; - let vercel_artifacts_owner = "team_SOMEHASH"; - let vercel_artifacts_token = "correct-horse-battery-staple"; - - let mut env: HashMap = HashMap::new(); - env.insert("turbo_teamid".into(), turbo_teamid.into()); - env.insert("turbo_token".into(), turbo_token.into()); - env.insert( - "vercel_artifacts_token".into(), - vercel_artifacts_token.into(), - ); - env.insert( - "vercel_artifacts_owner".into(), - vercel_artifacts_owner.into(), - ); - - let override_config = ConfigurationOptions { - token: Some("unseen".into()), - team_id: Some("unseen".into()), - ..Default::default() - }; - - let builder = TurborepoConfigBuilder { - repo_root, - override_config, - global_config_path: Some(global_config_path), - environment: env, - }; - - let config = builder.build().unwrap(); - assert_eq!(config.team_id().unwrap(), vercel_artifacts_owner); - assert_eq!(config.token().unwrap(), vercel_artifacts_token); - assert_eq!(config.spaces_id().unwrap(), "my-spaces-id"); - } - - #[test] - fn test_turbo_json_remote_cache() { - let tmp_dir = TempDir::new().unwrap(); - let repo_root = AbsoluteSystemPathBuf::try_from(tmp_dir.path()).unwrap(); - - let api_url = "url1"; - let login_url = "url2"; - let team_slug = "my-slug"; - let team_id = "an-id"; - let turbo_json_contents = serde_json::to_string_pretty(&serde_json::json!({ - "remoteCache": { - "enabled": true, - "apiUrl": api_url, - "loginUrl": login_url, - "teamSlug": team_slug, - "teamId": team_id, - "signature": true, - "preflight": false, - "timeout": 123 - } - })) - .unwrap(); - repo_root - .join_component("turbo.json") - .create_with_contents(&turbo_json_contents) - .unwrap(); - - let builder = TurborepoConfigBuilder { - repo_root, - override_config: ConfigurationOptions::default(), - global_config_path: None, - environment: HashMap::default(), - }; - - let config = builder.build().unwrap(); - // Directly accessing field to make sure we're not getting the default value - assert_eq!(config.enabled, Some(true)); - assert_eq!(config.api_url(), api_url); - assert_eq!(config.login_url(), login_url); - assert_eq!(config.team_slug(), Some(team_slug)); - assert_eq!(config.team_id(), Some(team_id)); - assert!(config.signature()); - assert!(!config.preflight()); - assert_eq!(config.timeout(), 123); - } -} diff --git a/crates/turborepo-lib/src/config/env.rs b/crates/turborepo-lib/src/config/env.rs new file mode 100644 index 0000000000000..0e982c1d2a9a0 --- /dev/null +++ b/crates/turborepo-lib/src/config/env.rs @@ -0,0 +1,404 @@ +use std::{ + collections::HashMap, + ffi::{OsStr, OsString}, +}; + +use turbopath::AbsoluteSystemPathBuf; + +use super::{ConfigurationOptions, Error, ResolvedConfigurationOptions}; +use crate::{cli::EnvMode, turbo_json::UIMode}; + +const TURBO_MAPPING: &[(&str, &str)] = [ + ("turbo_api", "api_url"), + ("turbo_login", "login_url"), + ("turbo_team", "team_slug"), + ("turbo_teamid", "team_id"), + ("turbo_token", "token"), + ("turbo_remote_cache_timeout", "timeout"), + ("turbo_remote_cache_upload_timeout", "upload_timeout"), + ("turbo_ui", "ui"), + ( + "turbo_dangerously_disable_package_manager_check", + "allow_no_package_manager", + ), + ("turbo_daemon", "daemon"), + ("turbo_env_mode", "env_mode"), + ("turbo_cache_dir", "cache_dir"), + ("turbo_preflight", "preflight"), + ("turbo_scm_base", "scm_base"), + ("turbo_scm_head", "scm_head"), + ("turbo_root_turbo_json", "root_turbo_json_path"), +] +.as_slice(); + +pub struct EnvVars { + output_map: HashMap<&'static str, String>, +} + +impl EnvVars { + pub fn new(environment: &HashMap) -> Result { + let turbo_mapping: HashMap<_, _> = TURBO_MAPPING.iter().copied().collect(); + let output_map = map_environment(turbo_mapping, environment)?; + Ok(Self { output_map }) + } +} + +impl ResolvedConfigurationOptions for EnvVars { + fn get_configuration_options( + &self, + _existing_config: &ConfigurationOptions, + ) -> Result { + // Process signature + let signature = if let Some(signature) = self.output_map.get("signature") { + match signature.as_str() { + "0" => Some(false), + "1" => Some(true), + _ => return Err(Error::InvalidSignature), + } + } else { + None + }; + + // Process preflight + let preflight = if let Some(preflight) = self.output_map.get("preflight") { + match preflight.as_str() { + "0" | "false" => Some(false), + "1" | "true" => Some(true), + "" => None, + _ => return Err(Error::InvalidPreflight), + } + } else { + None + }; + + // Process enabled + let enabled = if let Some(enabled) = self.output_map.get("enabled") { + match enabled.as_str() { + "0" => Some(false), + "1" => Some(true), + _ => return Err(Error::InvalidRemoteCacheEnabled), + } + } else { + None + }; + + // Process timeout + let timeout = if let Some(timeout) = self.output_map.get("timeout") { + Some( + timeout + .parse::() + .map_err(Error::InvalidRemoteCacheTimeout)?, + ) + } else { + None + }; + + let upload_timeout = if let Some(upload_timeout) = self.output_map.get("upload_timeout") { + Some( + upload_timeout + .parse::() + .map_err(Error::InvalidUploadTimeout)?, + ) + } else { + None + }; + + // Process experimentalUI + let ui = self + .output_map + .get("ui") + .map(|s| s.as_str()) + .and_then(truth_env_var) + .map(|ui| if ui { UIMode::Tui } else { UIMode::Stream }); + + let allow_no_package_manager = self + .output_map + .get("allow_no_package_manager") + .map(|s| s.as_str()) + .and_then(truth_env_var); + + // Process daemon + let daemon = self + .output_map + .get("daemon") + .and_then(|val| match val.as_str() { + "1" | "true" => Some(true), + "0" | "false" => Some(false), + _ => None, + }); + + let env_mode = self + .output_map + .get("env_mode") + .map(|s| s.as_str()) + .and_then(|s| match s { + "strict" => Some(EnvMode::Strict), + "loose" => Some(EnvMode::Loose), + _ => None, + }); + + let cache_dir = self.output_map.get("cache_dir").map(|s| s.clone().into()); + + let root_turbo_json_path = self + .output_map + .get("root_turbo_json_path") + .filter(|s| !s.is_empty()) + .map(AbsoluteSystemPathBuf::from_cwd) + .transpose()?; + + // We currently don't pick up a Spaces ID via env var, we likely won't + // continue using the Spaces name, we can add an env var when we have the + // name we want to stick with. + let spaces_id = None; + + let output = ConfigurationOptions { + api_url: self.output_map.get("api_url").cloned(), + login_url: self.output_map.get("login_url").cloned(), + team_slug: self.output_map.get("team_slug").cloned(), + team_id: self.output_map.get("team_id").cloned(), + token: self.output_map.get("token").cloned(), + scm_base: self.output_map.get("scm_base").cloned(), + scm_head: self.output_map.get("scm_head").cloned(), + // Processed booleans + signature, + preflight, + enabled, + ui, + allow_no_package_manager, + daemon, + + // Processed numbers + timeout, + upload_timeout, + spaces_id, + env_mode, + cache_dir, + root_turbo_json_path, + }; + + Ok(output) + } +} + +const VERCEL_ARTIFACTS_MAPPING: &[(&str, &str)] = [ + ("vercel_artifacts_token", "token"), + ("vercel_artifacts_owner", "team_id"), +] +.as_slice(); + +pub struct OverrideEnvVars<'a> { + environment: &'a HashMap, + output_map: HashMap<&'static str, String>, +} + +impl<'a> OverrideEnvVars<'a> { + pub fn new(environment: &'a HashMap) -> Result { + let vercel_artifacts_mapping: HashMap<_, _> = + VERCEL_ARTIFACTS_MAPPING.iter().copied().collect(); + + let output_map = map_environment(vercel_artifacts_mapping, environment)?; + Ok(Self { + environment, + output_map, + }) + } +} + +impl<'a> ResolvedConfigurationOptions for OverrideEnvVars<'a> { + fn get_configuration_options( + &self, + _existing_config: &ConfigurationOptions, + ) -> Result { + let ui = self + .environment + .get(OsStr::new("ci")) + .or_else(|| self.environment.get(OsStr::new("no_color"))) + .and_then(|value| { + // If either of these are truthy, then we disable the TUI + if value == "true" || value == "1" { + Some(UIMode::Stream) + } else { + None + } + }); + + let output = ConfigurationOptions { + api_url: None, + login_url: None, + team_slug: None, + team_id: self.output_map.get("team_id").cloned(), + token: self.output_map.get("token").cloned(), + scm_base: None, + scm_head: None, + + signature: None, + preflight: None, + enabled: None, + ui, + daemon: None, + timeout: None, + upload_timeout: None, + spaces_id: None, + allow_no_package_manager: None, + env_mode: None, + cache_dir: None, + root_turbo_json_path: None, + }; + + Ok(output) + } +} + +fn truth_env_var(s: &str) -> Option { + match s { + "true" | "1" => Some(true), + "false" | "0" => Some(false), + _ => None, + } +} + +fn map_environment<'a>( + mapping: HashMap<&str, &'a str>, + environment: &HashMap, +) -> Result, Error> { + let mut output_map = HashMap::new(); + mapping + .into_iter() + .try_for_each(|(mapping_key, mapped_property)| -> Result<(), Error> { + if let Some(value) = environment.get(OsStr::new(mapping_key)) { + let converted = value + .to_str() + .ok_or_else(|| Error::Encoding(mapping_key.to_ascii_uppercase()))?; + output_map.insert(mapped_property, converted.to_owned()); + } + Ok(()) + })?; + Ok(output_map) +} + +#[cfg(test)] +mod test { + use camino::Utf8PathBuf; + + use super::*; + use crate::config::{DEFAULT_API_URL, DEFAULT_LOGIN_URL}; + + #[test] + fn test_env_setting() { + let mut env: HashMap = HashMap::new(); + + let turbo_api = "https://example.com/api"; + let turbo_login = "https://example.com/login"; + let turbo_team = "vercel"; + let turbo_teamid = "team_nLlpyC6REAqxydlFKbrMDlud"; + let turbo_token = "abcdef1234567890abcdef"; + let cache_dir = Utf8PathBuf::from("nebulo9"); + let turbo_remote_cache_timeout = 200; + let root_turbo_json = if cfg!(windows) { + "C:\\some\\dir\\yolo.json" + } else { + "/some/dir/yolo.json" + }; + + env.insert("turbo_api".into(), turbo_api.into()); + env.insert("turbo_login".into(), turbo_login.into()); + env.insert("turbo_team".into(), turbo_team.into()); + env.insert("turbo_teamid".into(), turbo_teamid.into()); + env.insert("turbo_token".into(), turbo_token.into()); + env.insert( + "turbo_remote_cache_timeout".into(), + turbo_remote_cache_timeout.to_string().into(), + ); + env.insert("turbo_ui".into(), "true".into()); + env.insert( + "turbo_dangerously_disable_package_manager_check".into(), + "true".into(), + ); + env.insert("turbo_daemon".into(), "true".into()); + env.insert("turbo_preflight".into(), "true".into()); + env.insert("turbo_env_mode".into(), "strict".into()); + env.insert("turbo_cache_dir".into(), cache_dir.clone().into()); + env.insert("turbo_root_turbo_json".into(), root_turbo_json.into()); + + let config = EnvVars::new(&env) + .unwrap() + .get_configuration_options(&ConfigurationOptions::default()) + .unwrap(); + assert!(config.preflight()); + assert_eq!(turbo_api, config.api_url.unwrap()); + assert_eq!(turbo_login, config.login_url.unwrap()); + assert_eq!(turbo_team, config.team_slug.unwrap()); + assert_eq!(turbo_teamid, config.team_id.unwrap()); + assert_eq!(turbo_token, config.token.unwrap()); + assert_eq!(turbo_remote_cache_timeout, config.timeout.unwrap()); + assert_eq!(Some(UIMode::Tui), config.ui); + assert_eq!(Some(true), config.allow_no_package_manager); + assert_eq!(Some(true), config.daemon); + assert_eq!(Some(EnvMode::Strict), config.env_mode); + assert_eq!(cache_dir, config.cache_dir.unwrap()); + assert_eq!( + config.root_turbo_json_path, + Some(AbsoluteSystemPathBuf::new(root_turbo_json).unwrap()) + ); + } + + #[test] + fn test_empty_env_setting() { + let mut env: HashMap = HashMap::new(); + env.insert("turbo_api".into(), "".into()); + env.insert("turbo_login".into(), "".into()); + env.insert("turbo_team".into(), "".into()); + env.insert("turbo_teamid".into(), "".into()); + env.insert("turbo_token".into(), "".into()); + env.insert("turbo_ui".into(), "".into()); + env.insert("turbo_daemon".into(), "".into()); + env.insert("turbo_env_mode".into(), "".into()); + env.insert("turbo_preflight".into(), "".into()); + env.insert("turbo_scm_head".into(), "".into()); + env.insert("turbo_scm_base".into(), "".into()); + env.insert("turbo_root_turbo_json".into(), "".into()); + + let config = EnvVars::new(&env) + .unwrap() + .get_configuration_options(&ConfigurationOptions::default()) + .unwrap(); + assert_eq!(config.api_url(), DEFAULT_API_URL); + assert_eq!(config.login_url(), DEFAULT_LOGIN_URL); + assert_eq!(config.team_slug(), None); + assert_eq!(config.team_id(), None); + assert_eq!(config.token(), None); + assert_eq!(config.ui, None); + assert_eq!(config.daemon, None); + assert_eq!(config.env_mode, None); + assert!(!config.preflight()); + assert_eq!(config.scm_base(), None); + assert_eq!(config.scm_head(), "HEAD"); + assert_eq!(config.root_turbo_json_path, None); + } + + #[test] + fn test_override_env_setting() { + let mut env: HashMap = HashMap::new(); + + let vercel_artifacts_token = "correct-horse-battery-staple"; + let vercel_artifacts_owner = "bobby_tables"; + + env.insert( + "vercel_artifacts_token".into(), + vercel_artifacts_token.into(), + ); + env.insert( + "vercel_artifacts_owner".into(), + vercel_artifacts_owner.into(), + ); + env.insert("ci".into(), "1".into()); + + let config = OverrideEnvVars::new(&env) + .unwrap() + .get_configuration_options(&ConfigurationOptions::default()) + .unwrap(); + assert_eq!(vercel_artifacts_token, config.token.unwrap()); + assert_eq!(vercel_artifacts_owner, config.team_id.unwrap()); + assert_eq!(Some(UIMode::Stream), config.ui); + } +} diff --git a/crates/turborepo-lib/src/config/file.rs b/crates/turborepo-lib/src/config/file.rs new file mode 100644 index 0000000000000..4a295ba2d41f5 --- /dev/null +++ b/crates/turborepo-lib/src/config/file.rs @@ -0,0 +1,102 @@ +use turbopath::{AbsoluteSystemPath, AbsoluteSystemPathBuf}; +use turborepo_auth::{TURBO_TOKEN_DIR, TURBO_TOKEN_FILE, VERCEL_TOKEN_DIR, VERCEL_TOKEN_FILE}; +use turborepo_dirs::{config_dir, vercel_config_dir}; + +use super::{ConfigurationOptions, Error, ResolvedConfigurationOptions}; + +pub struct ConfigFile { + path: AbsoluteSystemPathBuf, +} + +impl ConfigFile { + pub fn global_config(override_path: Option) -> Result { + let path = override_path.map_or_else(global_config_path, Ok)?; + Ok(Self { path }) + } + + pub fn local_config(repo_root: &AbsoluteSystemPath) -> Self { + let path = repo_root.join_components(&[".turbo", "config.json"]); + Self { path } + } +} + +impl ResolvedConfigurationOptions for ConfigFile { + fn get_configuration_options( + &self, + _existing_config: &ConfigurationOptions, + ) -> Result { + let mut contents = self + .path + .read_existing_to_string_or(Ok("{}")) + .map_err(|error| Error::FailedToReadConfig { + config_path: self.path.clone(), + error, + })?; + if contents.is_empty() { + contents = String::from("{}"); + } + let global_config: ConfigurationOptions = serde_json::from_str(&contents)?; + Ok(global_config) + } +} + +pub struct AuthFile { + path: AbsoluteSystemPathBuf, +} + +impl AuthFile { + pub fn global_auth(override_path: Option) -> Result { + let path = override_path.map_or_else(global_auth_path, Ok)?; + Ok(Self { path }) + } +} + +impl ResolvedConfigurationOptions for AuthFile { + fn get_configuration_options( + &self, + _existing_config: &ConfigurationOptions, + ) -> Result { + let token = match turborepo_auth::Token::from_file(&self.path) { + Ok(token) => token, + // Multiple ways this can go wrong. Don't error out if we can't find the token - it + // just might not be there. + Err(e) => { + if matches!(e, turborepo_auth::Error::TokenNotFound) { + return Ok(ConfigurationOptions::default()); + } + + return Err(e.into()); + } + }; + + // No auth token found in either Vercel or Turbo config. + if token.into_inner().is_empty() { + return Ok(ConfigurationOptions::default()); + } + + let global_auth: ConfigurationOptions = ConfigurationOptions { + token: Some(token.into_inner().to_owned()), + ..Default::default() + }; + Ok(global_auth) + } +} + +fn global_config_path() -> Result { + let config_dir = config_dir()?.ok_or(Error::NoGlobalConfigPath)?; + + Ok(config_dir.join_components(&[TURBO_TOKEN_DIR, TURBO_TOKEN_FILE])) +} + +fn global_auth_path() -> Result { + let vercel_config_dir = vercel_config_dir()?.ok_or(Error::NoGlobalConfigDir)?; + // Check for both Vercel and Turbo paths. Vercel takes priority. + let vercel_path = vercel_config_dir.join_components(&[VERCEL_TOKEN_DIR, VERCEL_TOKEN_FILE]); + if vercel_path.exists() { + return Ok(vercel_path); + } + + let turbo_config_dir = config_dir()?.ok_or(Error::NoGlobalConfigDir)?; + + Ok(turbo_config_dir.join_components(&[TURBO_TOKEN_DIR, TURBO_TOKEN_FILE])) +} diff --git a/crates/turborepo-lib/src/config/mod.rs b/crates/turborepo-lib/src/config/mod.rs new file mode 100644 index 0000000000000..398754bea9c4d --- /dev/null +++ b/crates/turborepo-lib/src/config/mod.rs @@ -0,0 +1,651 @@ +mod env; +mod file; +mod turbo_json; + +use std::{collections::HashMap, ffi::OsString, io}; + +use camino::{Utf8Path, Utf8PathBuf}; +use convert_case::{Case, Casing}; +use env::{EnvVars, OverrideEnvVars}; +use file::{AuthFile, ConfigFile}; +use miette::{Diagnostic, NamedSource, SourceSpan}; +use serde::Deserialize; +use struct_iterable::Iterable; +use thiserror::Error; +use turbo_json::TurboJsonReader; +use turbopath::{AbsoluteSystemPath, AbsoluteSystemPathBuf}; +use turborepo_errors::TURBO_SITE; + +pub use crate::turbo_json::{RawTurboJson, UIMode}; +use crate::{cli::EnvMode, commands::CommandBase, turbo_json::CONFIG_FILE}; + +#[derive(Debug, Error, Diagnostic)] +#[error("Environment variables should not be prefixed with \"{env_pipeline_delimiter}\"")] +#[diagnostic( + code(invalid_env_prefix), + url("{}/messages/{}", TURBO_SITE, self.code().unwrap().to_string().to_case(Case::Kebab)) +)] +pub struct InvalidEnvPrefixError { + pub value: String, + pub key: String, + #[source_code] + pub text: NamedSource, + #[label("variable with invalid prefix declared here")] + pub span: Option, + pub env_pipeline_delimiter: &'static str, +} + +#[allow(clippy::enum_variant_names)] +#[derive(Debug, Error, Diagnostic)] +pub enum Error { + #[error("Authentication error: {0}")] + Auth(#[from] turborepo_auth::Error), + #[error("Global config path not found")] + NoGlobalConfigPath, + #[error("Global auth file path not found")] + NoGlobalAuthFilePath, + #[error("Global config directory not found")] + NoGlobalConfigDir, + #[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 \ + one" + )] + NoTurboJSON, + #[error(transparent)] + SerdeJson(#[from] serde_json::Error), + #[error(transparent)] + Io(#[from] io::Error), + #[error(transparent)] + Camino(#[from] camino::FromPathBufError), + #[error(transparent)] + Reqwest(#[from] reqwest::Error), + #[error("Encountered an IO error while attempting to read {config_path}: {error}")] + FailedToReadConfig { + config_path: AbsoluteSystemPathBuf, + error: io::Error, + }, + #[error("Encountered an IO error while attempting to set {config_path}: {error}")] + FailedToSetConfig { + config_path: AbsoluteSystemPathBuf, + error: io::Error, + }, + #[error( + "Package tasks (#) are not allowed in single-package repositories: found \ + {task_id}" + )] + #[diagnostic(code(package_task_in_single_package_mode), url("{}/messages/{}", TURBO_SITE, self.code().unwrap().to_string().to_case(Case::Kebab)))] + PackageTaskInSinglePackageMode { + task_id: String, + #[source_code] + text: NamedSource, + #[label("package task found here")] + span: Option, + }, + #[error(transparent)] + #[diagnostic(transparent)] + InvalidEnvPrefix(Box), + #[error(transparent)] + PathError(#[from] turbopath::PathError), + #[diagnostic( + code(unnecessary_package_task_syntax), + url("{}/messages/{}", TURBO_SITE, self.code().unwrap().to_string().to_case(Case::Kebab)) + )] + #[error("\"{actual}\". Use \"{wanted}\" instead")] + UnnecessaryPackageTaskSyntax { + actual: String, + wanted: String, + #[label("unnecessary package syntax found here")] + span: Option, + #[source_code] + text: NamedSource, + }, + #[error("You can only extend from the root workspace")] + ExtendFromNonRoot { + #[label("non-root workspace found here")] + span: Option, + #[source_code] + text: NamedSource, + }, + #[error("`{field}` cannot contain an environment variable")] + InvalidDependsOnValue { + field: &'static str, + #[label("environment variable found here")] + span: Option, + #[source_code] + text: NamedSource, + }, + #[error("`{field}` cannot contain an absolute path")] + AbsolutePathInConfig { + field: &'static str, + #[label("absolute path found here")] + span: Option, + #[source_code] + text: NamedSource, + }, + #[error("No \"extends\" key found")] + NoExtends { + #[label("add extends key here")] + span: Option, + #[source_code] + text: NamedSource, + }, + #[error("Tasks cannot be marked as interactive and cacheable")] + InteractiveNoCacheable { + #[label("marked interactive here")] + span: Option, + #[source_code] + text: NamedSource, + }, + #[error("found `pipeline` field instead of `tasks`")] + #[diagnostic(help("changed in 2.0: `pipeline` has been renamed to `tasks`"))] + PipelineField { + #[label("rename `pipeline` field to `tasks`")] + span: Option, + #[source_code] + text: NamedSource, + }, + #[error("Failed to create APIClient: {0}")] + ApiClient(#[source] turborepo_api_client::Error), + #[error("{0} is not UTF8.")] + Encoding(String), + #[error("TURBO_SIGNATURE should be either 1 or 0.")] + InvalidSignature, + #[error("TURBO_REMOTE_CACHE_ENABLED should be either 1 or 0.")] + InvalidRemoteCacheEnabled, + #[error("TURBO_REMOTE_CACHE_TIMEOUT: error parsing timeout.")] + InvalidRemoteCacheTimeout(#[source] std::num::ParseIntError), + #[error("TURBO_REMOTE_CACHE_UPLOAD_TIMEOUT: error parsing timeout.")] + InvalidUploadTimeout(#[source] std::num::ParseIntError), + #[error("TURBO_PREFLIGHT should be either 1 or 0.")] + InvalidPreflight, + #[error(transparent)] + #[diagnostic(transparent)] + TurboJsonParseError(#[from] crate::turbo_json::parser::Error), + #[error("found absolute path in `cacheDir`")] + #[diagnostic(help("if absolute paths are required, use `--cache-dir` or `TURBO_CACHE_DIR`"))] + AbsoluteCacheDir { + #[label("make `cacheDir` value a relative unix path")] + span: Option, + #[source_code] + text: NamedSource, + }, +} + +macro_rules! create_builder { + ($func_name:ident, $property_name:ident, $type:ty) => { + pub fn $func_name(mut self, value: $type) -> Self { + self.override_config.$property_name = value; + self + } + }; +} + +const DEFAULT_API_URL: &str = "https://vercel.com/api"; +const DEFAULT_LOGIN_URL: &str = "https://vercel.com"; +const DEFAULT_TIMEOUT: u64 = 30; +const DEFAULT_UPLOAD_TIMEOUT: u64 = 60; + +// We intentionally don't derive Serialize so that different parts +// of the code that want to display the config can tune how they +// want to display and what fields they want to include. +#[derive(Deserialize, Default, Debug, PartialEq, Eq, Clone, Iterable)] +#[serde(rename_all = "camelCase")] +pub struct ConfigurationOptions { + #[serde(alias = "apiurl")] + #[serde(alias = "ApiUrl")] + #[serde(alias = "APIURL")] + pub(crate) api_url: Option, + #[serde(alias = "loginurl")] + #[serde(alias = "LoginUrl")] + #[serde(alias = "LOGINURL")] + pub(crate) login_url: Option, + #[serde(alias = "teamslug")] + #[serde(alias = "TeamSlug")] + #[serde(alias = "TEAMSLUG")] + pub(crate) team_slug: Option, + #[serde(alias = "teamid")] + #[serde(alias = "TeamId")] + #[serde(alias = "TEAMID")] + pub(crate) team_id: Option, + pub(crate) token: Option, + pub(crate) signature: Option, + pub(crate) preflight: Option, + pub(crate) timeout: Option, + pub(crate) upload_timeout: Option, + pub(crate) enabled: Option, + pub(crate) spaces_id: Option, + #[serde(rename = "ui")] + pub(crate) ui: Option, + #[serde(rename = "dangerouslyDisablePackageManagerCheck")] + pub(crate) allow_no_package_manager: Option, + pub(crate) daemon: Option, + #[serde(rename = "envMode")] + pub(crate) env_mode: Option, + pub(crate) scm_base: Option, + pub(crate) scm_head: Option, + #[serde(rename = "cacheDir")] + pub(crate) cache_dir: Option, + // This is skipped as we never want this to be stored in a file + #[serde(skip)] + pub(crate) root_turbo_json_path: Option, +} + +#[derive(Default)] +pub struct TurborepoConfigBuilder { + repo_root: AbsoluteSystemPathBuf, + override_config: ConfigurationOptions, + global_config_path: Option, + environment: Option>, +} + +// Getters +impl ConfigurationOptions { + pub fn api_url(&self) -> &str { + non_empty_str(self.api_url.as_deref()).unwrap_or(DEFAULT_API_URL) + } + + pub fn login_url(&self) -> &str { + non_empty_str(self.login_url.as_deref()).unwrap_or(DEFAULT_LOGIN_URL) + } + + pub fn team_slug(&self) -> Option<&str> { + self.team_slug + .as_deref() + .and_then(|slug| (!slug.is_empty()).then_some(slug)) + } + + pub fn team_id(&self) -> Option<&str> { + non_empty_str(self.team_id.as_deref()) + } + + pub fn token(&self) -> Option<&str> { + non_empty_str(self.token.as_deref()) + } + + pub fn signature(&self) -> bool { + self.signature.unwrap_or_default() + } + + pub fn enabled(&self) -> bool { + self.enabled.unwrap_or(true) + } + + pub fn preflight(&self) -> bool { + self.preflight.unwrap_or_default() + } + + /// Note: 0 implies no timeout + pub fn timeout(&self) -> u64 { + self.timeout.unwrap_or(DEFAULT_TIMEOUT) + } + + /// Note: 0 implies no timeout + pub fn upload_timeout(&self) -> u64 { + self.upload_timeout.unwrap_or(DEFAULT_UPLOAD_TIMEOUT) + } + + pub fn spaces_id(&self) -> Option<&str> { + self.spaces_id.as_deref() + } + + pub fn ui(&self) -> UIMode { + // If we aren't hooked up to a TTY, then do not use TUI + if !atty::is(atty::Stream::Stdout) { + return UIMode::Stream; + } + + self.ui.unwrap_or(UIMode::Stream) + } + + pub fn scm_base(&self) -> Option<&str> { + non_empty_str(self.scm_base.as_deref()) + } + + pub fn scm_head(&self) -> &str { + non_empty_str(self.scm_head.as_deref()).unwrap_or("HEAD") + } + + pub fn allow_no_package_manager(&self) -> bool { + self.allow_no_package_manager.unwrap_or_default() + } + + pub fn daemon(&self) -> Option { + self.daemon + } + + pub fn env_mode(&self) -> EnvMode { + self.env_mode.unwrap_or_default() + } + + pub fn cache_dir(&self) -> &Utf8Path { + self.cache_dir.as_deref().unwrap_or_else(|| { + Utf8Path::new(if cfg!(windows) { + ".turbo\\cache" + } else { + ".turbo/cache" + }) + }) + } + + 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)) + } +} + +macro_rules! create_set_if_empty { + ($func_name:ident, $property_name:ident, $type:ty) => { + fn $func_name(&mut self, value: &mut Option<$type>) { + if self.$property_name.is_none() { + if let Some(value) = value.take() { + self.$property_name = Some(value); + } + } + } + }; +} + +// Private setters used only for construction +impl ConfigurationOptions { + create_set_if_empty!(set_api_url, api_url, String); + create_set_if_empty!(set_login_url, login_url, String); + create_set_if_empty!(set_team_slug, team_slug, String); + create_set_if_empty!(set_team_id, team_id, String); + create_set_if_empty!(set_token, token, String); + create_set_if_empty!(set_signature, signature, bool); + create_set_if_empty!(set_enabled, enabled, bool); + create_set_if_empty!(set_preflight, preflight, bool); + create_set_if_empty!(set_timeout, timeout, u64); + create_set_if_empty!(set_ui, ui, UIMode); + create_set_if_empty!(set_allow_no_package_manager, allow_no_package_manager, bool); + create_set_if_empty!(set_daemon, daemon, bool); + create_set_if_empty!(set_env_mode, env_mode, EnvMode); + create_set_if_empty!(set_cache_dir, cache_dir, Utf8PathBuf); + create_set_if_empty!(set_scm_base, scm_base, String); + create_set_if_empty!(set_scm_head, scm_head, String); + create_set_if_empty!(set_spaces_id, spaces_id, String); + create_set_if_empty!( + set_root_turbo_json_path, + root_turbo_json_path, + AbsoluteSystemPathBuf + ); +} + +// Maps Some("") to None to emulate how Go handles empty strings +fn non_empty_str(s: Option<&str>) -> Option<&str> { + s.filter(|s| !s.is_empty()) +} + +trait ResolvedConfigurationOptions { + fn get_configuration_options( + &self, + existing_config: &ConfigurationOptions, + ) -> Result; +} + +// Used for global config and local config. +impl<'a> ResolvedConfigurationOptions for &'a ConfigurationOptions { + fn get_configuration_options( + &self, + _existing_config: &ConfigurationOptions, + ) -> Result { + Ok((*self).clone()) + } +} + +fn get_lowercased_env_vars() -> HashMap { + std::env::vars_os() + .map(|(k, v)| (k.to_ascii_lowercase(), v)) + .collect() +} + +impl TurborepoConfigBuilder { + pub fn new(base: &CommandBase) -> Self { + Self { + repo_root: base.repo_root.to_owned(), + override_config: Default::default(), + global_config_path: base.override_global_config_path.clone(), + environment: None, + } + } + + // 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() + .unwrap_or_else(get_lowercased_env_vars) + } + + create_builder!(with_api_url, api_url, Option); + create_builder!(with_login_url, login_url, Option); + create_builder!(with_team_slug, team_slug, Option); + create_builder!(with_team_id, team_id, Option); + create_builder!(with_token, token, Option); + create_builder!(with_signature, signature, Option); + create_builder!(with_enabled, enabled, Option); + create_builder!(with_preflight, preflight, Option); + create_builder!(with_timeout, timeout, Option); + create_builder!(with_ui, ui, Option); + create_builder!( + with_allow_no_package_manager, + allow_no_package_manager, + Option + ); + create_builder!(with_daemon, daemon, Option); + create_builder!(with_env_mode, env_mode, Option); + create_builder!(with_cache_dir, cache_dir, Option); + create_builder!( + with_root_turbo_json_path, + root_turbo_json_path, + Option + ); + + pub fn build(&self) -> Result { + // Priority, from least significant to most significant: + // - shared configuration (turbo.json) + // - global configuration (~/.turbo/config.json) + // - local configuration (/.turbo/config.json) + // - environment variables + // - CLI arguments + // - builder pattern overrides. + + let turbo_json = TurboJsonReader::new(&self.repo_root); + let global_config = ConfigFile::global_config(self.global_config_path.clone())?; + let global_auth = AuthFile::global_auth(self.global_config_path.clone())?; + let local_config = ConfigFile::local_config(&self.repo_root); + let env_vars = self.get_environment(); + let env_var_config = EnvVars::new(&env_vars)?; + let override_env_var_config = OverrideEnvVars::new(&env_vars)?; + + // These are ordered from highest to lowest priority + let sources: [Box; 7] = [ + Box::new(override_env_var_config), + Box::new(&self.override_config), + Box::new(env_var_config), + Box::new(local_config), + Box::new(global_auth), + Box::new(global_config), + Box::new(turbo_json), + ]; + + let config = sources.into_iter().try_fold( + ConfigurationOptions::default(), + |mut acc, current_source| { + let mut current_source_config = current_source.get_configuration_options(&acc)?; + acc.set_api_url(&mut current_source_config.api_url); + acc.set_login_url(&mut current_source_config.login_url); + acc.set_team_slug(&mut current_source_config.team_slug); + acc.set_team_id(&mut current_source_config.team_id); + acc.set_token(&mut current_source_config.token); + acc.set_signature(&mut current_source_config.signature); + acc.set_enabled(&mut current_source_config.enabled); + acc.set_preflight(&mut current_source_config.preflight); + acc.set_timeout(&mut current_source_config.timeout); + acc.set_spaces_id(&mut current_source_config.spaces_id); + acc.set_ui(&mut current_source_config.ui); + acc.set_allow_no_package_manager( + &mut current_source_config.allow_no_package_manager, + ); + acc.set_daemon(&mut current_source_config.daemon); + acc.set_env_mode(&mut current_source_config.env_mode); + acc.set_scm_base(&mut current_source_config.scm_base); + acc.set_scm_head(&mut current_source_config.scm_head); + acc.set_cache_dir(&mut current_source_config.cache_dir); + acc.set_root_turbo_json_path(&mut current_source_config.root_turbo_json_path); + Ok(acc) + }, + ); + + // We explicitly do a let and return to help the Rust compiler see that there + // are no references still held by the folding. + #[allow(clippy::let_and_return)] + config + } +} + +#[cfg(test)] +mod test { + use std::{collections::HashMap, ffi::OsString}; + + use tempfile::TempDir; + use turbopath::{AbsoluteSystemPath, AbsoluteSystemPathBuf}; + + use crate::config::{ + ConfigurationOptions, TurborepoConfigBuilder, DEFAULT_API_URL, DEFAULT_LOGIN_URL, + DEFAULT_TIMEOUT, + }; + + #[test] + fn test_defaults() { + let defaults: ConfigurationOptions = Default::default(); + assert_eq!(defaults.api_url(), DEFAULT_API_URL); + assert_eq!(defaults.login_url(), DEFAULT_LOGIN_URL); + assert_eq!(defaults.team_slug(), None); + assert_eq!(defaults.team_id(), None); + assert_eq!(defaults.token(), None); + assert!(!defaults.signature()); + assert!(defaults.enabled()); + assert!(!defaults.preflight()); + assert_eq!(defaults.timeout(), DEFAULT_TIMEOUT); + assert_eq!(defaults.spaces_id(), None); + assert!(!defaults.allow_no_package_manager()); + let repo_root = AbsoluteSystemPath::new(if cfg!(windows) { + "C:\\fake\\repo" + } else { + "/fake/repo" + }) + .unwrap(); + assert_eq!( + defaults.root_turbo_json_path(repo_root), + repo_root.join_component("turbo.json") + ) + } + + #[test] + fn test_env_layering() { + let tmp_dir = TempDir::new().unwrap(); + let repo_root = AbsoluteSystemPathBuf::try_from(tmp_dir.path()).unwrap(); + let global_config_path = AbsoluteSystemPathBuf::try_from( + TempDir::new().unwrap().path().join("nonexistent.json"), + ) + .unwrap(); + + repo_root + .join_component("turbo.json") + .create_with_contents(r#"{"experimentalSpaces": {"id": "my-spaces-id"}}"#) + .unwrap(); + + let turbo_teamid = "team_nLlpyC6REAqxydlFKbrMDlud"; + let turbo_token = "abcdef1234567890abcdef"; + let vercel_artifacts_owner = "team_SOMEHASH"; + let vercel_artifacts_token = "correct-horse-battery-staple"; + + let mut env: HashMap = HashMap::new(); + env.insert("turbo_teamid".into(), turbo_teamid.into()); + env.insert("turbo_token".into(), turbo_token.into()); + env.insert( + "vercel_artifacts_token".into(), + vercel_artifacts_token.into(), + ); + env.insert( + "vercel_artifacts_owner".into(), + vercel_artifacts_owner.into(), + ); + + let override_config = ConfigurationOptions { + token: Some("unseen".into()), + team_id: Some("unseen".into()), + ..Default::default() + }; + + let builder = TurborepoConfigBuilder { + repo_root, + override_config, + global_config_path: Some(global_config_path), + environment: Some(env), + }; + + let config = builder.build().unwrap(); + assert_eq!(config.team_id().unwrap(), vercel_artifacts_owner); + assert_eq!(config.token().unwrap(), vercel_artifacts_token); + assert_eq!(config.spaces_id().unwrap(), "my-spaces-id"); + } + + #[test] + fn test_turbo_json_remote_cache() { + let tmp_dir = TempDir::new().unwrap(); + let repo_root = AbsoluteSystemPathBuf::try_from(tmp_dir.path()).unwrap(); + + let api_url = "url1"; + let login_url = "url2"; + let team_slug = "my-slug"; + let team_id = "an-id"; + let turbo_json_contents = serde_json::to_string_pretty(&serde_json::json!({ + "remoteCache": { + "enabled": true, + "apiUrl": api_url, + "loginUrl": login_url, + "teamSlug": team_slug, + "teamId": team_id, + "signature": true, + "preflight": false, + "timeout": 123 + } + })) + .unwrap(); + repo_root + .join_component("turbo.json") + .create_with_contents(&turbo_json_contents) + .unwrap(); + + let builder = TurborepoConfigBuilder { + repo_root, + override_config: ConfigurationOptions::default(), + global_config_path: None, + environment: Some(HashMap::default()), + }; + + let config = builder.build().unwrap(); + // Directly accessing field to make sure we're not getting the default value + assert_eq!(config.enabled, Some(true)); + assert_eq!(config.api_url(), api_url); + assert_eq!(config.login_url(), login_url); + assert_eq!(config.team_slug(), Some(team_slug)); + assert_eq!(config.team_id(), Some(team_id)); + assert!(config.signature()); + assert!(!config.preflight()); + assert_eq!(config.timeout(), 123); + } +} diff --git a/crates/turborepo-lib/src/config/turbo_json.rs b/crates/turborepo-lib/src/config/turbo_json.rs new file mode 100644 index 0000000000000..e4cf24c1182cd --- /dev/null +++ b/crates/turborepo-lib/src/config/turbo_json.rs @@ -0,0 +1,126 @@ +use camino::Utf8PathBuf; +use turbopath::{AbsoluteSystemPath, RelativeUnixPath}; + +use super::{ConfigurationOptions, Error, ResolvedConfigurationOptions}; +use crate::turbo_json::RawTurboJson; + +pub struct TurboJsonReader<'a> { + repo_root: &'a AbsoluteSystemPath, +} + +impl<'a> TurboJsonReader<'a> { + pub fn new(repo_root: &'a AbsoluteSystemPath) -> Self { + Self { repo_root } + } +} + +impl<'a> ResolvedConfigurationOptions for TurboJsonReader<'a> { + fn get_configuration_options( + &self, + existing_config: &ConfigurationOptions, + ) -> Result { + 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) { + return Ok(Default::default()); + } + } + + Err(e) + })?; + let mut opts = if let Some(remote_cache_options) = &turbo_json.remote_cache { + remote_cache_options.into() + } else { + ConfigurationOptions::default() + }; + + let cache_dir = if let Some(cache_dir) = turbo_json.cache_dir { + let cache_dir_str: &str = &cache_dir; + let cache_dir_unix = RelativeUnixPath::new(cache_dir_str).map_err(|_| { + let (span, text) = cache_dir.span_and_text("turbo.json"); + Error::AbsoluteCacheDir { span, text } + })?; + // Convert the relative unix path to an anchored system path + // For unix/macos this is a no-op + let cache_dir_system = cache_dir_unix.to_anchored_system_path_buf(); + Some(Utf8PathBuf::from(cache_dir_system.to_string())) + } else { + None + }; + + // Don't allow token to be set for shared config. + opts.token = None; + opts.spaces_id = turbo_json + .experimental_spaces + .and_then(|spaces| spaces.id) + .map(|spaces_id| spaces_id.into()); + opts.ui = turbo_json.ui; + opts.allow_no_package_manager = turbo_json.allow_no_package_manager; + opts.daemon = turbo_json.daemon.map(|daemon| *daemon.as_inner()); + opts.env_mode = turbo_json.env_mode; + opts.cache_dir = cache_dir; + Ok(opts) + } +} + +#[cfg(test)] +mod test { + use tempfile::tempdir; + + use super::*; + + #[test] + fn test_reads_from_default() { + let tmpdir = tempdir().unwrap(); + let repo_root = AbsoluteSystemPath::new(tmpdir.path().to_str().unwrap()).unwrap(); + + let existing_config = ConfigurationOptions { + ..Default::default() + }; + repo_root + .join_component("turbo.json") + .create_with_contents( + serde_json::to_string_pretty(&serde_json::json!({ + "daemon": false + })) + .unwrap(), + ) + .unwrap(); + + let reader = TurboJsonReader::new(repo_root); + let config = reader.get_configuration_options(&existing_config).unwrap(); + // Make sure we read the default turbo.json + assert_eq!(config.daemon(), Some(false)); + } + + #[test] + fn test_respects_root_turbo_json_config() { + let tmpdir = tempdir().unwrap(); + let tmpdir_path = AbsoluteSystemPath::new(tmpdir.path().to_str().unwrap()).unwrap(); + let root_turbo_json_path = tmpdir_path.join_component("yolo.json"); + let repo_root = AbsoluteSystemPath::new(if cfg!(windows) { + "C:\\my\\repo" + } else { + "/my/repo" + }) + .unwrap(); + let existing_config = ConfigurationOptions { + root_turbo_json_path: Some(root_turbo_json_path.to_owned()), + ..Default::default() + }; + root_turbo_json_path + .create_with_contents( + serde_json::to_string_pretty(&serde_json::json!({ + "daemon": false + })) + .unwrap(), + ) + .unwrap(); + + let reader = TurboJsonReader::new(repo_root); + let config = reader.get_configuration_options(&existing_config).unwrap(); + // Make sure we read the correct turbo.json + assert_eq!(config.daemon(), Some(false)); + } +} diff --git a/crates/turborepo-lib/src/engine/builder.rs b/crates/turborepo-lib/src/engine/builder.rs index 3f84db8a7069c..00aaf64a6b47a 100644 --- a/crates/turborepo-lib/src/engine/builder.rs +++ b/crates/turborepo-lib/src/engine/builder.rs @@ -13,7 +13,10 @@ use crate::{ config, run::task_id::{TaskId, TaskName}, task_graph::TaskDefinition, - turbo_json::{validate_extends, validate_no_package_task_syntax, RawTaskDefinition, TurboJson}, + turbo_json::{ + validate_extends, validate_no_package_task_syntax, RawTaskDefinition, TurboJson, + CONFIG_FILE, + }, }; #[derive(Debug, thiserror::Error, Diagnostic)] @@ -491,9 +494,13 @@ impl<'a> EngineBuilder<'a> { .ok_or_else(|| Error::MissingPackageJson { workspace: workspace.clone(), })?; + let workspace_turbo_json = self + .repo_root + .resolve(workspace_dir) + .join_component(CONFIG_FILE); Ok(TurboJson::load( self.repo_root, - workspace_dir, + &workspace_turbo_json, package_json, self.is_single, )?) @@ -534,7 +541,7 @@ mod test { use serde_json::json; use tempfile::TempDir; use test_case::test_case; - use turbopath::{AbsoluteSystemPathBuf, AnchoredSystemPath}; + use turbopath::AbsoluteSystemPathBuf; use turborepo_lockfiles::Lockfile; use turborepo_repository::{ discovery::PackageDiscovery, package_json::PackageJson, package_manager::PackageManager, @@ -680,7 +687,7 @@ mod test { fn turbo_json(value: serde_json::Value) -> TurboJson { let json_text = serde_json::to_string(&value).unwrap(); - let raw = RawTurboJson::parse(&json_text, AnchoredSystemPath::new("").unwrap()).unwrap(); + let raw = RawTurboJson::parse(&json_text, "").unwrap(); TurboJson::try_from(raw).unwrap() } diff --git a/crates/turborepo-lib/src/package_changes_watcher.rs b/crates/turborepo-lib/src/package_changes_watcher.rs index ceeeb3331f424..deb74acc5d199 100644 --- a/crates/turborepo-lib/src/package_changes_watcher.rs +++ b/crates/turborepo-lib/src/package_changes_watcher.rs @@ -21,7 +21,7 @@ use turborepo_repository::{ }; use turborepo_scm::package_deps::GitHashes; -use crate::turbo_json::TurboJson; +use crate::turbo_json::{TurboJson, CONFIG_FILE}; #[derive(Clone)] pub enum PackageChangeEvent { @@ -164,7 +164,7 @@ impl Subscriber { let root_turbo_json = TurboJson::load( &self.repo_root, - &AnchoredSystemPathBuf::default(), + &self.repo_root.join_component(CONFIG_FILE), &root_package_json, false, ) diff --git a/crates/turborepo-lib/src/run/builder.rs b/crates/turborepo-lib/src/run/builder.rs index 6b6f95bcbd369..ede9e9577a7bc 100644 --- a/crates/turborepo-lib/src/run/builder.rs +++ b/crates/turborepo-lib/src/run/builder.rs @@ -7,7 +7,7 @@ use std::{ use chrono::Local; use tracing::debug; -use turbopath::{AbsoluteSystemPath, AbsoluteSystemPathBuf, AnchoredSystemPath}; +use turbopath::{AbsoluteSystemPath, AbsoluteSystemPathBuf}; use turborepo_analytics::{start_analytics, AnalyticsHandle, AnalyticsSender}; use turborepo_api_client::{APIAuth, APIClient}; use turborepo_cache::AsyncCache; @@ -54,6 +54,7 @@ pub struct RunBuilder { opts: Opts, api_auth: Option, repo_root: AbsoluteSystemPathBuf, + root_turbo_json_path: AbsoluteSystemPathBuf, color_config: ColorConfig, version: &'static str, ui_mode: UIMode, @@ -85,6 +86,8 @@ impl RunBuilder { // - if we're on windows, we're using the UI (!cfg!(windows) || matches!(ui_mode, UIMode::Tui)), ); + let root_turbo_json_path = config.root_turbo_json_path(&base.repo_root); + let CommandBase { repo_root, color_config: ui, @@ -104,6 +107,7 @@ impl RunBuilder { entrypoint_packages: None, should_print_prelude_override: None, allow_missing_package_manager, + root_turbo_json_path, }) } @@ -358,12 +362,19 @@ impl RunBuilder { let task_access = TaskAccess::new(self.repo_root.clone(), async_cache.clone(), &scm); task_access.restore_config().await; - let root_turbo_json = TurboJson::load( - &self.repo_root, - AnchoredSystemPath::empty(), - &root_package_json, - is_single_package, - )?; + let root_turbo_json = task_access + .load_turbo_json(&self.root_turbo_json_path) + .map_or_else( + || { + TurboJson::load( + &self.repo_root, + &self.root_turbo_json_path, + &root_package_json, + is_single_package, + ) + }, + Result::Ok, + )?; pkg_dep_graph.validate()?; diff --git a/crates/turborepo-lib/src/run/task_access.rs b/crates/turborepo-lib/src/run/task_access.rs index c8056ece1ce43..65fa374b9c26b 100644 --- a/crates/turborepo-lib/src/run/task_access.rs +++ b/crates/turborepo-lib/src/run/task_access.rs @@ -7,13 +7,13 @@ use std::{ use serde::Deserialize; use tracing::{debug, error, warn}; -use turbopath::{AbsoluteSystemPathBuf, PathRelation}; +use turbopath::{AbsoluteSystemPath, AbsoluteSystemPathBuf, PathRelation}; use turborepo_cache::AsyncCache; use turborepo_scm::SCM; use turborepo_unescape::UnescapedString; use super::ConfigCache; -use crate::{config::RawTurboJson, gitignore::ensure_turbo_is_gitignored}; +use crate::{config::RawTurboJson, gitignore::ensure_turbo_is_gitignored, turbo_json::TurboJson}; // Environment variable key that will be used to enable, and set the expected // trace location @@ -138,7 +138,7 @@ impl TaskAccessTraceFile { #[derive(Clone)] pub struct TaskAccess { - pub repo_root: AbsoluteSystemPathBuf, + repo_root: AbsoluteSystemPathBuf, trace_by_task: Arc>>, config_cache: Option, enabled: bool, @@ -245,6 +245,25 @@ impl TaskAccess { } } + /// Attempt to load a task traced turbo.json + pub fn load_turbo_json(&self, root_turbo_json_path: &AbsoluteSystemPath) -> Option { + if !self.enabled { + return None; + } + let trace_json_path = self.repo_root.join_components(&TASK_ACCESS_CONFIG_PATH); + let turbo_from_trace = TurboJson::read(&self.repo_root, &trace_json_path); + + // check the zero config case (turbo trace file, but no turbo.json file) + if let Ok(turbo_from_trace) = turbo_from_trace { + if !root_turbo_json_path.exists() { + debug!("Using turbo.json synthesized from trace file"); + return Some(turbo_from_trace); + } + } + + None + } + async fn to_file(&self) -> Result<(), ToFileError> { // if task access tracing is not enabled, we don't need to do anything if !self.is_enabled() { diff --git a/crates/turborepo-lib/src/run/watch.rs b/crates/turborepo-lib/src/run/watch.rs index 7119b128f070d..995ff810400c4 100644 --- a/crates/turborepo-lib/src/run/watch.rs +++ b/crates/turborepo-lib/src/run/watch.rs @@ -15,12 +15,12 @@ use turborepo_ui::{tui, tui::AppSender}; use crate::{ cli::{Command, RunArgs}, - commands, - commands::CommandBase, + commands::{self, CommandBase}, daemon::{proto, DaemonConnectorError, DaemonError}, - get_version, opts, run, - run::{builder::RunBuilder, scope::target_selector::InvalidSelectorError, Run}, + get_version, opts, + run::{self, builder::RunBuilder, scope::target_selector::InvalidSelectorError, Run}, signal::SignalHandler, + turbo_json::CONFIG_FILE, DaemonConnector, DaemonPaths, }; @@ -101,12 +101,22 @@ pub enum Error { PackageChange(#[from] tonic::Status), #[error("could not connect to UI thread")] UISend(String), + #[error("cannot use root turbo.json at {0} with watch mode")] + NonStandardTurboJsonPath(String), + #[error("invalid config: {0}")] + Config(#[from] crate::config::Error), } impl WatchClient { pub async fn new(base: CommandBase, telemetry: CommandEventBuilder) -> Result { let signal = commands::run::get_signal()?; let handler = SignalHandler::new(signal); + let root_turbo_json_path = base.config()?.root_turbo_json_path(&base.repo_root); + if root_turbo_json_path != base.repo_root.join_component(CONFIG_FILE) { + return Err(Error::NonStandardTurboJsonPath( + root_turbo_json_path.to_string(), + )); + } let Some(Command::Watch(execution_args)) = &base.args().command else { unreachable!() diff --git a/crates/turborepo-lib/src/turbo_json/mod.rs b/crates/turborepo-lib/src/turbo_json/mod.rs index 7f16f11e6d08f..b76d043a3fd16 100644 --- a/crates/turborepo-lib/src/turbo_json/mod.rs +++ b/crates/turborepo-lib/src/turbo_json/mod.rs @@ -10,8 +10,7 @@ use clap::ValueEnum; use miette::{NamedSource, SourceSpan}; use serde::{Deserialize, Serialize}; use struct_iterable::Iterable; -use tracing::debug; -use turbopath::{AbsoluteSystemPath, AnchoredSystemPath}; +use turbopath::AbsoluteSystemPath; use turborepo_errors::Spanned; use turborepo_repository::{package_graph::ROOT_PKG_NAME, package_json::PackageJson}; use turborepo_unescape::UnescapedString; @@ -20,7 +19,7 @@ use crate::{ cli::{EnvMode, OutputLogsMode}, config::{ConfigurationOptions, Error, InvalidEnvPrefixError}, run::{ - task_access::{TaskAccessTraceFile, TASK_ACCESS_CONFIG_PATH}, + task_access::TaskAccessTraceFile, task_id::{TaskId, TaskName}, }, task_graph::{TaskDefinition, TaskOutputs}, @@ -249,7 +248,7 @@ impl RawTaskDefinition { } } -const CONFIG_FILE: &str = "turbo.json"; +pub const CONFIG_FILE: &str = "turbo.json"; const ENV_PIPELINE_DELIMITER: &str = "$"; const TOPOLOGICAL_PIPELINE_DELIMITER: &str = "^"; @@ -401,11 +400,16 @@ impl TryFrom for TaskDefinition { impl RawTurboJson { pub(crate) fn read( repo_root: &AbsoluteSystemPath, - path: &AnchoredSystemPath, + path: &AbsoluteSystemPath, ) -> Result { - let absolute_path = repo_root.resolve(path); - let contents = absolute_path.read_to_string()?; - let raw_turbo_json = RawTurboJson::parse(&contents, path)?; + let contents = path.read_to_string()?; + // Anchoring the path can fail if the path resides outside of the repository + // Just display absolute path in that case. + let root_relative_path = repo_root.anchor(path).map_or_else( + |_| path.as_str().to_owned(), + |relative| relative.to_string(), + ); + let raw_turbo_json = RawTurboJson::parse(&contents, &root_relative_path)?; Ok(raw_turbo_json) } @@ -540,21 +544,11 @@ impl TurboJson { /// with synthesized information from the provided package.json pub fn load( repo_root: &AbsoluteSystemPath, - dir: &AnchoredSystemPath, + turbo_json_path: &AbsoluteSystemPath, root_package_json: &PackageJson, include_synthesized_from_root_package_json: bool, ) -> Result { - let turbo_from_files = Self::read(repo_root, &dir.join_component(CONFIG_FILE)); - let turbo_from_trace = - Self::read(repo_root, &dir.join_components(&TASK_ACCESS_CONFIG_PATH)); - - // check the zero config case (turbo trace file, but no turbo.json file) - if let Ok(turbo_from_trace) = turbo_from_trace { - if turbo_from_files.is_err() { - debug!("Using turbo.json synthesized from trace file"); - return Ok(turbo_from_trace); - } - } + let turbo_from_files = Self::read(repo_root, turbo_json_path); let mut turbo_json = match (include_synthesized_from_root_package_json, turbo_from_files) { // If the file didn't exist, throw a custom error here instead of propagating @@ -630,7 +624,7 @@ impl TurboJson { /// and then converts it into `TurboJson` pub(crate) fn read( repo_root: &AbsoluteSystemPath, - path: &AnchoredSystemPath, + path: &AbsoluteSystemPath, ) -> Result { let raw_turbo_json = RawTurboJson::read(repo_root, path)?; raw_turbo_json.try_into() @@ -747,7 +741,7 @@ mod tests { use serde_json::json; use tempfile::tempdir; use test_case::test_case; - use turbopath::{AbsoluteSystemPath, AnchoredSystemPath}; + use turbopath::AbsoluteSystemPath; use turborepo_repository::package_json::PackageJson; use turborepo_unescape::UnescapedString; @@ -756,7 +750,7 @@ mod tests { cli::OutputLogsMode, run::task_id::TaskName, task_graph::{TaskDefinition, TaskOutputs}, - turbo_json::{RawTaskDefinition, TurboJson}, + turbo_json::{RawTaskDefinition, TurboJson, CONFIG_FILE}, }; #[test_case(r"{}", TurboJson::default() ; "empty")] @@ -785,7 +779,7 @@ mod tests { let mut turbo_json = TurboJson::load( repo_root, - AnchoredSystemPath::empty(), + &repo_root.join_component(CONFIG_FILE), &root_package_json, false, )?; @@ -859,7 +853,7 @@ mod tests { let mut turbo_json = TurboJson::load( repo_root, - AnchoredSystemPath::empty(), + &repo_root.join_component(CONFIG_FILE), &root_package_json, true, )?; @@ -1085,14 +1079,14 @@ mod tests { #[test_case(r#"{ "ui": "stream" }"#, Some(UIMode::Stream) ; "stream")] #[test_case(r#"{}"#, None ; "missing")] fn test_ui(json: &str, expected: Option) { - let json = RawTurboJson::parse(json, AnchoredSystemPath::new("").unwrap()).unwrap(); + let json = RawTurboJson::parse(json, "").unwrap(); assert_eq!(json.ui, expected); } #[test_case(r#"{ "daemon": true }"#, r#"{"daemon":true}"# ; "daemon_on")] #[test_case(r#"{ "daemon": false }"#, r#"{"daemon":false}"# ; "daemon_off")] fn test_daemon(json: &str, expected: &str) { - let parsed = RawTurboJson::parse(json, AnchoredSystemPath::new("").unwrap()).unwrap(); + let parsed = RawTurboJson::parse(json, "").unwrap(); let actual = serde_json::to_string(&parsed).unwrap(); assert_eq!(actual, expected); } @@ -1100,7 +1094,7 @@ mod tests { #[test_case(r#"{ "ui": "tui" }"#, r#"{"ui":"tui"}"# ; "tui")] #[test_case(r#"{ "ui": "stream" }"#, r#"{"ui":"stream"}"# ; "stream")] fn test_ui_serialization(input: &str, expected: &str) { - let parsed = RawTurboJson::parse(input, AnchoredSystemPath::new("").unwrap()).unwrap(); + let parsed = RawTurboJson::parse(input, "").unwrap(); let actual = serde_json::to_string(&parsed).unwrap(); assert_eq!(actual, expected); } @@ -1109,7 +1103,7 @@ mod tests { #[test_case(r#"{"dangerouslyDisablePackageManagerCheck":false}"#, Some(false) ; "f")] #[test_case(r#"{}"#, None ; "missing")] fn test_allow_no_package_manager_serde(json_str: &str, expected: Option) { - let json = RawTurboJson::parse(json_str, AnchoredSystemPath::new("").unwrap()).unwrap(); + let json = RawTurboJson::parse(json_str, "").unwrap(); assert_eq!(json.allow_no_package_manager, expected); let serialized = serde_json::to_string(&json).unwrap(); assert_eq!(serialized, json_str); diff --git a/crates/turborepo-lib/src/turbo_json/parser.rs b/crates/turborepo-lib/src/turbo_json/parser.rs index 8f8693316d998..bb16c723f95e0 100644 --- a/crates/turborepo-lib/src/turbo_json/parser.rs +++ b/crates/turborepo-lib/src/turbo_json/parser.rs @@ -11,7 +11,6 @@ use convert_case::{Case, Casing}; use miette::Diagnostic; use struct_iterable::Iterable; use thiserror::Error; -use turbopath::AnchoredSystemPath; use turborepo_errors::{ParseDiagnostic, WithMetadata}; use turborepo_unescape::UnescapedString; @@ -172,7 +171,7 @@ impl RawTurboJson { #[cfg(test)] pub fn parse_from_serde(value: serde_json::Value) -> Result { let json_string = serde_json::to_string(&value).expect("should be able to serialize"); - Self::parse(&json_string, AnchoredSystemPath::new("turbo.json").unwrap()) + Self::parse(&json_string, "turbo.json") } /// Parses a turbo.json file into the raw representation with span info /// attached. @@ -184,11 +183,11 @@ impl RawTurboJson { /// display, so doesn't need to actually be a correct path. /// /// returns: Result - pub fn parse(text: &str, file_path: &AnchoredSystemPath) -> Result { + pub fn parse(text: &str, file_path: &str) -> Result { let result = deserialize_from_json_str::( text, JsonParserOptions::default().with_allow_comments(), - file_path.as_str(), + file_path, ); if !result.diagnostics().is_empty() { @@ -197,7 +196,7 @@ impl RawTurboJson { .into_iter() .map(|d| { d.with_file_source_code(text) - .with_file_path(file_path.as_str()) + .with_file_path(file_path) .into() }) .collect(); @@ -216,7 +215,7 @@ impl RawTurboJson { })?; turbo_json.add_text(Arc::from(text)); - turbo_json.add_path(Arc::from(file_path.as_str())); + turbo_json.add_path(Arc::from(file_path)); Ok(turbo_json) } diff --git a/turborepo-tests/integration/tests/no-args.t b/turborepo-tests/integration/tests/no-args.t index c61a5560c0f02..ad843fd5b12d9 100644 --- a/turborepo-tests/integration/tests/no-args.t +++ b/turborepo-tests/integration/tests/no-args.t @@ -58,6 +58,8 @@ Make sure exit code is 2 when no args are passed Verbosity level --dangerously-disable-package-manager-check Allow for missing `packageManager` in `package.json` + --root-turbo-json + Use the `turbo.json` located at the provided path instead of one at the root of the repository -h, --help Print help (see more with '--help') diff --git a/turborepo-tests/integration/tests/run/no-root-turbo.t b/turborepo-tests/integration/tests/run/no-root-turbo.t new file mode 100644 index 0000000000000..60292a0a7b544 --- /dev/null +++ b/turborepo-tests/integration/tests/run/no-root-turbo.t @@ -0,0 +1,46 @@ +Setup + $ . ${TESTDIR}/../../../helpers/setup_integration_test.sh basic_monorepo + $ mv turbo.json turborepo.json + +Run without --root-turbo-json should fail + $ ${TURBO} build + x Could not find turbo.json. + | Follow directions at https://turbo.build/repo/docs to create one + + [1] + +Run with --root-turbo-json should use specified config + $ ${TURBO} build --filter=my-app --root-turbo-json=turborepo.json + \xe2\x80\xa2 Packages in scope: my-app (esc) + \xe2\x80\xa2 Running build in 1 packages (esc) + \xe2\x80\xa2 Remote caching disabled (esc) + my-app:build: cache miss, executing 0555ce94ca234049 + my-app:build: + my-app:build: > build + my-app:build: > echo building + my-app:build: + my-app:build: building + + Tasks: 1 successful, 1 total + Cached: 0 cached, 1 total + Time:\s*[\.0-9]+m?s (re) + + +Run with TURBO_ROOT_TURBO_JSON should use specified config + $ TURBO_ROOT_TURBO_JSON=turborepo.json ${TURBO} build --filter=my-app + \xe2\x80\xa2 Packages in scope: my-app (esc) + \xe2\x80\xa2 Running build in 1 packages (esc) + \xe2\x80\xa2 Remote caching disabled (esc) + my-app:build: cache hit, replaying logs 0555ce94ca234049 + my-app:build: + my-app:build: > build + my-app:build: > echo building + my-app:build: + my-app:build: building + + Tasks: 1 successful, 1 total + Cached: 1 cached, 1 total + Time:\s*[\.0-9]+m?s >>> FULL TURBO (re) + + +Run with --continue diff --git a/turborepo-tests/integration/tests/turbo-help.t b/turborepo-tests/integration/tests/turbo-help.t index c94bb85edc6c9..c18f8e85df8ca 100644 --- a/turborepo-tests/integration/tests/turbo-help.t +++ b/turborepo-tests/integration/tests/turbo-help.t @@ -58,6 +58,8 @@ Test help flag Verbosity level --dangerously-disable-package-manager-check Allow for missing `packageManager` in `package.json` + --root-turbo-json + Use the `turbo.json` located at the provided path instead of one at the root of the repository -h, --help Print help (see more with '--help') @@ -197,6 +199,9 @@ Test help flag `turbo` will use hints from codebase to guess which package manager should be used. + --root-turbo-json + Use the `turbo.json` located at the provided path instead of one at the root of the repository + -h, --help Print help (see a summary with '-h') @@ -353,6 +358,8 @@ Test help flag for link command Verbosity level --dangerously-disable-package-manager-check Allow for missing `packageManager` in `package.json` + --root-turbo-json + Use the `turbo.json` located at the provided path instead of one at the root of the repository -h, --help Print help (see more with '--help') @@ -399,6 +406,8 @@ Test help flag for unlink command Verbosity level --dangerously-disable-package-manager-check Allow for missing `packageManager` in `package.json` + --root-turbo-json + Use the `turbo.json` located at the provided path instead of one at the root of the repository -h, --help Print help (see more with '--help') @@ -447,6 +456,8 @@ Test help flag for login command Verbosity level --dangerously-disable-package-manager-check Allow for missing `packageManager` in `package.json` + --root-turbo-json + Use the `turbo.json` located at the provided path instead of one at the root of the repository -h, --help Print help (see more with '--help') @@ -493,5 +504,7 @@ Test help flag for logout command Verbosity level --dangerously-disable-package-manager-check Allow for missing `packageManager` in `package.json` + --root-turbo-json + Use the `turbo.json` located at the provided path instead of one at the root of the repository -h, --help Print help (see more with '--help')