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,
216 pub directory: PathBuf,
217 pub seccomp: bool,
220}
221
222impl Recording {
223 pub fn probe(directory: &Path) -> anyhow::Result<Self> {
226 let path = std::env::var_os("PATH").context("recording needs strace on PATH")?;
227 let program = std::env::split_paths(&path)
228 .map(|directory| directory.join("strace"))
229 .find(|candidate| candidate.is_file())
230 .context("recording needs strace on PATH; install strace and retry")?
231 .canonicalize()?;
232 let mut failure = String::new();
233 for seccomp in [true, false] {
234 let recording = Self {
235 program: program.clone(),
236 directory: directory.to_owned(),
237 seccomp,
238 };
239 let probe = recording
240 .command(0, "exec /bin/true")
241 .output()
242 .context("cannot probe strace")?;
243 if probe.status.success() {
244 return Ok(recording);
245 }
246 failure = String::from_utf8_lossy(&probe.stderr).into_owned();
247 }
248 anyhow::bail!("strace cannot record child processes: {failure}")
249 }
250
251 pub fn in_directory(&self, directory: PathBuf) -> Self {
253 Self {
254 directory,
255 ..self.clone()
256 }
257 }
258
259 pub fn command(&self, index: usize, text: &str) -> std::process::Command {
261 let mut command = std::process::Command::new(&self.program);
262 if self.seccomp {
264 command.arg("--seccomp-bpf");
265 }
266 command
267 .args([
268 "-ff",
269 "-ttt",
270 "-yy",
271 "-qq",
272 "-s",
273 "4096",
274 "-e",
275 "trace=%file,%process,getdents64,fchdir,close,dup,dup2,dup3,getcwd",
276 "-e",
277 "raw=execve,execveat",
278 "-o",
279 ])
280 .arg(self.directory.join(format!("command-{index}")))
281 .args(["--", "/bin/sh", "-c", text]);
282 command
283 }
284}
285
286#[derive(Clone, Debug)]
287pub struct PreparedTask {
288 pub id: String,
289 pub commands: Vec<String>,
290 pub parallel: bool,
291 pub cwd: PathBuf,
292 pub env: BTreeMap<OsString, OsString>,
293 pub runtime_env: BTreeMap<OsString, OsString>,
295 pub node_path: Option<NodePath>,
296 pub execution: BTreeMap<OsString, OsString>,
299 pub ready_when: Vec<String>,
302 pub ready: std::sync::Arc<std::sync::atomic::AtomicBool>,
304 pub ready_check: Option<std::result::Result<readiness::ReadyCheck, String>>,
308 pub ready_inputs: Option<std::sync::Arc<ReadyInputs>>,
311 pub verdict: std::sync::Arc<readiness::Verdict>,
313 pub processes: std::sync::Arc<std::sync::Mutex<std::collections::BTreeSet<u32>>>,
316 pub decorations: Vec<Decoration>,
318 pub interactive: bool,
322 pub pseudo_terminal: bool,
326 pub tty: bool,
329 pub sandbox: Option<Confinement>,
331 pub recording: Option<Recording>,
332 pub display: Display,
334}
335
336fn dotenv_files(
343 project_root: &str,
344 target: &str,
345 configuration: Option<&str>,
346 non_atomized: Option<&str>,
347) -> Vec<String> {
348 let mut identifiers = Vec::new();
349 if let Some(configuration) = configuration {
350 identifiers.push(format!("{target}.{configuration}"));
351 if let Some(non_atomized) = non_atomized {
352 identifiers.push(format!("{non_atomized}.{configuration}"));
353 }
354 identifiers.push(configuration.to_owned());
355 }
356 identifiers.push(target.to_owned());
357 if let Some(non_atomized) = non_atomized {
358 identifiers.push(non_atomized.to_owned());
359 }
360 identifiers.push(String::new());
361 let variants = |identifier: &str, root: &str| -> Vec<String> {
362 let prefix = if root.is_empty() || root == "." {
363 String::new()
364 } else {
365 format!("{root}/")
366 };
367 if identifier.is_empty() {
368 [".env.local", ".local.env", ".env"]
369 .map(|name| format!("{prefix}{name}"))
370 .to_vec()
371 } else {
372 [
373 format!(".env.{identifier}.local"),
374 format!(".env.{identifier}"),
375 format!(".{identifier}.local.env"),
376 format!(".{identifier}.env"),
377 ]
378 .map(|name| format!("{prefix}{name}"))
379 .to_vec()
380 }
381 };
382 let mut files = Vec::new();
383 for root in [project_root, ""] {
384 for identifier in &identifiers {
385 #[cfg(windows)]
387 if identifier.contains(':') {
388 continue;
389 }
390 for file in variants(identifier, root) {
391 if !files.contains(&file) {
392 files.push(file);
393 }
394 }
395 }
396 }
397 files
398}
399
400fn owner_target<'a>(
407 project: &'a qk_config::Project,
408 target: &'a str,
409) -> (&'a str, Option<&'a str>) {
410 let non_atomized = |definition: &'a qk_config::Target| {
411 definition
412 .extra
413 .get("metadata")
414 .and_then(|metadata| metadata.get("nonAtomizedTarget"))
415 .and_then(Value::as_str)
416 };
417 if let Some((name, definition)) = project.targets.get_key_value(target)
418 && let Some(non_atomized) = non_atomized(definition)
419 {
420 return (name, Some(non_atomized));
421 }
422 let groups = project
423 .extra
424 .get("metadata")
425 .and_then(|metadata| metadata.get("targetGroups"))
426 .and_then(Value::as_object);
427 for group in groups.into_iter().flat_map(|groups| groups.values()) {
428 let members: Vec<&str> = group
429 .as_array()
430 .into_iter()
431 .flatten()
432 .filter_map(Value::as_str)
433 .collect();
434 if !members.contains(&target) {
435 continue;
436 }
437 for member in members {
438 if let Some((name, definition)) = project.targets.get_key_value(member)
439 && let Some(non_atomized) = non_atomized(definition)
440 {
441 return (name, Some(non_atomized));
442 }
443 }
444 }
445 (target, None)
446}
447
448pub const INVOCATIONS: &str = "QK_TASK_INVOCATIONS";
450
451pub fn invocation(task: &Task) -> String {
454 let mut text = task.id.clone();
455 for arg in &task.args {
456 text.push('\u{1f}');
457 text.push_str(arg);
458 }
459 text.replace('\n', " ")
460}
461
462pub fn enclosing_invocations() -> Vec<String> {
464 std::env::var(INVOCATIONS)
465 .map(|chain| chain.lines().map(str::to_owned).collect())
466 .unwrap_or_default()
467}
468
469pub const ROOT_DOTENV_FILES: [&str; 3] = [".local.env", ".env.local", ".env"];
472
473fn task_environment(
478 workspace: &Workspace,
479 task: &Task,
480 base_env: &BTreeMap<OsString, OsString>,
481) -> Result<(BTreeMap<OsString, OsString>, BTreeMap<OsString, OsString>)> {
482 let mut env = base_env.clone();
483 let project = &workspace.projects[&task.project];
484 if loads_dotenv(base_env) {
485 for file in [".env", ".local.env", ".env.local"] {
489 for (name, value) in dotenv::read_alone(&workspace.root.join(file))? {
490 if env.get(&name) == Some(&value) {
491 env.remove(&name);
492 }
493 }
494 }
495 let (target, non_atomized) = owner_target(project, &task.target);
496 let files: Vec<PathBuf> = dotenv_files(
497 &project.root,
498 target,
499 task.configuration.as_deref(),
500 non_atomized,
501 )
502 .into_iter()
503 .map(|file| workspace.root.join(file))
504 .collect();
505 dotenv::load(&files, &mut env)?;
506 }
507 let runtime_env = env.clone();
508 for name in [
510 "NX_SET_CLI",
511 "NX_ORIGINAL_FORCE_COLOR",
512 "NX_TERMINAL_OUTPUT_PATH",
513 "NX_STREAM_OUTPUT",
514 "NX_PREFIX_OUTPUT",
515 ready_signal::SOCKET_ENV,
518 ready_signal::TASK_ENV,
519 ready_signal::URL_ENV,
520 ] {
521 env.remove(std::ffi::OsStr::new(name));
522 }
523 let mut set = |name: &str, value: OsString| {
524 env.insert(name.into(), value);
525 };
526 set("NX_WORKSPACE_ROOT", workspace.root.clone().into_os_string());
527 set("NX_TASK_TARGET_PROJECT", task.project.clone().into());
528 set("NX_TASK_TARGET_TARGET", task.target.clone().into());
529 set("LERNA_PACKAGE_NAME", task.project.clone().into());
530 set("NX_TUI", "false".into());
531 let ancestors = match base_env.get(std::ffi::OsStr::new("NX_INVOCATION_ANCESTOR_PIDS")) {
533 Some(inherited) if !inherited.is_empty() => {
534 format!("{},{}", inherited.to_string_lossy(), std::process::id())
535 }
536 _ => std::process::id().to_string(),
537 };
538 set("NX_INVOCATION_ANCESTOR_PIDS", ancestors.into());
539 let mut chain = base_env
542 .get(std::ffi::OsStr::new(INVOCATIONS))
543 .map(|chain| chain.to_string_lossy().into_owned())
544 .unwrap_or_default();
545 if !chain.is_empty() {
546 chain.push('\n');
547 }
548 chain.push_str(&invocation(task));
549 set(INVOCATIONS, chain.into());
550 if task.target == "test" {
551 set("NX_TERMINAL_CAPTURE_STDERR", "true".into());
552 }
553 match &task.configuration {
554 Some(configuration) => set("NX_TASK_TARGET_CONFIGURATION", configuration.clone().into()),
555 None => {
556 env.remove(std::ffi::OsStr::new("NX_TASK_TARGET_CONFIGURATION"));
557 }
558 }
559 Ok((env, runtime_env))
560}
561
562fn loads_dotenv(env: &BTreeMap<OsString, OsString>) -> bool {
565 env.get(std::ffi::OsStr::new("NX_LOAD_DOT_ENV_FILES"))
566 .is_none_or(|value| value != "false")
567}
568
569pub fn environment(workspace: &Path) -> Result<BTreeMap<OsString, OsString>> {
573 let mut values: BTreeMap<OsString, OsString> = std::env::vars_os().collect();
574 if loads_dotenv(&values) {
575 let files: Vec<PathBuf> = ROOT_DOTENV_FILES
576 .iter()
577 .map(|file| workspace.join(file))
578 .collect();
579 dotenv::load(&files, &mut values)?;
580 }
581 Ok(values)
582}
583
584pub fn prepare(
585 workspace: &Workspace,
586 task: &Task,
587 base_env: &BTreeMap<OsString, OsString>,
588) -> Result<PreparedTask> {
589 let definition = &task.definition;
590 let executor = definition
591 .executor
592 .as_deref()
593 .context("target has no executor or command")?;
594 let project = &workspace.projects[&task.project];
595 let run_commands = executor == "nx:run-commands";
596 if !matches!(executor, "nx:run-commands" | "nx:run-script" | "nx:noop") {
597 bail!("unsupported executor {executor:?}");
598 }
599 let options = if run_commands {
601 run_commands_overrides(&definition.options, &task.args, project)?
602 } else {
603 definition.options.clone()
604 };
605 let options = &options;
606 let empty = Map::new();
607 let mut interpolation = if run_commands {
608 interpolate::Interpolation::new(&task.args, options)?
609 } else {
610 interpolate::Interpolation::new(&[], &empty)?
611 };
612 let (mut env, mut runtime_env) = task_environment(workspace, task, base_env)?;
613 if let Some(file) = options.get("envFile").filter(|_| run_commands) {
617 let file = file.as_str().context("envFile must be a string")?;
618 let path = workspace.root.join(file);
619 if loads_dotenv(&runtime_env) {
620 if !path.is_file() {
621 bail!("envFile {file} does not exist");
622 }
623 dotenv::load(std::slice::from_ref(&path), &mut runtime_env)?;
624 dotenv::load(std::slice::from_ref(&path), &mut env)?;
625 }
626 }
627 let option_env = options.get("env").filter(|_| run_commands);
629 for values in [definition.extra.get("env"), option_env]
630 .into_iter()
631 .flatten()
632 {
633 for (name, value) in values.as_object().context("env must be an object")? {
634 if name.is_empty() || name.contains(['=', '\0']) {
635 bail!("invalid environment variable name");
636 }
637 let value: OsString = value
638 .as_str()
639 .context("environment values must be strings")?
640 .into();
641 let name = environment_key(&env, name.into());
642 runtime_env.insert(name.clone(), value.clone());
643 env.insert(name, value);
644 }
645 }
646 interpolation.set_env(&env);
647 let mut execution = BTreeMap::new();
648 execution.insert(
649 "FORCE_COLOR".into(),
650 env.get(std::ffi::OsStr::new("FORCE_COLOR"))
651 .cloned()
652 .unwrap_or_else(|| "true".into()),
653 );
654 if run_commands && boolean(options.get("color"), false, "color")? {
655 execution.insert("FORCE_COLOR".into(), "true".into());
656 }
657 let cwd = match options.get("cwd").filter(|_| run_commands) {
660 Some(cwd) => cwd.as_str().context("cwd must be a string")?,
661 None if run_commands => ".",
662 None => &project.root,
663 };
664 let cwd = workspace.root.join(cwd);
665 let mut resolved = PathBuf::new();
666 for part in cwd.components() {
667 match part {
668 std::path::Component::ParentDir => {
669 resolved.pop();
670 }
671 std::path::Component::CurDir => {}
672 _ => resolved.push(part.as_os_str()),
673 }
674 }
675 let mut paths = Vec::new();
677 for directory in resolved.ancestors() {
678 paths.push(directory.join("node_modules/.bin"));
679 }
680 let node_path = NodePath::new(&workspace.root, base_env, paths.len());
681 let path_key = path_key(&env);
682 if let Some(path) = env.get(&path_key) {
683 paths.extend(std::env::split_paths(path));
684 }
685 let path = std::env::join_paths(paths).context("cannot construct task PATH")?;
686 env.insert(path_key.clone(), path.clone());
687 runtime_env.insert(path_key, path);
688 let ready_when: Vec<String> = match options.get("readyWhen").filter(|_| run_commands) {
689 None | Some(Value::Null) => Vec::new(),
690 Some(Value::String(text)) => vec![text.clone()],
691 Some(value) => serde_json::from_value(value.clone())
692 .context("readyWhen must be a string or an array of strings")?,
693 };
694 let parallel = if run_commands && options.get("command").is_some_and(merge_truthy) {
697 !ready_when.is_empty()
698 } else if run_commands {
699 boolean(options.get("parallel"), true, "parallel")?
700 } else {
701 true
702 };
703 let (mut pseudo_terminal, tty) = if run_commands {
704 (
705 boolean(options.get("usePty"), true, "usePty")?
706 && base_env
707 .get(std::ffi::OsStr::new("NX_NATIVE_COMMAND_RUNNER"))
708 .is_none_or(|value| value != "false"),
709 boolean(options.get("tty"), true, "tty")?,
710 )
711 } else {
712 (true, true)
713 };
714 let mut decorations = Vec::new();
715 if !ready_when.is_empty() && !parallel {
716 bail!("readyWhen can only be used when parallel is true");
717 }
718 let mut commands = Vec::new();
719 match executor {
720 "nx:noop" => {}
721 "nx:run-script" => {
722 let script = options
723 .get("script")
724 .and_then(Value::as_str)
725 .context("run-script requires options.script")?;
726 let package = workspace
727 .packages
728 .get(&task.project)
729 .context("run-script requires a package.json")?;
730 if !package.scripts.contains_key(script) {
731 bail!("package.json has no script {script:?}");
732 }
733 let manager = package_manager(workspace)?;
734 let args = interpolate::quoted_arguments(&task.args)?;
737 let script = interpolate::quote(script)?;
738 let mut command = match manager {
741 "yarn" => format!("yarn {script}"),
742 manager => format!("{manager} run {script}"),
743 };
744 if !args.is_empty() {
745 if matches!(manager, "npm" | "bun") {
746 command.push_str(" --");
747 }
748 command.push(' ');
749 command.push_str(&args);
750 }
751 commands.push(command);
752 }
753 "nx:run-commands" => {
754 let forward = boolean(options.get("forwardAllArgs"), true, "forwardAllArgs")?;
755 if let Some(command) = options
757 .get("command")
758 .filter(|command| merge_truthy(command))
759 {
760 let command = match command {
762 Value::String(command) => command.clone(),
763 Value::Array(words) => words
764 .iter()
765 .map(|word| word.as_str().context("command words must be strings"))
766 .collect::<Result<Vec<_>>>()?
767 .join(" "),
768 _ => bail!("command must be a string or an array of strings"),
769 };
770 commands.push(interpolation.command(&command, forward)?);
771 } else if let Some(values) = options.get("commands") {
772 for value in values.as_array().context("commands must be an array")? {
773 let (command, forward) = if let Some(command) = value.as_str() {
774 (command, forward)
775 } else {
776 let value = value
777 .as_object()
778 .context("commands entries must be strings or objects")?;
779 for key in value.keys() {
780 if !matches!(
781 key.as_str(),
782 "command"
783 | "forwardAllArgs"
784 | "prefix"
785 | "prefixColor"
786 | "color"
787 | "bgColor"
788 | "description"
789 ) {
790 bail!("unsupported command entry field {key:?}");
791 }
792 }
793 let field =
794 |name: &str, background: Option<bool>| -> Result<Option<String>> {
795 let Some(value) = value.get(name) else {
796 return Ok(None);
797 };
798 let text = value.as_str().with_context(|| {
799 format!("command entry {name} must be a string")
800 })?;
801 if let Some(background) = background
802 && !Decoration::valid(text, background)
803 {
804 bail!("command entry {name} {text:?} is not a colour");
805 }
806 Ok(Some(text.to_owned()))
807 };
808 let decoration = Decoration {
809 prefix: field("prefix", None)?,
810 prefix_color: field("prefixColor", Some(false))?,
811 color: field("color", Some(false))?,
812 bg_color: field("bgColor", Some(true))?,
813 };
814 if !decoration.is_empty() && !parallel {
815 bail!(
816 "prefix, prefixColor, color and bgColor can only be set when parallel is true"
817 );
818 }
819 decorations.resize(commands.len(), Decoration::default());
820 decorations.push(decoration);
821 (
822 value
823 .get("command")
824 .and_then(Value::as_str)
825 .context("command entry requires a command string")?,
826 boolean(value.get("forwardAllArgs"), forward, "forwardAllArgs")?,
827 )
828 };
829 commands.push(interpolation.command(command, forward)?);
830 }
831 } else {
832 bail!("run-commands requires command or commands");
833 }
834 if commands.iter().any(|command| command.trim().is_empty()) {
836 bail!("run-commands requires nonempty commands");
837 }
838 }
839 _ => bail!("unsupported executor {executor:?}"),
840 }
841 pseudo_terminal &= commands.len() == 1 || !parallel;
843 let ready_inputs = ready_inputs(workspace, task, options)?.map(std::sync::Arc::new);
844 let mut prepared = PreparedTask {
845 id: task.id.clone(),
846 commands,
847 parallel,
848 cwd,
849 env,
850 runtime_env,
851 node_path: Some(node_path),
852 execution,
853 ready_when,
854 ready: Default::default(),
855 ready_check: None,
856 ready_inputs,
857 verdict: Default::default(),
858 processes: Default::default(),
859 decorations,
860 interactive: false,
861 pseudo_terminal,
862 tty,
863 sandbox: None,
864 recording: None,
865 display: Display::default(),
866 };
867 prepared.ready_check = prepared.resolve_ready_check(None);
868 Ok(prepared)
869}
870
871#[derive(Debug)]
874pub struct ReadyInputs {
875 config: Value,
876 workspace_root: String,
877 project_root: String,
878 project_name: String,
879 options: Map<String, Value>,
880 options_json: String,
882}
883
884fn ready_inputs(
887 workspace: &Workspace,
888 task: &Task,
889 options: &Map<String, Value>,
890) -> Result<Option<ReadyInputs>> {
891 let Some(ready_when) = task
892 .definition
893 .extra
894 .get("readyWhen")
895 .filter(|value| !value.is_null() && task.definition.continuous == Some(true))
896 else {
897 return Ok(None);
898 };
899 let mut options = options.clone();
900 let project = &workspace.projects[&task.project];
901 for (key, value) in interpolate::yargs(&task.args, false, false) {
902 if key == "_" {
903 continue;
904 }
905 let value = match value {
907 Value::String(text) => Value::String(qk_config::interpolate_override(&text, project)?),
908 value => value,
909 };
910 if key.contains('-') {
911 options.insert(interpolate::camel_case(&key), value.clone());
912 }
913 options.insert(key, value);
914 }
915 Ok(Some(ReadyInputs {
916 config: ready_when.clone(),
917 workspace_root: workspace
918 .root
919 .to_str()
920 .context("workspace path must be UTF-8")?
921 .to_owned(),
922 project_root: project.root.clone(),
923 project_name: task.project.clone(),
924 options_json: serde_json::to_string(&options)?,
925 options,
926 }))
927}
928
929impl PreparedTask {
930 pub fn resolve_ready_check(
936 &self,
937 url: Option<&str>,
938 ) -> Option<std::result::Result<readiness::ReadyCheck, String>> {
939 let inputs = self.ready_inputs.as_deref()?;
940 let mut full_env = self.env.clone();
941 if let Some(url) = url {
942 full_env.insert(ready_signal::URL_ENV.into(), url.into());
943 }
944 let env: BTreeMap<String, String> = full_env
945 .iter()
946 .filter_map(|(name, value)| {
947 Some((name.to_str()?.to_owned(), value.to_str()?.to_owned()))
948 })
949 .collect();
950 let scope = readiness::Scope {
951 workspace_root: &inputs.workspace_root,
952 project_root: &inputs.project_root,
953 project_name: &inputs.project_name,
954 options: &inputs.options,
955 env: &env,
956 };
957 let mut check = readiness::resolve(&self.id, &inputs.config, &scope);
958 if let Ok(Some(readiness::ReadyCheck {
961 probe: readiness::Probe::Command { env: probe_env, .. },
962 ..
963 })) = &mut check
964 {
965 *probe_env = full_env;
966 probe_env.insert("NX_TASK_OPTIONS".into(), inputs.options_json.clone().into());
967 }
968 check.map_err(|error| format!("{error:#}")).transpose()
969 }
970}
971
972const OPTION_FLAGS: &[&str] = &[
974 "command",
975 "commands",
976 "color",
977 "parallel",
978 "readyWhen",
979 "cwd",
980 "args",
981 "envFile",
982 "__unparsed__",
983 "env",
984 "usePty",
985 "streamOutput",
986 "verbose",
987 "forwardAllArgs",
988 "tty",
989];
990
991fn run_commands_overrides(
995 options: &serde_json::Map<String, Value>,
996 args: &[String],
997 project: &qk_config::Project,
998) -> Result<serde_json::Map<String, Value>> {
999 const BOOLEAN: &[&str] = &[
1000 "color",
1001 "parallel",
1002 "usePty",
1003 "streamOutput",
1004 "verbose",
1005 "forwardAllArgs",
1006 "tty",
1007 ];
1008 let mut options = options.clone();
1009 let mut ready = Vec::new();
1010 let mut args = args.iter().peekable();
1011 while let Some(arg) = args.next() {
1012 let Some(flag) = arg.strip_prefix("--") else {
1013 continue;
1014 };
1015 let (key, value) = match flag.split_once('=') {
1016 Some((key, value)) => (key, Some(value.to_owned())),
1017 None => (flag, None),
1018 };
1019 let (key, negated) = match key.strip_prefix("no-") {
1020 Some(key) if OPTION_FLAGS.contains(&key) => (key, true),
1021 _ => (key, false),
1022 };
1023 let (base, field) = match key.split_once('.') {
1024 Some((base, field)) => (base, Some(field)),
1025 None => (key, None),
1026 };
1027 if !OPTION_FLAGS.contains(&base) {
1028 continue;
1029 }
1030 let boolean = BOOLEAN.contains(&base);
1031 let value = match value {
1032 Some(value) => value,
1033 None if negated => "false".into(),
1034 None if !boolean && args.peek().is_some_and(|next| !next.starts_with('-')) => {
1035 args.next().unwrap().clone()
1036 }
1037 None => "true".into(),
1038 };
1039 let value = if boolean {
1040 Value::Bool(value != "false")
1041 } else {
1042 Value::String(qk_config::interpolate_override(&value, project)?)
1044 };
1045 match (base, field) {
1046 ("env", Some(name)) => {
1047 let env = options
1048 .entry("env")
1049 .or_insert_with(|| Value::Object(Default::default()));
1050 if let Value::Object(env) = env {
1051 env.insert(name.to_owned(), value);
1052 }
1053 }
1054 ("readyWhen", None) => ready.push(value),
1055 ("__unparsed__" | "commands", _) | (_, Some(_)) => {}
1057 (base, None) => {
1058 options.insert(base.to_owned(), value);
1059 }
1060 }
1061 }
1062 if !ready.is_empty() {
1063 let ready = if ready.len() == 1 {
1064 ready.pop().unwrap()
1065 } else {
1066 Value::Array(ready)
1067 };
1068 options.insert("readyWhen".into(), ready);
1069 }
1070 Ok(options)
1071}
1072
1073fn merge_truthy(value: &Value) -> bool {
1075 match value {
1076 Value::Null => false,
1077 Value::Bool(flag) => *flag,
1078 Value::Number(number) => number.as_f64().is_some_and(|number| number != 0.0),
1079 Value::String(text) => !text.is_empty(),
1080 Value::Array(_) | Value::Object(_) => true,
1081 }
1082}
1083
1084fn boolean(value: Option<&Value>, default: bool, name: &str) -> Result<bool> {
1085 value
1086 .map(|value| {
1087 value
1088 .as_bool()
1089 .with_context(|| format!("{name} must be boolean"))
1090 })
1091 .transpose()
1092 .map(|value| value.unwrap_or(default))
1093}
1094
1095fn package_manager(workspace: &Workspace) -> Result<&'static str> {
1096 workspace.package_manager_name()
1097}
1098
1099#[cfg(all(test, windows))]
1100mod dotenv_tests {
1101 #[test]
1102 fn colon_targets_keep_configuration_and_generic_dotenv_files() {
1103 let files = super::dotenv_files("app", "test:unit:watch", Some("ci"), None);
1104 assert!(files.iter().all(|file| !file.contains(':')));
1105 assert!(files.contains(&"app/.env.ci".to_owned()));
1106 assert!(files.contains(&"app/.env".to_owned()));
1107 assert!(files.contains(&".env.ci".to_owned()));
1108 assert!(files.contains(&".env".to_owned()));
1109 }
1110}