1mod bins;
4mod capture;
5pub mod dotenv;
6mod env_flags;
7mod interpolate;
8mod process;
9mod providers;
10#[cfg(unix)]
11mod pty;
12pub mod readiness;
13pub mod ready_signal;
14pub mod report;
15pub mod tokens;
16
17use std::collections::BTreeMap;
18use std::ffi::OsString;
19use std::path::{Path, PathBuf};
20
21use anyhow::{Context, Result, bail};
22use qk_config::Workspace;
23use qk_taskgraph::Task;
24use serde_json::{Map, Value};
25
26pub use capture::{
27 Capture, Decoration, Display, MAX_LOG_BYTES, OutputStyle, RecordedLog, Shown, collapsed,
28 printable, read_capture, replay,
29};
30pub use env_flags::{force_colour, is_ci, is_ci_with};
31pub use process::{Outcome, execute, execute_captured, shell};
32
33#[derive(Debug)]
34struct NodeRuntime {
35 root: PathBuf,
36 env: BTreeMap<OsString, OsString>,
37 paths: Vec<PathBuf>,
38 directory: std::sync::OnceLock<Option<PathBuf>>,
39}
40
41#[derive(Clone, Debug)]
43pub struct NodePath {
44 runtime: std::sync::Arc<NodeRuntime>,
45 local_entries: usize,
46}
47
48impl NodePath {
49 fn new(root: &Path, env: &BTreeMap<OsString, OsString>, local_entries: usize) -> Self {
50 type Key = (PathBuf, BTreeMap<OsString, OsString>);
51 static RUNTIMES: std::sync::OnceLock<
52 std::sync::Mutex<BTreeMap<Key, std::sync::Arc<NodeRuntime>>>,
53 > = std::sync::OnceLock::new();
54 let mut runtimes = RUNTIMES.get_or_init(Default::default).lock().unwrap();
55 let runtime = runtimes
56 .entry((root.to_owned(), env.clone()))
57 .or_insert_with(|| {
58 let paths = env
59 .get(&path_key(env))
60 .map(|path| std::env::split_paths(path).collect())
61 .unwrap_or_default();
62 std::sync::Arc::new(NodeRuntime {
63 root: root.to_owned(),
64 env: env.clone(),
65 paths,
66 directory: Default::default(),
67 })
68 })
69 .clone();
70 Self {
71 runtime,
72 local_entries,
73 }
74 }
75
76 fn path(&self, env: &BTreeMap<OsString, OsString>) -> Result<Option<(OsString, OsString)>> {
77 let Some(directory) = self.runtime.directory.get_or_init(|| {
80 node_directory(&self.runtime.root, &self.runtime.paths, &self.runtime.env)
81 }) else {
82 return Ok(None);
83 };
84 let key = path_key(env);
85 let mut paths: Vec<_> = env
86 .get(&key)
87 .map(|path| std::env::split_paths(path).collect())
88 .unwrap_or_default();
89 paths.insert(self.local_entries.min(paths.len()), directory.clone());
90 Ok(Some((
91 key,
92 std::env::join_paths(paths).context("cannot construct task PATH")?,
93 )))
94 }
95}
96
97fn path_key(env: &BTreeMap<OsString, OsString>) -> OsString {
98 if cfg!(windows) {
99 env.keys()
100 .find(|key| key.to_string_lossy().eq_ignore_ascii_case("PATH"))
101 .cloned()
102 .unwrap_or_else(|| "PATH".into())
103 } else {
104 "PATH".into()
105 }
106}
107
108fn environment_key(env: &BTreeMap<OsString, OsString>, name: OsString) -> OsString {
109 if cfg!(windows) && name.to_string_lossy().eq_ignore_ascii_case("PATH") {
110 path_key(env)
111 } else {
112 name
113 }
114}
115
116fn node_directory(
117 cwd: &Path,
118 paths: &[PathBuf],
119 env: &BTreeMap<OsString, OsString>,
120) -> Option<PathBuf> {
121 let name = if cfg!(windows) { "node.exe" } else { "node" };
122 for directory in paths {
123 let candidate = cwd.join(directory).join(name);
124 let Ok(metadata) = candidate.metadata() else {
125 continue;
126 };
127 if !metadata.is_file() {
128 continue;
129 }
130 #[cfg(unix)]
131 {
132 use std::os::unix::fs::PermissionsExt;
133 if metadata.permissions().mode() & 0o111 == 0 {
134 continue;
135 }
136 }
137 let actual = candidate.canonicalize().ok()?;
139 if actual.file_name()? == name {
140 use std::io::Read;
141 let mut header = [0; 2];
142 let script = std::fs::File::open(&actual)
143 .and_then(|mut file| file.read_exact(&mut header))
144 .is_ok()
145 && header == *b"#!";
146 if !script {
147 return actual.parent().map(Path::to_owned);
148 }
149 }
150 return probe_node(&candidate, cwd, env);
151 }
152 None
153}
154
155fn probe_node(candidate: &Path, cwd: &Path, env: &BTreeMap<OsString, OsString>) -> Option<PathBuf> {
156 use std::io::{Read, Seek};
157 use std::process::{Command, Stdio};
158 use std::time::{Duration, Instant};
159 let mut output = tempfile::tempfile().ok()?;
160 let mut child = Command::new(candidate)
161 .args(["-p", "process.execPath"])
162 .current_dir(cwd)
163 .env_clear()
164 .envs(env)
165 .stdout(Stdio::from(output.try_clone().ok()?))
166 .stderr(Stdio::null())
167 .spawn()
168 .ok()?;
169 let started = Instant::now();
170 loop {
171 match child.try_wait() {
172 Ok(Some(status)) => {
173 if !status.success() {
174 return None;
175 }
176 let mut bytes = Vec::new();
177 output.rewind().ok()?;
178 output.take(4096).read_to_end(&mut bytes).ok()?;
179 let path = PathBuf::from(std::str::from_utf8(&bytes).ok()?.trim());
180 return (path.is_absolute() && path.is_file())
181 .then(|| path.parent().unwrap().to_owned());
182 }
183 Ok(None) if started.elapsed() < Duration::from_secs(2) => {
184 std::thread::sleep(Duration::from_millis(5))
185 }
186 _ => {
187 let _ = child.kill();
188 let _ = child.wait();
189 return None;
190 }
191 }
192 }
193}
194
195#[macro_export]
198macro_rules! status {
199 ($($arg:tt)*) => {{
200 $crate::report::warning(&::std::format!($($arg)*));
201 }};
202}
203
204#[derive(Clone, Debug)]
206pub enum Confinement {
207 Seatbelt(PathBuf),
209 Landlock(i32),
212 Refused(String),
215}
216
217#[derive(Clone, Debug)]
219pub struct Recording {
220 pub program: PathBuf,
223 pub directory: PathBuf,
224 pub seccomp: bool,
227 #[cfg(target_os = "linux")]
230 ebpf: Option<Ebpf>,
231}
232
233#[cfg(target_os = "linux")]
234#[derive(Clone, Debug)]
235struct Ebpf {
236 recorder: std::sync::Arc<qk_trace::Recorder>,
237 sessions: std::sync::Arc<std::sync::Mutex<Vec<qk_trace::Session>>>,
238 failure: std::sync::Arc<std::sync::Mutex<Option<String>>>,
241 resolve: bool,
244}
245
246#[cfg(target_os = "linux")]
250fn ebpf_recorder() -> Result<std::sync::Arc<qk_trace::Recorder>, String> {
251 static RECORDER: std::sync::OnceLock<Result<std::sync::Arc<qk_trace::Recorder>, String>> =
252 std::sync::OnceLock::new();
253 RECORDER
254 .get_or_init(|| {
255 let qk = std::env::current_exe().map_err(|error| error.to_string())?;
256 let helper = if unsafe { libc::geteuid() } == 0 {
258 let mut helper = std::process::Command::new(qk);
259 helper.arg("__trace-helper");
260 helper
261 } else {
262 let mut helper = std::process::Command::new("sudo");
263 helper.args(["-n", "--"]).arg(qk).arg("__trace-helper");
264 helper
265 };
266 qk_trace::Recorder::start(helper).map_err(|error| format!("{error:#}"))
267 })
268 .clone()
269}
270
271impl Recording {
272 pub fn probe(directory: &Path) -> anyhow::Result<Self> {
277 let choice = std::env::var("QK_RECORDER").unwrap_or_default();
278 match choice.as_str() {
279 "" | "auto" | "ebpf" =>
280 {
281 #[cfg(target_os = "linux")]
282 match ebpf_recorder() {
283 Ok(recorder) => {
284 return Ok(Self {
285 program: std::env::current_exe()?,
286 directory: directory.to_owned(),
287 seccomp: false,
288 ebpf: Some(Ebpf {
289 recorder,
290 sessions: Default::default(),
291 failure: Default::default(),
292 resolve: false,
293 }),
294 });
295 }
296 Err(error) if choice == "ebpf" => {
297 anyhow::bail!("cannot record with eBPF: {error}")
298 }
299 Err(_) => {}
300 }
301 }
302 "strace" => {}
303 other => anyhow::bail!("unknown QK_RECORDER {other:?}; use auto, ebpf or strace"),
304 }
305 let path = std::env::var_os("PATH").context("recording needs strace on PATH")?;
306 let program = std::env::split_paths(&path)
307 .map(|directory| directory.join("strace"))
308 .find(|candidate| candidate.is_file())
309 .context("recording needs strace on PATH; install strace and retry")?
310 .canonicalize()?;
311 let mut failure = String::new();
312 for seccomp in [true, false] {
313 let recording = Self {
314 program: program.clone(),
315 directory: directory.to_owned(),
316 seccomp,
317 #[cfg(target_os = "linux")]
318 ebpf: None,
319 };
320 let probe = recording
321 .command(0, "exec /bin/true", Path::new("/"))
322 .output()
323 .context("cannot probe strace")?;
324 if probe.status.success() {
325 return Ok(recording);
326 }
327 failure = String::from_utf8_lossy(&probe.stderr).into_owned();
328 }
329 anyhow::bail!("strace cannot record child processes: {failure}")
330 }
331
332 pub fn backend(&self) -> &'static str {
335 #[cfg(target_os = "linux")]
336 if self.ebpf.is_some() {
337 return "ebpf";
338 }
339 "strace"
340 }
341
342 pub fn in_directory(&self, directory: PathBuf) -> Self {
343 Self {
344 directory,
345 #[cfg(target_os = "linux")]
346 ebpf: self.ebpf.as_ref().map(|ebpf| Ebpf {
347 recorder: ebpf.recorder.clone(),
348 sessions: Default::default(),
349 failure: Default::default(),
350 resolve: ebpf.resolve,
351 }),
352 ..self.clone()
353 }
354 }
355
356 pub fn resolving(self) -> Self {
359 #[cfg(target_os = "linux")]
360 {
361 let mut recording = self;
362 if let Some(ebpf) = &mut recording.ebpf {
363 ebpf.resolve = true;
364 }
365 recording
366 }
367 #[cfg(not(target_os = "linux"))]
368 self
369 }
370
371 pub fn settle(&self) -> anyhow::Result<()> {
375 #[cfg(target_os = "linux")]
376 if let Some(ebpf) = &self.ebpf {
377 if let Some(failure) = ebpf.failure.lock().unwrap().as_ref() {
378 anyhow::bail!("{failure}");
379 }
380 let sessions = std::mem::take(&mut *ebpf.sessions.lock().unwrap());
381 let mut sessions = sessions.into_iter();
382 while let Some(session) = sessions.next() {
383 if let Err(error) = ebpf.recorder.finish(session) {
384 *ebpf.failure.lock().unwrap() = Some(format!("{error:#}"));
385 sessions
388 .by_ref()
389 .for_each(|rest| ebpf.recorder.abandon(rest));
390 return Err(error);
391 }
392 }
393 }
394 Ok(())
395 }
396
397 pub fn command(&self, index: usize, text: &str, cwd: &Path) -> std::process::Command {
400 #[cfg(target_os = "linux")]
401 if let Some(ebpf) = &self.ebpf {
402 use std::os::unix::process::CommandExt;
403 let session = ebpf.recorder.begin(
404 &self.directory,
405 &format!("command-{index}"),
406 cwd,
407 ebpf.resolve,
408 );
409 ebpf.sessions.lock().unwrap().push(session);
410 let mut command = crate::process::shell(text);
411 unsafe { command.pre_exec(ebpf.recorder.join(session)) };
413 return command;
414 }
415 let _ = cwd;
416 let mut command = std::process::Command::new(&self.program);
417 if self.seccomp {
419 command.arg("--seccomp-bpf");
420 }
421 command
422 .args([
423 "-ff",
424 "-ttt",
425 "-yy",
426 "-qq",
427 "-s",
428 "4096",
429 "-e",
430 "trace=%file,%process,getdents64,fchdir,close,dup,dup2,dup3,getcwd",
431 "-e",
432 "raw=execve,execveat",
433 "-o",
434 ])
435 .arg(self.directory.join(format!("command-{index}")))
436 .args(["--", "/bin/sh", "-c", text]);
437 command
438 }
439}
440
441#[derive(Clone, Debug)]
444pub struct Scratch {
445 pub root: PathBuf,
446 pub directory: PathBuf,
447}
448
449#[derive(Clone, Debug)]
450pub struct PreparedTask {
451 pub id: String,
452 pub commands: Vec<String>,
453 pub parallel: bool,
454 pub cwd: PathBuf,
455 pub env: BTreeMap<OsString, OsString>,
456 pub runtime_env: BTreeMap<OsString, OsString>,
458 pub node_path: Option<NodePath>,
459 pub environment: Option<qk_config::env::Environment>,
462 pub execution: BTreeMap<OsString, OsString>,
465 pub ready_when: Vec<String>,
468 pub ready: std::sync::Arc<std::sync::atomic::AtomicBool>,
470 pub ready_check: Option<std::result::Result<readiness::ReadyCheck, String>>,
474 pub ready_inputs: Option<std::sync::Arc<ReadyInputs>>,
477 pub verdict: std::sync::Arc<readiness::Verdict>,
479 pub processes: std::sync::Arc<std::sync::Mutex<std::collections::BTreeSet<u32>>>,
482 pub decorations: Vec<Decoration>,
484 pub interactive: bool,
488 pub pseudo_terminal: bool,
492 pub tty: bool,
495 pub sandbox: Option<Confinement>,
497 pub recording: Option<Recording>,
498 pub recording_keys: bool,
501 pub scratch: Vec<Scratch>,
504 pub display: Display,
506}
507
508fn dotenv_files(
515 project_root: &str,
516 target: &str,
517 configuration: Option<&str>,
518 non_atomized: Option<&str>,
519) -> Vec<String> {
520 let mut identifiers = Vec::new();
521 if let Some(configuration) = configuration {
522 identifiers.push(format!("{target}.{configuration}"));
523 if let Some(non_atomized) = non_atomized {
524 identifiers.push(format!("{non_atomized}.{configuration}"));
525 }
526 identifiers.push(configuration.to_owned());
527 }
528 identifiers.push(target.to_owned());
529 if let Some(non_atomized) = non_atomized {
530 identifiers.push(non_atomized.to_owned());
531 }
532 identifiers.push(String::new());
533 let variants = |identifier: &str, root: &str| -> Vec<String> {
534 let prefix = if root.is_empty() || root == "." {
535 String::new()
536 } else {
537 format!("{root}/")
538 };
539 if identifier.is_empty() {
540 [".env.local", ".local.env", ".env"]
541 .map(|name| format!("{prefix}{name}"))
542 .to_vec()
543 } else {
544 [
545 format!(".env.{identifier}.local"),
546 format!(".env.{identifier}"),
547 format!(".{identifier}.local.env"),
548 format!(".{identifier}.env"),
549 ]
550 .map(|name| format!("{prefix}{name}"))
551 .to_vec()
552 }
553 };
554 let mut files = Vec::new();
555 for root in [project_root, ""] {
556 for identifier in &identifiers {
557 #[cfg(windows)]
559 if identifier.contains(':') {
560 continue;
561 }
562 for file in variants(identifier, root) {
563 if !files.contains(&file) {
564 files.push(file);
565 }
566 }
567 }
568 }
569 files
570}
571
572fn owner_target<'a>(
579 project: &'a qk_config::Project,
580 target: &'a str,
581) -> (&'a str, Option<&'a str>) {
582 let non_atomized = |definition: &'a qk_config::Target| {
583 definition
584 .extra
585 .get("metadata")
586 .and_then(|metadata| metadata.get("nonAtomizedTarget"))
587 .and_then(Value::as_str)
588 };
589 if let Some((name, definition)) = project.targets.get_key_value(target)
590 && let Some(non_atomized) = non_atomized(definition)
591 {
592 return (name, Some(non_atomized));
593 }
594 let groups = project
595 .extra
596 .get("metadata")
597 .and_then(|metadata| metadata.get("targetGroups"))
598 .and_then(Value::as_object);
599 for group in groups.into_iter().flat_map(|groups| groups.values()) {
600 let members: Vec<&str> = group
601 .as_array()
602 .into_iter()
603 .flatten()
604 .filter_map(Value::as_str)
605 .collect();
606 if !members.contains(&target) {
607 continue;
608 }
609 for member in members {
610 if let Some((name, definition)) = project.targets.get_key_value(member)
611 && let Some(non_atomized) = non_atomized(definition)
612 {
613 return (name, Some(non_atomized));
614 }
615 }
616 }
617 (target, None)
618}
619
620pub const INVOCATIONS: &str = "QK_TASK_INVOCATIONS";
622
623pub fn invocation(task: &Task) -> String {
626 let mut text = task.id.clone();
627 for arg in &task.args {
628 text.push('\u{1f}');
629 text.push_str(arg);
630 }
631 text.replace('\n', " ")
632}
633
634pub fn enclosing_invocations() -> Vec<String> {
636 std::env::var(INVOCATIONS)
637 .map(|chain| chain.lines().map(str::to_owned).collect())
638 .unwrap_or_default()
639}
640
641pub const ROOT_DOTENV_FILES: [&str; 3] = [".local.env", ".env.local", ".env"];
644
645fn task_environment(
652 workspace: &Workspace,
653 task: &Task,
654 base_env: &BTreeMap<OsString, OsString>,
655 environment: Option<&mut qk_config::env::Environment>,
656) -> Result<(BTreeMap<OsString, OsString>, BTreeMap<OsString, OsString>)> {
657 let mut env = base_env.clone();
658 let project = &workspace.projects[&task.project];
659 if loads_dotenv(base_env) {
660 for file in [".env", ".local.env", ".env.local"] {
664 for (name, value) in dotenv::read_alone(&workspace.root.join(file))? {
665 if env.get(&name) == Some(&value) {
666 env.remove(&name);
667 }
668 }
669 }
670 }
671 if let Some(environment) = environment {
674 let mut provided = BTreeMap::new();
675 let mut keys = Vec::new();
676 for provider in environment.providers() {
677 let (variables, key) = providers::load(provider, base_env, &workspace.root)
678 .with_context(|| format!("cannot build the environment of {}", task.id))?;
679 provided.extend(variables);
680 keys.push(key);
681 }
682 environment.provide(provided, keys);
683 environment
684 .filter(&mut env)
685 .with_context(|| format!("cannot build the environment of {}", task.id))?;
686 if environment.builds_path() {
687 let path = bins::path(
688 environment,
689 base_env.get(&path_key(base_env)),
690 &workspace.root,
691 &project.root,
692 )
693 .with_context(|| format!("cannot build the PATH of {}", task.id))?;
694 env.insert(path_key(&env), path);
695 }
696 }
697 if loads_dotenv(base_env) {
698 let (target, non_atomized) = owner_target(project, &task.target);
699 let files: Vec<PathBuf> = dotenv_files(
700 &project.root,
701 target,
702 task.configuration.as_deref(),
703 non_atomized,
704 )
705 .into_iter()
706 .map(|file| workspace.root.join(file))
707 .collect();
708 dotenv::load(&files, &mut env)?;
709 }
710 let runtime_env = env.clone();
711 for name in [
713 "NX_SET_CLI",
714 "NX_ORIGINAL_FORCE_COLOR",
715 "NX_TERMINAL_OUTPUT_PATH",
716 "NX_STREAM_OUTPUT",
717 "NX_PREFIX_OUTPUT",
718 ready_signal::SOCKET_ENV,
721 ready_signal::TASK_ENV,
722 ready_signal::URL_ENV,
723 ] {
724 env.remove(std::ffi::OsStr::new(name));
725 }
726 let mut set = |name: &str, value: OsString| {
727 env.insert(name.into(), value);
728 };
729 set("NX_WORKSPACE_ROOT", workspace.root.clone().into_os_string());
730 set("NX_TASK_TARGET_PROJECT", task.project.clone().into());
731 set("NX_TASK_TARGET_TARGET", task.target.clone().into());
732 set("LERNA_PACKAGE_NAME", task.project.clone().into());
733 set("NX_TUI", "false".into());
734 let ancestors = match base_env.get(std::ffi::OsStr::new("NX_INVOCATION_ANCESTOR_PIDS")) {
736 Some(inherited) if !inherited.is_empty() => {
737 format!("{},{}", inherited.to_string_lossy(), std::process::id())
738 }
739 _ => std::process::id().to_string(),
740 };
741 set("NX_INVOCATION_ANCESTOR_PIDS", ancestors.into());
742 let mut chain = base_env
745 .get(std::ffi::OsStr::new(INVOCATIONS))
746 .map(|chain| chain.to_string_lossy().into_owned())
747 .unwrap_or_default();
748 if !chain.is_empty() {
749 chain.push('\n');
750 }
751 chain.push_str(&invocation(task));
752 set(INVOCATIONS, chain.into());
753 if task.target == "test" {
754 set("NX_TERMINAL_CAPTURE_STDERR", "true".into());
755 }
756 match &task.configuration {
757 Some(configuration) => set("NX_TASK_TARGET_CONFIGURATION", configuration.clone().into()),
758 None => {
759 env.remove(std::ffi::OsStr::new("NX_TASK_TARGET_CONFIGURATION"));
760 }
761 }
762 Ok((env, runtime_env))
763}
764
765fn loads_dotenv(env: &BTreeMap<OsString, OsString>) -> bool {
768 env.get(std::ffi::OsStr::new("NX_LOAD_DOT_ENV_FILES"))
769 .is_none_or(|value| value != "false")
770}
771
772pub fn environment(workspace: &Path) -> Result<BTreeMap<OsString, OsString>> {
776 let mut values: BTreeMap<OsString, OsString> = std::env::vars_os().collect();
777 if loads_dotenv(&values) {
778 let files: Vec<PathBuf> = ROOT_DOTENV_FILES
779 .iter()
780 .map(|file| workspace.join(file))
781 .collect();
782 dotenv::load(&files, &mut values)?;
783 }
784 Ok(values)
785}
786
787pub fn prepare(
788 workspace: &Workspace,
789 task: &Task,
790 base_env: &BTreeMap<OsString, OsString>,
791) -> Result<PreparedTask> {
792 let definition = &task.definition;
793 let executor = definition
794 .executor
795 .as_deref()
796 .context("target has no executor or command")?;
797 let project = &workspace.projects[&task.project];
798 let run_commands = executor == "nx:run-commands";
799 if !matches!(executor, "nx:run-commands" | "nx:run-script" | "nx:noop") {
800 bail!("unsupported executor {executor:?}");
801 }
802 let options = if run_commands {
804 run_commands_overrides(&definition.options, &task.args, project)?
805 } else {
806 definition.options.clone()
807 };
808 let options = &options;
809 let empty = Map::new();
810 let mut interpolation = if run_commands {
811 interpolate::Interpolation::new(&task.args, options)?
812 } else {
813 interpolate::Interpolation::new(&[], &empty)?
814 };
815 let mut environment = qk_config::env::Environment::of(&workspace.config, definition)
816 .with_context(|| format!("invalid environment for {}", task.id))?;
817 let (mut env, mut runtime_env) =
818 task_environment(workspace, task, base_env, environment.as_mut())?;
819 if let Some(file) = options.get("envFile").filter(|_| run_commands) {
823 let file = file.as_str().context("envFile must be a string")?;
824 let path = workspace.root.join(file);
825 if loads_dotenv(&runtime_env) {
826 if !path.is_file() {
827 bail!("envFile {file} does not exist");
828 }
829 dotenv::load(std::slice::from_ref(&path), &mut runtime_env)?;
830 dotenv::load(std::slice::from_ref(&path), &mut env)?;
831 }
832 }
833 let option_env = options.get("env").filter(|_| run_commands);
835 for values in [definition.extra.get("env"), option_env]
836 .into_iter()
837 .flatten()
838 {
839 for (name, value) in values.as_object().context("env must be an object")? {
840 if name.is_empty() || name.contains(['=', '\0']) {
841 bail!("invalid environment variable name");
842 }
843 let value: OsString = value
844 .as_str()
845 .context("environment values must be strings")?
846 .into();
847 let name = environment_key(&env, name.into());
848 runtime_env.insert(name.clone(), value.clone());
849 env.insert(name, value);
850 }
851 }
852 interpolation.set_env(&env);
853 let mut execution = BTreeMap::new();
854 execution.insert(
855 "FORCE_COLOR".into(),
856 env.get(std::ffi::OsStr::new("FORCE_COLOR"))
857 .cloned()
858 .unwrap_or_else(|| "true".into()),
859 );
860 if run_commands && boolean(options.get("color"), false, "color")? {
861 execution.insert("FORCE_COLOR".into(), "true".into());
862 }
863 let cwd = match options.get("cwd").filter(|_| run_commands) {
866 Some(cwd) => cwd.as_str().context("cwd must be a string")?,
867 None if run_commands => ".",
868 None => &project.root,
869 };
870 let cwd = workspace.root.join(cwd);
871 let mut resolved = PathBuf::new();
872 for part in cwd.components() {
873 match part {
874 std::path::Component::ParentDir => {
875 resolved.pop();
876 }
877 std::path::Component::CurDir => {}
878 _ => resolved.push(part.as_os_str()),
879 }
880 }
881 let mut paths = Vec::new();
883 for directory in resolved.ancestors() {
884 paths.push(directory.join("node_modules/.bin"));
885 }
886 let node_path = NodePath::new(&workspace.root, base_env, paths.len());
887 let path_key = path_key(&env);
888 if let Some(path) = env.get(&path_key) {
889 paths.extend(std::env::split_paths(path));
890 }
891 let path = std::env::join_paths(paths).context("cannot construct task PATH")?;
892 env.insert(path_key.clone(), path.clone());
893 runtime_env.insert(path_key, path);
894 let ready_when: Vec<String> = match options.get("readyWhen").filter(|_| run_commands) {
895 None | Some(Value::Null) => Vec::new(),
896 Some(Value::String(text)) => vec![text.clone()],
897 Some(value) => serde_json::from_value(value.clone())
898 .context("readyWhen must be a string or an array of strings")?,
899 };
900 let parallel = if run_commands && options.get("command").is_some_and(merge_truthy) {
903 !ready_when.is_empty()
904 } else if run_commands {
905 boolean(options.get("parallel"), true, "parallel")?
906 } else {
907 true
908 };
909 let (mut pseudo_terminal, tty) = if run_commands {
910 (
911 boolean(options.get("usePty"), true, "usePty")?
912 && base_env
913 .get(std::ffi::OsStr::new("NX_NATIVE_COMMAND_RUNNER"))
914 .is_none_or(|value| value != "false"),
915 boolean(options.get("tty"), true, "tty")?,
916 )
917 } else {
918 (true, true)
919 };
920 let mut decorations = Vec::new();
921 if !ready_when.is_empty() && !parallel {
922 bail!("readyWhen can only be used when parallel is true");
923 }
924 let mut commands = Vec::new();
925 match executor {
926 "nx:noop" => {}
927 "nx:run-script" => {
928 let script = options
929 .get("script")
930 .and_then(Value::as_str)
931 .context("run-script requires options.script")?;
932 let package = workspace
933 .packages
934 .get(&task.project)
935 .context("run-script requires a package.json")?;
936 if !package.scripts.contains_key(script) {
937 bail!("package.json has no script {script:?}");
938 }
939 let manager = package_manager(workspace)?;
940 let args = interpolate::quoted_arguments(&task.args)?;
943 let script = interpolate::quote(script)?;
944 let mut command = match manager {
947 "yarn" => format!("yarn {script}"),
948 manager => format!("{manager} run {script}"),
949 };
950 if !args.is_empty() {
951 if matches!(manager, "npm" | "bun") {
952 command.push_str(" --");
953 }
954 command.push(' ');
955 command.push_str(&args);
956 }
957 commands.push(command);
958 }
959 "nx:run-commands" => {
960 let forward = boolean(options.get("forwardAllArgs"), true, "forwardAllArgs")?;
961 if let Some(command) = options
963 .get("command")
964 .filter(|command| merge_truthy(command))
965 {
966 let command = match command {
968 Value::String(command) => command.clone(),
969 Value::Array(words) => words
970 .iter()
971 .map(|word| word.as_str().context("command words must be strings"))
972 .collect::<Result<Vec<_>>>()?
973 .join(" "),
974 _ => bail!("command must be a string or an array of strings"),
975 };
976 commands.push(interpolation.command(&command, forward)?);
977 } else if let Some(values) = options.get("commands") {
978 for value in values.as_array().context("commands must be an array")? {
979 let (command, forward) = if let Some(command) = value.as_str() {
980 (command, forward)
981 } else {
982 let value = value
983 .as_object()
984 .context("commands entries must be strings or objects")?;
985 for key in value.keys() {
986 if !matches!(
987 key.as_str(),
988 "command"
989 | "forwardAllArgs"
990 | "prefix"
991 | "prefixColor"
992 | "color"
993 | "bgColor"
994 | "description"
995 ) {
996 bail!("unsupported command entry field {key:?}");
997 }
998 }
999 let field =
1000 |name: &str, background: Option<bool>| -> Result<Option<String>> {
1001 let Some(value) = value.get(name) else {
1002 return Ok(None);
1003 };
1004 let text = value.as_str().with_context(|| {
1005 format!("command entry {name} must be a string")
1006 })?;
1007 if let Some(background) = background
1008 && !Decoration::valid(text, background)
1009 {
1010 bail!("command entry {name} {text:?} is not a colour");
1011 }
1012 Ok(Some(text.to_owned()))
1013 };
1014 let decoration = Decoration {
1015 prefix: field("prefix", None)?,
1016 prefix_color: field("prefixColor", Some(false))?,
1017 color: field("color", Some(false))?,
1018 bg_color: field("bgColor", Some(true))?,
1019 };
1020 if !decoration.is_empty() && !parallel {
1021 bail!(
1022 "prefix, prefixColor, color and bgColor can only be set when parallel is true"
1023 );
1024 }
1025 decorations.resize(commands.len(), Decoration::default());
1026 decorations.push(decoration);
1027 (
1028 value
1029 .get("command")
1030 .and_then(Value::as_str)
1031 .context("command entry requires a command string")?,
1032 boolean(value.get("forwardAllArgs"), forward, "forwardAllArgs")?,
1033 )
1034 };
1035 commands.push(interpolation.command(command, forward)?);
1036 }
1037 } else {
1038 bail!("run-commands requires command or commands");
1039 }
1040 if commands.iter().any(|command| command.trim().is_empty()) {
1042 bail!("run-commands requires nonempty commands");
1043 }
1044 }
1045 _ => bail!("unsupported executor {executor:?}"),
1046 }
1047 pseudo_terminal &= commands.len() == 1 || !parallel;
1049 let ready_inputs = ready_inputs(workspace, task, options)?.map(std::sync::Arc::new);
1050 let mut prepared = PreparedTask {
1051 id: task.id.clone(),
1052 commands,
1053 parallel,
1054 cwd,
1055 env,
1056 runtime_env,
1057 environment,
1058 node_path: Some(node_path),
1059 execution,
1060 ready_when,
1061 ready: Default::default(),
1062 ready_check: None,
1063 ready_inputs,
1064 verdict: Default::default(),
1065 processes: Default::default(),
1066 decorations,
1067 interactive: false,
1068 pseudo_terminal,
1069 tty,
1070 sandbox: None,
1071 recording: None,
1072 recording_keys: false,
1073 scratch: Vec::new(),
1074 display: Display::default(),
1075 };
1076 prepared.ready_check = prepared.resolve_ready_check(None);
1077 Ok(prepared)
1078}
1079
1080#[derive(Debug)]
1083pub struct ReadyInputs {
1084 config: Value,
1085 workspace_root: String,
1086 project_root: String,
1087 project_name: String,
1088 options: Map<String, Value>,
1089 options_json: String,
1091}
1092
1093fn ready_inputs(
1096 workspace: &Workspace,
1097 task: &Task,
1098 options: &Map<String, Value>,
1099) -> Result<Option<ReadyInputs>> {
1100 let Some(ready_when) = task
1101 .definition
1102 .extra
1103 .get("readyWhen")
1104 .filter(|value| !value.is_null() && task.definition.continuous == Some(true))
1105 else {
1106 return Ok(None);
1107 };
1108 let mut options = options.clone();
1109 let project = &workspace.projects[&task.project];
1110 for (key, value) in interpolate::yargs(&task.args, false, false) {
1111 if key == "_" {
1112 continue;
1113 }
1114 let value = match value {
1116 Value::String(text) => Value::String(qk_config::interpolate_override(&text, project)?),
1117 value => value,
1118 };
1119 if key.contains('-') {
1120 options.insert(interpolate::camel_case(&key), value.clone());
1121 }
1122 options.insert(key, value);
1123 }
1124 Ok(Some(ReadyInputs {
1125 config: ready_when.clone(),
1126 workspace_root: workspace
1127 .root
1128 .to_str()
1129 .context("workspace path must be UTF-8")?
1130 .to_owned(),
1131 project_root: project.root.clone(),
1132 project_name: task.project.clone(),
1133 options_json: serde_json::to_string(&options)?,
1134 options,
1135 }))
1136}
1137
1138impl PreparedTask {
1139 pub fn resolve_ready_check(
1145 &self,
1146 url: Option<&str>,
1147 ) -> Option<std::result::Result<readiness::ReadyCheck, String>> {
1148 let inputs = self.ready_inputs.as_deref()?;
1149 let mut full_env = self.env.clone();
1150 if let Some(url) = url {
1151 full_env.insert(ready_signal::URL_ENV.into(), url.into());
1152 }
1153 let env: BTreeMap<String, String> = full_env
1154 .iter()
1155 .filter_map(|(name, value)| {
1156 Some((name.to_str()?.to_owned(), value.to_str()?.to_owned()))
1157 })
1158 .collect();
1159 let scope = readiness::Scope {
1160 workspace_root: &inputs.workspace_root,
1161 project_root: &inputs.project_root,
1162 project_name: &inputs.project_name,
1163 options: &inputs.options,
1164 env: &env,
1165 };
1166 let mut check = readiness::resolve(&self.id, &inputs.config, &scope);
1167 if let Ok(Some(readiness::ReadyCheck {
1170 probe: readiness::Probe::Command { env: probe_env, .. },
1171 ..
1172 })) = &mut check
1173 {
1174 *probe_env = full_env;
1175 probe_env.insert("NX_TASK_OPTIONS".into(), inputs.options_json.clone().into());
1176 }
1177 check.map_err(|error| format!("{error:#}")).transpose()
1178 }
1179}
1180
1181const OPTION_FLAGS: &[&str] = &[
1183 "command",
1184 "commands",
1185 "color",
1186 "parallel",
1187 "readyWhen",
1188 "cwd",
1189 "args",
1190 "envFile",
1191 "__unparsed__",
1192 "env",
1193 "usePty",
1194 "streamOutput",
1195 "verbose",
1196 "forwardAllArgs",
1197 "tty",
1198];
1199
1200fn run_commands_overrides(
1204 options: &serde_json::Map<String, Value>,
1205 args: &[String],
1206 project: &qk_config::Project,
1207) -> Result<serde_json::Map<String, Value>> {
1208 const BOOLEAN: &[&str] = &[
1209 "color",
1210 "parallel",
1211 "usePty",
1212 "streamOutput",
1213 "verbose",
1214 "forwardAllArgs",
1215 "tty",
1216 ];
1217 let mut options = options.clone();
1218 let mut ready = Vec::new();
1219 let mut args = args.iter().peekable();
1220 while let Some(arg) = args.next() {
1221 let Some(flag) = arg.strip_prefix("--") else {
1222 continue;
1223 };
1224 let (key, value) = match flag.split_once('=') {
1225 Some((key, value)) => (key, Some(value.to_owned())),
1226 None => (flag, None),
1227 };
1228 let (key, negated) = match key.strip_prefix("no-") {
1229 Some(key) if OPTION_FLAGS.contains(&key) => (key, true),
1230 _ => (key, false),
1231 };
1232 let (base, field) = match key.split_once('.') {
1233 Some((base, field)) => (base, Some(field)),
1234 None => (key, None),
1235 };
1236 if !OPTION_FLAGS.contains(&base) {
1237 continue;
1238 }
1239 let boolean = BOOLEAN.contains(&base);
1240 let value = match value {
1241 Some(value) => value,
1242 None if negated => "false".into(),
1243 None if !boolean && args.peek().is_some_and(|next| !next.starts_with('-')) => {
1244 args.next().unwrap().clone()
1245 }
1246 None => "true".into(),
1247 };
1248 let value = if boolean {
1249 Value::Bool(value != "false")
1250 } else {
1251 Value::String(qk_config::interpolate_override(&value, project)?)
1253 };
1254 match (base, field) {
1255 ("env", Some(name)) => {
1256 let env = options
1257 .entry("env")
1258 .or_insert_with(|| Value::Object(Default::default()));
1259 if let Value::Object(env) = env {
1260 env.insert(name.to_owned(), value);
1261 }
1262 }
1263 ("readyWhen", None) => ready.push(value),
1264 ("__unparsed__" | "commands", _) | (_, Some(_)) => {}
1266 (base, None) => {
1267 options.insert(base.to_owned(), value);
1268 }
1269 }
1270 }
1271 if !ready.is_empty() {
1272 let ready = if ready.len() == 1 {
1273 ready.pop().unwrap()
1274 } else {
1275 Value::Array(ready)
1276 };
1277 options.insert("readyWhen".into(), ready);
1278 }
1279 Ok(options)
1280}
1281
1282fn merge_truthy(value: &Value) -> bool {
1284 match value {
1285 Value::Null => false,
1286 Value::Bool(flag) => *flag,
1287 Value::Number(number) => number.as_f64().is_some_and(|number| number != 0.0),
1288 Value::String(text) => !text.is_empty(),
1289 Value::Array(_) | Value::Object(_) => true,
1290 }
1291}
1292
1293fn boolean(value: Option<&Value>, default: bool, name: &str) -> Result<bool> {
1294 value
1295 .map(|value| {
1296 value
1297 .as_bool()
1298 .with_context(|| format!("{name} must be boolean"))
1299 })
1300 .transpose()
1301 .map(|value| value.unwrap_or(default))
1302}
1303
1304fn package_manager(workspace: &Workspace) -> Result<&'static str> {
1305 workspace.package_manager_name()
1306}
1307
1308#[cfg(all(test, windows))]
1309mod dotenv_tests {
1310 #[test]
1311 fn colon_targets_keep_configuration_and_generic_dotenv_files() {
1312 let files = super::dotenv_files("app", "test:unit:watch", Some("ci"), None);
1313 assert!(files.iter().all(|file| !file.contains(':')));
1314 assert!(files.contains(&"app/.env.ci".to_owned()));
1315 assert!(files.contains(&"app/.env".to_owned()));
1316 assert!(files.contains(&".env.ci".to_owned()));
1317 assert!(files.contains(&".env".to_owned()));
1318 }
1319}