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