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