diff --git a/crates/turborepo-lib/src/cli/mod.rs b/crates/turborepo-lib/src/cli/mod.rs index 81339812a1059..ad7d915f4b9a2 100644 --- a/crates/turborepo-lib/src/cli/mod.rs +++ b/crates/turborepo-lib/src/cli/mod.rs @@ -611,6 +611,9 @@ pub enum Command { /// Set the idle timeout for turbod #[clap(long, default_value_t = String::from("4h0m0s"))] idle_time: String, + /// Path to a custom turbo.json file to watch from --root-turbo-json + #[clap(long)] + turbo_json_path: Option, #[clap(subcommand)] command: Option, }, @@ -1428,15 +1431,23 @@ pub async fn run( Ok(clone::run(cwd, url, dir.as_deref(), *ci, *local, *depth)?) } #[allow(unused_variables)] - Command::Daemon { command, idle_time } => { + Command::Daemon { + command, + idle_time, + turbo_json_path, + } => { let event = CommandEventBuilder::new("daemon").with_parent(&root_telemetry); event.track_call(); let base = CommandBase::new(cli_args.clone(), repo_root, version, color_config)?; event.track_ui_mode(base.opts.run_opts.ui_mode); match command { - Some(command) => daemon::daemon_client(command, &base).await, - None => daemon::daemon_server(&base, idle_time, logger).await, + Some(command) => { + daemon::daemon_client(command, &base, turbo_json_path.clone()).await + } + None => { + daemon::daemon_server(&base, idle_time, turbo_json_path.clone(), logger).await + } }?; Ok(0) diff --git a/crates/turborepo-lib/src/commands/daemon.rs b/crates/turborepo-lib/src/commands/daemon.rs index ce1ac1030b43f..452ef49097cd9 100644 --- a/crates/turborepo-lib/src/commands/daemon.rs +++ b/crates/turborepo-lib/src/commands/daemon.rs @@ -24,8 +24,31 @@ use crate::{ const DAEMON_NOT_RUNNING_MESSAGE: &str = "daemon is not running, run `turbo daemon start` to start it"; +/// Converts an optional turbo.json path to an absolute system path. +fn convert_turbo_json_path( + path: Option<&camino::Utf8Path>, +) -> Result, DaemonError> { + match path { + Some(p) => match turbopath::AbsoluteSystemPathBuf::from_cwd(p) { + Ok(path) => Ok(Some(path)), + Err(e) => { + tracing::error!("Failed to convert custom turbo.json path: {}", e); + Err(DaemonError::Unavailable(format!( + "Invalid turbo.json path: {}", + e + ))) + } + }, + None => Ok(None), + } +} + /// Runs the daemon command. -pub async fn daemon_client(command: &DaemonCommand, base: &CommandBase) -> Result<(), DaemonError> { +pub async fn daemon_client( + command: &DaemonCommand, + base: &CommandBase, + custom_turbo_json_path: Option, +) -> Result<(), DaemonError> { let (can_start_server, can_kill_server) = match command { DaemonCommand::Status { .. } | DaemonCommand::Logs => (false, false), DaemonCommand::Stop => (false, true), @@ -33,7 +56,14 @@ pub async fn daemon_client(command: &DaemonCommand, base: &CommandBase) -> Resul DaemonCommand::Clean { .. } => (false, true), }; - let connector = DaemonConnector::new(can_start_server, can_kill_server, &base.repo_root); + let custom_turbo_json_path = convert_turbo_json_path(custom_turbo_json_path.as_deref())?; + + let connector = DaemonConnector::new( + can_start_server, + can_kill_server, + &base.repo_root, + custom_turbo_json_path, + ); match command { DaemonCommand::Restart => { @@ -275,6 +305,7 @@ fn log_filename(base_filename: &str) -> Result { pub async fn daemon_server( base: &CommandBase, idle_time: &String, + turbo_json_path: Option, logging: &TurboSubscriber, ) -> Result<(), DaemonError> { let paths = Paths::from_repo_root(&base.repo_root); @@ -298,8 +329,14 @@ pub async fn daemon_server( } CloseReason::Interrupt }); - let server = - crate::daemon::TurboGrpcService::new(base.repo_root.clone(), paths, timeout, exit_signal); + let custom_turbo_json_path = convert_turbo_json_path(turbo_json_path.as_deref())?; + let server = crate::daemon::TurboGrpcService::new( + base.repo_root.clone(), + paths, + timeout, + exit_signal, + custom_turbo_json_path, + ); let reason = server.serve().await?; diff --git a/crates/turborepo-lib/src/commands/info.rs b/crates/turborepo-lib/src/commands/info.rs index 394ac1b65a518..4a4686705f62a 100644 --- a/crates/turborepo-lib/src/commands/info.rs +++ b/crates/turborepo-lib/src/commands/info.rs @@ -20,7 +20,7 @@ fn is_wsl() -> bool { pub async fn run(base: CommandBase) { let system = System::new_all(); - let connector = DaemonConnector::new(false, false, &base.repo_root); + let connector = DaemonConnector::new(false, false, &base.repo_root, None); let daemon_status = match connector.connect().await { Ok(_status) => "Running", Err(DaemonConnectorError::NotRunning) => "Not running", diff --git a/crates/turborepo-lib/src/daemon/connector.rs b/crates/turborepo-lib/src/daemon/connector.rs index a5c941b7a2239..bf9e377af1579 100644 --- a/crates/turborepo-lib/src/daemon/connector.rs +++ b/crates/turborepo-lib/src/daemon/connector.rs @@ -66,6 +66,8 @@ pub struct DaemonConnector { /// in the event of a version mismatch). pub can_kill_server: bool, pub paths: Paths, + /// Optional custom turbo.json path to watch + pub custom_turbo_json_path: Option, } impl DaemonConnector { @@ -73,12 +75,14 @@ impl DaemonConnector { can_start_server: bool, can_kill_server: bool, repo_root: &AbsoluteSystemPath, + custom_turbo_json_path: Option, ) -> Self { let paths = Paths::from_repo_root(repo_root); Self { can_start_server, can_kill_server, paths, + custom_turbo_json_path, } } @@ -154,21 +158,29 @@ impl DaemonConnector { } None if self.can_start_server => { debug!("no pid found, starting daemon"); - Self::start_daemon().await + Self::start_daemon(&self.custom_turbo_json_path).await } None => Err(DaemonConnectorError::NotRunning), } } /// Starts the daemon process, returning its PID. - async fn start_daemon() -> Result { + async fn start_daemon( + custom_turbo_json_path: &Option, + ) -> Result { let binary_path = std::env::current_exe().map_err(|e| DaemonConnectorError::Fork(e.into()))?; // this creates a new process group for the given command // in a cross platform way, directing all output to /dev/null - let mut group = tokio::process::Command::new(binary_path) - .arg("--skip-infer") - .arg("daemon") + let mut command = tokio::process::Command::new(binary_path); + command.arg("--skip-infer").arg("daemon"); + + // Pass custom turbo.json path if specified + if let Some(path) = custom_turbo_json_path { + command.arg("--turbo-json-path").arg(path.as_str()); + } + + let mut group = command .stderr(Stdio::null()) .stdout(Stdio::null()) .group() @@ -437,7 +449,7 @@ mod test { let tmp_dir = tempfile::tempdir().unwrap(); let repo_root = AbsoluteSystemPathBuf::try_from(tmp_dir.path()).unwrap(); - let connector = DaemonConnector::new(false, false, &repo_root); + let connector = DaemonConnector::new(false, false, &repo_root, None); connector.paths.pid_file.ensure_dir().unwrap(); connector .paths @@ -455,7 +467,7 @@ mod test { async fn handles_missing_server_connect() { let tmp_dir = tempfile::tempdir().unwrap(); let repo_root = AbsoluteSystemPathBuf::try_from(tmp_dir.path()).unwrap(); - let connector = DaemonConnector::new(false, false, &repo_root); + let connector = DaemonConnector::new(false, false, &repo_root, None); assert_matches!( connector.connect().await, @@ -467,7 +479,7 @@ mod test { async fn handles_kill_dead_server_missing_pid() { let tmp_dir = tempfile::tempdir().unwrap(); let repo_root = AbsoluteSystemPathBuf::try_from(tmp_dir.path()).unwrap(); - let connector = DaemonConnector::new(false, false, &repo_root); + let connector = DaemonConnector::new(false, false, &repo_root, None); assert_matches!( connector.kill_dead_server(Pid::from(usize::MAX)).await, @@ -479,7 +491,7 @@ mod test { async fn handles_kill_dead_server_missing_process() { let tmp_dir = tempfile::tempdir().unwrap(); let repo_root = AbsoluteSystemPathBuf::try_from(tmp_dir.path()).unwrap(); - let connector = DaemonConnector::new(false, false, &repo_root); + let connector = DaemonConnector::new(false, false, &repo_root, None); connector.paths.pid_file.ensure_dir().unwrap(); connector @@ -505,7 +517,7 @@ mod test { async fn handles_kill_dead_server_wrong_process() { let tmp_dir = tempfile::tempdir().unwrap(); let repo_root = AbsoluteSystemPathBuf::try_from(tmp_dir.path()).unwrap(); - let connector = DaemonConnector::new(false, false, &repo_root); + let connector = DaemonConnector::new(false, false, &repo_root, None); let proc = tokio::process::Command::new(NODE_EXE) .stdout(Stdio::null()) @@ -542,7 +554,7 @@ mod test { async fn handles_kill_dead_server() { let tmp_dir = tempfile::tempdir().unwrap(); let repo_root = AbsoluteSystemPathBuf::try_from(tmp_dir.path()).unwrap(); - let connector = DaemonConnector::new(false, true, &repo_root); + let connector = DaemonConnector::new(false, true, &repo_root, None); let proc = tokio::process::Command::new(NODE_EXE) .stdout(Stdio::null()) @@ -682,7 +694,7 @@ mod test { let tmp_dir = tempfile::tempdir().unwrap(); let repo_root = AbsoluteSystemPathBuf::try_from(tmp_dir.path()).unwrap(); - let connector = DaemonConnector::new(false, false, &repo_root); + let connector = DaemonConnector::new(false, false, &repo_root, None); let mut client = Endpoint::try_from("http://[::]:50051") .expect("this is a valid uri") diff --git a/crates/turborepo-lib/src/daemon/server.rs b/crates/turborepo-lib/src/daemon/server.rs index 923d31a797592..9c68bc70fef77 100644 --- a/crates/turborepo-lib/src/daemon/server.rs +++ b/crates/turborepo-lib/src/daemon/server.rs @@ -67,6 +67,7 @@ pub struct FileWatching { pub package_watcher: Arc, pub package_changes_watcher: OnceLock>, pub hash_watcher: Arc, + custom_turbo_json_path: Option, } #[derive(Debug, Error)] @@ -111,7 +112,10 @@ impl FileWatching { /// waiting for the filewatcher to be ready. Using `OptionalWatch`, /// dependent services can wait for resources they need to become /// available, and the server can start up without waiting for them. - pub fn new(repo_root: AbsoluteSystemPathBuf) -> Result { + pub fn new( + repo_root: AbsoluteSystemPathBuf, + custom_turbo_json_path: Option, + ) -> Result { let watcher = Arc::new(FileSystemWatcher::new_with_default_cookie_dir(&repo_root)?); let recv = watcher.watch(); @@ -144,6 +148,7 @@ impl FileWatching { package_watcher, package_changes_watcher: OnceLock::new(), hash_watcher, + custom_turbo_json_path, }) } @@ -155,6 +160,7 @@ impl FileWatching { self.repo_root.clone(), recv, self.hash_watcher.clone(), + self.custom_turbo_json_path.clone(), )) }) .clone() @@ -169,6 +175,7 @@ pub struct TurboGrpcService { paths: Paths, timeout: Duration, external_shutdown: S, + custom_turbo_json_path: Option, } impl TurboGrpcService @@ -186,6 +193,7 @@ where paths: Paths, timeout: Duration, external_shutdown: S, + custom_turbo_json_path: Option, ) -> Self { // Run the actual service. It takes ownership of the struct given to it, // so we use a private struct with just the pieces of state needed to handle @@ -195,6 +203,7 @@ where paths, timeout, external_shutdown, + custom_turbo_json_path, } } @@ -204,6 +213,7 @@ where paths, repo_root, timeout, + custom_turbo_json_path, } = self; // A channel to trigger the shutdown of the gRPC server. This is handed out @@ -211,8 +221,12 @@ where // well as available to the gRPC server itself to handle the shutdown RPC. let (trigger_shutdown, mut shutdown_signal) = mpsc::channel::<()>(1); - let (service, exit_root_watch, watch_root_handle) = - TurboGrpcServiceInner::new(repo_root.clone(), trigger_shutdown, paths.log_file); + let (service, exit_root_watch, watch_root_handle) = TurboGrpcServiceInner::new( + repo_root.clone(), + trigger_shutdown, + paths.log_file, + custom_turbo_json_path, + ); let running = Arc::new(AtomicBool::new(true)); let (_pid_lock, stream) = @@ -290,12 +304,13 @@ impl TurboGrpcServiceInner { repo_root: AbsoluteSystemPathBuf, trigger_shutdown: mpsc::Sender<()>, log_file: AbsoluteSystemPathBuf, + custom_turbo_json_path: Option, ) -> ( Self, oneshot::Sender<()>, JoinHandle>, ) { - let file_watching = FileWatching::new(repo_root.clone()).unwrap(); + let file_watching = FileWatching::new(repo_root.clone(), custom_turbo_json_path).unwrap(); tracing::debug!("initing package discovery"); // Note that we're cloning the Arc, not the package watcher itself @@ -778,6 +793,7 @@ mod test { paths.clone(), Duration::from_secs(60 * 60), exit_signal, + None, ); // the package watcher reads data from the package.json file @@ -835,6 +851,7 @@ mod test { paths.clone(), Duration::from_millis(10), exit_signal, + None, ); // the package watcher reads data from the package.json file @@ -892,6 +909,7 @@ mod test { paths, Duration::from_secs(60 * 60), exit_signal, + None, ); let handle = tokio::task::spawn(server.serve()); diff --git a/crates/turborepo-lib/src/diagnostics.rs b/crates/turborepo-lib/src/diagnostics.rs index 3eb4fd32138b6..f7e8661821239 100644 --- a/crates/turborepo-lib/src/diagnostics.rs +++ b/crates/turborepo-lib/src/diagnostics.rs @@ -317,6 +317,7 @@ impl Diagnostic for DaemonDiagnostic { can_kill_server: false, can_start_server: true, paths, + custom_turbo_json_path: None, }; let mut client = match connector.connect().await { diff --git a/crates/turborepo-lib/src/package_changes_watcher.rs b/crates/turborepo-lib/src/package_changes_watcher.rs index 9ae12381fbc19..a902f5f36dabe 100644 --- a/crates/turborepo-lib/src/package_changes_watcher.rs +++ b/crates/turborepo-lib/src/package_changes_watcher.rs @@ -23,7 +23,9 @@ use turborepo_repository::{ }; use turborepo_scm::GitHashes; -use crate::turbo_json::{TurboJson, TurboJsonLoader, CONFIG_FILE, CONFIG_FILE_JSONC}; +use crate::turbo_json::{ + resolve_turbo_config_path, TurboJson, TurboJsonLoader, CONFIG_FILE, CONFIG_FILE_JSONC, +}; #[derive(Clone)] pub enum PackageChangeEvent { @@ -47,6 +49,7 @@ impl PackageChangesWatcher { repo_root: AbsoluteSystemPathBuf, file_events_lazy: OptionalWatch>>, hash_watcher: Arc, + custom_turbo_json_path: Option, ) -> Self { let (exit_tx, exit_rx) = oneshot::channel(); let (package_change_events_tx, package_change_events_rx) = @@ -56,6 +59,7 @@ impl PackageChangesWatcher { file_events_lazy, package_change_events_tx, hash_watcher, + custom_turbo_json_path, ); let _handle = tokio::spawn(subscriber.watch(exit_rx)); @@ -98,6 +102,7 @@ struct Subscriber { repo_root: AbsoluteSystemPathBuf, package_change_events_tx: broadcast::Sender, hash_watcher: Arc, + custom_turbo_json_path: Option, } // This is a workaround because `ignore` doesn't match against a path's @@ -146,13 +151,47 @@ impl Subscriber { file_events_lazy: OptionalWatch>>, package_change_events_tx: broadcast::Sender, hash_watcher: Arc, + custom_turbo_json_path: Option, ) -> Self { + // Try to canonicalize the custom path to match what the file watcher reports + let normalized_custom_path = custom_turbo_json_path.map(|path| { + // Check if the custom turbo.json path is outside the repository + if repo_root.anchor(&path).is_err() { + tracing::warn!( + "turbo.json is located outside of repository at {}. Changes to this file will \ + not be watched.", + path + ); + } + + match path.to_realpath() { + Ok(real_path) => { + tracing::info!( + "PackageChangesWatcher: monitoring custom turbo.json at: {} \ + (canonicalized: {})", + path, + real_path + ); + real_path + } + Err(e) => { + tracing::warn!( + "Failed to canonicalize custom turbo.json path {}: {}, using original path", + path, + e + ); + path + } + } + }); + Subscriber { repo_root, file_events_lazy, changed_files: Default::default(), package_change_events_tx, hash_watcher, + custom_turbo_json_path: normalized_custom_path, } } @@ -171,25 +210,24 @@ impl Subscriber { return None; }; - let turbo_json_path = self.repo_root.join_component(CONFIG_FILE); - let turbo_jsonc_path = self.repo_root.join_component(CONFIG_FILE_JSONC); - - let turbo_json_exists = turbo_json_path.exists(); - let turbo_jsonc_exists = turbo_jsonc_path.exists(); - - // TODO: Dedupe places where we search for turbo.json and turbo.jsonc - // There are now several places where we do this in the codebase - let config_path = match (turbo_json_exists, turbo_jsonc_exists) { - (true, true) => { - tracing::warn!( - "Found both turbo.json and turbo.jsonc in {}. Using turbo.json for watching.", - self.repo_root - ); - turbo_json_path + // Use custom turbo.json path if provided, otherwise use standard paths + let config_path = if let Some(custom_path) = &self.custom_turbo_json_path { + custom_path.clone() + } else { + match resolve_turbo_config_path(&self.repo_root) { + Ok(path) => path, + Err(_) => { + // TODO: If both turbo.json and turbo.jsonc exist, log warning and default to + // turbo.json to preserve existing behavior for file + // watching prior to refactoring. + tracing::warn!( + "Found both turbo.json and turbo.jsonc in {}. Using turbo.json for \ + watching.", + self.repo_root + ); + self.repo_root.join_component(CONFIG_FILE) + } } - (true, false) => turbo_json_path, - (false, true) => turbo_jsonc_path, - (false, false) => turbo_json_path, // Default to turbo.json }; let root_turbo_json = TurboJsonLoader::workspace( @@ -353,9 +391,19 @@ impl Subscriber { let turbo_json_path = self.repo_root.join_component(CONFIG_FILE); let turbo_jsonc_path = self.repo_root.join_component(CONFIG_FILE_JSONC); - if trie.get(turbo_json_path.as_str()).is_some() - || trie.get(turbo_jsonc_path.as_str()).is_some() - { + let standard_config_changed = trie.get(turbo_json_path.as_str()).is_some() + || trie.get(turbo_jsonc_path.as_str()).is_some(); + + let custom_config_changed = self + .custom_turbo_json_path + .as_ref() + .map(|path| { + let path_str = path.as_str(); + trie.get(path_str).is_some() + }) + .unwrap_or(false); + + if standard_config_changed || custom_config_changed { tracing::info!( "Detected change to turbo configuration file. Triggering rediscovery." ); diff --git a/crates/turborepo-lib/src/run/builder.rs b/crates/turborepo-lib/src/run/builder.rs index f41edb772eedf..eb52c31c903c5 100644 --- a/crates/turborepo-lib/src/run/builder.rs +++ b/crates/turborepo-lib/src/run/builder.rs @@ -47,7 +47,7 @@ use crate::{ opts::Opts, run::{scope, task_access::TaskAccess, task_id::TaskName, Error, Run, RunCache}, shim::TurboState, - turbo_json::{TurboJson, TurboJsonLoader, UIMode}, + turbo_json::{resolve_turbo_config_path, TurboJson, TurboJsonLoader, UIMode}, DaemonConnector, }; @@ -262,8 +262,26 @@ impl RunBuilder { (_, Some(true)) | (false, None) => { let can_start_server = true; let can_kill_server = true; - let connector = - DaemonConnector::new(can_start_server, can_kill_server, &self.repo_root); + + // Determine custom turbo.json path if different from standard path + let custom_turbo_json_path = + if let Ok(standard_path) = resolve_turbo_config_path(&self.repo_root) { + if self.opts.repo_opts.root_turbo_json_path != standard_path { + Some(self.opts.repo_opts.root_turbo_json_path.clone()) + } else { + None + } + } else { + None + }; + + let connector = DaemonConnector::new( + can_start_server, + can_kill_server, + &self.repo_root, + custom_turbo_json_path, + ); + match (connector.connect().await, self.opts.run_opts.daemon) { (Ok(client), _) => { run_telemetry.track_daemon_init(DaemonInitStatus::Started); diff --git a/crates/turborepo-lib/src/run/watch.rs b/crates/turborepo-lib/src/run/watch.rs index ffd9baa2c73c2..b9807bc3d99a3 100644 --- a/crates/turborepo-lib/src/run/watch.rs +++ b/crates/turborepo-lib/src/run/watch.rs @@ -103,8 +103,6 @@ pub enum Error { UI(#[from] turborepo_ui::Error), #[error("Could not connect to UI thread: {0}")] UISend(String), - #[error("Cannot use non-standard turbo configuration at {0} with Watch Mode.")] - NonStandardTurboJsonPath(String), #[error("Invalid config: {0}")] Config(#[from] crate::config::Error), #[error(transparent)] @@ -122,12 +120,6 @@ impl WatchClient { let standard_config_path = resolve_turbo_config_path(&base.repo_root)?; - if base.opts.repo_opts.root_turbo_json_path != standard_config_path { - return Err(Error::NonStandardTurboJsonPath( - base.opts.repo_opts.root_turbo_json_path.to_string(), - )); - } - if matches!(base.opts.run_opts.daemon, Some(false)) { warn!("daemon is required for watch, ignoring request to disable daemon"); } @@ -143,10 +135,24 @@ impl WatchClient { let (ui_sender, ui_handle) = run.start_ui()?.unzip(); + // Determine if we're using a custom turbo.json path + let custom_turbo_json_path = + if base.opts.repo_opts.root_turbo_json_path != standard_config_path { + tracing::info!( + "Using custom turbo.json path: {} (standard: {})", + base.opts.repo_opts.root_turbo_json_path, + standard_config_path + ); + Some(base.opts.repo_opts.root_turbo_json_path.clone()) + } else { + None + }; + let connector = DaemonConnector { can_start_server: true, can_kill_server: true, paths: DaemonPaths::from_repo_root(&base.repo_root), + custom_turbo_json_path, }; Ok(Self { diff --git a/crates/turborepo-lsp/src/lib.rs b/crates/turborepo-lsp/src/lib.rs index 729fcec1058a3..162effd0e74ce 100644 --- a/crates/turborepo-lsp/src/lib.rs +++ b/crates/turborepo-lsp/src/lib.rs @@ -85,8 +85,12 @@ impl LanguageServer for Backend { || { let can_start_server = true; let can_kill_server = false; - let connector = - DaemonConnector::new(can_start_server, can_kill_server, &repo_root); + let connector = DaemonConnector::new( + can_start_server, + can_kill_server, + &repo_root, + None, + ); connector.connect() }, )