1mod bins;
4mod capture;
5pub mod dotenv;
6mod env_flags;
7mod interpolate;
8pub mod landlock;
9#[cfg(target_os = "linux")]
10pub mod overlay;
11mod process;
12mod providers;
13#[cfg(unix)]
14mod pty;
15pub mod readiness;
16pub mod ready_signal;
17mod recording;
18pub mod report;
19pub mod tokens;
20
21use std::collections::BTreeMap;
22use std::ffi::OsString;
23use std::path::{Path, PathBuf};
24
25use anyhow::{Context, Result, bail};
26use qk_config::Workspace;
27use qk_taskgraph::Task;
28use serde_json::{Map, Value};
29
30pub use capture::{
31 Capture, Decoration, Display, MAX_LOG_BYTES, OutputStyle, RecordedLog, Shown, collapsed,
32 printable, read_capture, replay,
33};
34pub use env_flags::{force_colour, is_ci, is_ci_with};
35pub use process::{Outcome, execute, execute_captured, shell};
36pub use recording::{Choice, Probed, Recording};
37
38#[derive(Debug)]
39struct NodeRuntime {
40 root: PathBuf,
41 env: BTreeMap<OsString, OsString>,
42 paths: Vec<PathBuf>,
43 directory: std::sync::OnceLock<Option<PathBuf>>,
44}
45
46#[derive(Clone, Debug)]
48pub struct NodePath {
49 runtime: std::sync::Arc<NodeRuntime>,
50 local_entries: usize,
51}
52
53impl NodePath {
54 fn new(root: &Path, env: &BTreeMap<OsString, OsString>, local_entries: usize) -> Self {
55 type Key = (PathBuf, BTreeMap<OsString, OsString>);
56 static RUNTIMES: std::sync::OnceLock<
57 std::sync::Mutex<BTreeMap<Key, std::sync::Arc<NodeRuntime>>>,
58 > = std::sync::OnceLock::new();
59 let mut runtimes = RUNTIMES.get_or_init(Default::default).lock().unwrap();
60 let runtime = runtimes
61 .entry((root.to_owned(), env.clone()))
62 .or_insert_with(|| {
63 let paths = env
64 .get(&path_key(env))
65 .map(|path| std::env::split_paths(path).collect())
66 .unwrap_or_default();
67 std::sync::Arc::new(NodeRuntime {
68 root: root.to_owned(),
69 env: env.clone(),
70 paths,
71 directory: Default::default(),
72 })
73 })
74 .clone();
75 Self {
76 runtime,
77 local_entries,
78 }
79 }
80
81 fn path(&self, env: &BTreeMap<OsString, OsString>) -> Result<Option<(OsString, OsString)>> {
82 let Some(directory) = self.runtime.directory.get_or_init(|| {
85 node_directory(&self.runtime.root, &self.runtime.paths, &self.runtime.env)
86 }) else {
87 return Ok(None);
88 };
89 let key = path_key(env);
90 let mut paths: Vec<_> = env
91 .get(&key)
92 .map(|path| std::env::split_paths(path).collect())
93 .unwrap_or_default();
94 paths.insert(self.local_entries.min(paths.len()), directory.clone());
95 Ok(Some((
96 key,
97 std::env::join_paths(paths).context("cannot construct task PATH")?,
98 )))
99 }
100}
101
102fn path_key(env: &BTreeMap<OsString, OsString>) -> OsString {
103 if cfg!(windows) {
104 env.keys()
105 .find(|key| key.to_string_lossy().eq_ignore_ascii_case("PATH"))
106 .cloned()
107 .unwrap_or_else(|| "PATH".into())
108 } else {
109 "PATH".into()
110 }
111}
112
113fn environment_key(env: &BTreeMap<OsString, OsString>, name: OsString) -> OsString {
114 if cfg!(windows) && name.to_string_lossy().eq_ignore_ascii_case("PATH") {
115 path_key(env)
116 } else {
117 name
118 }
119}
120
121fn node_directory(
122 cwd: &Path,
123 paths: &[PathBuf],
124 env: &BTreeMap<OsString, OsString>,
125) -> Option<PathBuf> {
126 let name = if cfg!(windows) { "node.exe" } else { "node" };
127 for directory in paths {
128 let candidate = cwd.join(directory).join(name);
129 let Ok(metadata) = candidate.metadata() else {
130 continue;
131 };
132 if !metadata.is_file() {
133 continue;
134 }
135 #[cfg(unix)]
136 {
137 use std::os::unix::fs::PermissionsExt;
138 if metadata.permissions().mode() & 0o111 == 0 {
139 continue;
140 }
141 }
142 let actual = candidate.canonicalize().ok()?;
144 if actual.file_name()? == name {
145 use std::io::Read;
146 let mut header = [0; 2];
147 let script = std::fs::File::open(&actual)
148 .and_then(|mut file| file.read_exact(&mut header))
149 .is_ok()
150 && header == *b"#!";
151 if !script {
152 return actual.parent().map(Path::to_owned);
153 }
154 }
155 return probe_node(&candidate, cwd, env);
156 }
157 None
158}
159
160fn probe_node(candidate: &Path, cwd: &Path, env: &BTreeMap<OsString, OsString>) -> Option<PathBuf> {
161 use std::io::{Read, Seek};
162 use std::process::{Command, Stdio};
163 use std::time::{Duration, Instant};
164 let mut output = tempfile::tempfile().ok()?;
165 let mut child = Command::new(candidate)
166 .args(["-p", "process.execPath"])
167 .current_dir(cwd)
168 .env_clear()
169 .envs(env)
170 .stdout(Stdio::from(output.try_clone().ok()?))
171 .stderr(Stdio::null())
172 .spawn()
173 .ok()?;
174 let started = Instant::now();
175 loop {
176 match child.try_wait() {
177 Ok(Some(status)) => {
178 if !status.success() {
179 return None;
180 }
181 let mut bytes = Vec::new();
182 output.rewind().ok()?;
183 output.take(4096).read_to_end(&mut bytes).ok()?;
184 let path = PathBuf::from(std::str::from_utf8(&bytes).ok()?.trim());
185 return (path.is_absolute() && path.is_file())
186 .then(|| path.parent().unwrap().to_owned());
187 }
188 Ok(None) if started.elapsed() < Duration::from_secs(2) => {
189 std::thread::sleep(Duration::from_millis(5))
190 }
191 _ => {
192 let _ = child.kill();
193 let _ = child.wait();
194 return None;
195 }
196 }
197 }
198}
199
200#[macro_export]
203macro_rules! status {
204 ($($arg:tt)*) => {{
205 $crate::report::warning(&::std::format!($($arg)*));
206 }};
207}
208
209#[derive(Clone, Debug)]
211pub enum Confinement {
212 Seatbelt(PathBuf),
214 Landlock(i32),
217 Refused(String),
220 Overlay(std::sync::Arc<Overlay>),
224}
225
226#[derive(Debug)]
229pub struct Overlay {
230 pub workspace: PathBuf,
232 pub directory: PathBuf,
234 pub passthrough: Vec<PathBuf>,
237 pub rules: OverlayRules,
240 pub keep: Keep,
243 pub settled: std::sync::OnceLock<Settled>,
245}
246
247#[derive(Clone, Debug, Default)]
249pub struct Keep {
250 pub paths: Vec<PathBuf>,
252 pub names: Vec<std::ffi::OsString>,
255}
256
257impl Keep {
258 pub fn allows(&self, root: &std::path::Path, target: &std::path::Path) -> bool {
260 self.paths.iter().any(|path| target.starts_with(path))
261 || target.strip_prefix(root).is_ok_and(|relative| {
262 relative
263 .components()
264 .any(|part| self.names.iter().any(|name| part.as_os_str() == name))
265 })
266 }
267
268 pub fn holds(&self, target: &std::path::Path) -> bool {
272 self.paths.iter().any(|path| path.starts_with(target))
273 }
274}
275
276#[derive(Debug, Default)]
278pub struct Settled {
279 pub discarded: std::collections::BTreeSet<String>,
281 pub failed: Vec<String>,
284 pub made: Vec<PathBuf>,
288}
289
290impl Overlay {
291 pub fn settle(&self) -> &Settled {
295 self.settled.get_or_init(|| {
296 #[cfg(target_os = "linux")]
297 {
298 overlay::settle(self)
299 }
300 #[cfg(not(target_os = "linux"))]
301 {
302 Settled::default()
303 }
304 })
305 }
306}
307
308#[derive(Debug, Default)]
312pub struct OverlayRules {
313 pub handled: landlock::Rights,
315 #[cfg(unix)]
318 pub outside: Vec<(std::os::fd::OwnedFd, landlock::Rights)>,
319 pub workspace: landlock::Rights,
322 pub inside: Vec<(PathBuf, landlock::Rights, landlock::Open)>,
325}
326
327#[derive(Clone, Debug)]
330pub struct Scratch {
331 pub root: PathBuf,
332 pub directory: PathBuf,
333}
334
335#[derive(Clone, Debug)]
336pub struct PreparedTask {
337 pub id: String,
338 pub commands: Vec<String>,
339 pub parallel: bool,
340 pub cwd: PathBuf,
341 pub env: BTreeMap<OsString, OsString>,
342 pub runtime_env: BTreeMap<OsString, OsString>,
344 pub dotenv: Vec<PathBuf>,
347 pub node_path: Option<NodePath>,
348 pub environment: Option<qk_config::env::Environment>,
351 pub execution: BTreeMap<OsString, OsString>,
354 pub ready_when: Vec<String>,
357 pub ready: std::sync::Arc<std::sync::atomic::AtomicBool>,
359 pub ready_check: Option<std::result::Result<readiness::ReadyCheck, String>>,
363 pub ready_inputs: Option<std::sync::Arc<ReadyInputs>>,
366 pub verdict: std::sync::Arc<readiness::Verdict>,
368 pub processes: std::sync::Arc<std::sync::Mutex<std::collections::BTreeSet<u32>>>,
371 pub decorations: Vec<Decoration>,
373 pub interactive: bool,
377 pub pseudo_terminal: bool,
381 pub tty: bool,
384 pub sandbox: Option<Confinement>,
386 pub recording: Option<Recording>,
387 pub scratch: Vec<Scratch>,
390 pub display: Display,
392}
393
394fn dotenv_files(
401 project_root: &str,
402 target: &str,
403 configuration: Option<&str>,
404 non_atomized: Option<&str>,
405) -> Vec<String> {
406 let mut identifiers = Vec::new();
407 if let Some(configuration) = configuration {
408 identifiers.push(format!("{target}.{configuration}"));
409 if let Some(non_atomized) = non_atomized {
410 identifiers.push(format!("{non_atomized}.{configuration}"));
411 }
412 identifiers.push(configuration.to_owned());
413 }
414 identifiers.push(target.to_owned());
415 if let Some(non_atomized) = non_atomized {
416 identifiers.push(non_atomized.to_owned());
417 }
418 identifiers.push(String::new());
419 let variants = |identifier: &str, root: &str| -> Vec<String> {
420 let prefix = if root.is_empty() || root == "." {
421 String::new()
422 } else {
423 format!("{root}/")
424 };
425 if identifier.is_empty() {
426 [".env.local", ".local.env", ".env"]
427 .map(|name| format!("{prefix}{name}"))
428 .to_vec()
429 } else {
430 [
431 format!(".env.{identifier}.local"),
432 format!(".env.{identifier}"),
433 format!(".{identifier}.local.env"),
434 format!(".{identifier}.env"),
435 ]
436 .map(|name| format!("{prefix}{name}"))
437 .to_vec()
438 }
439 };
440 let mut files = Vec::new();
441 for root in [project_root, ""] {
442 for identifier in &identifiers {
443 #[cfg(windows)]
445 if identifier.contains(':') {
446 continue;
447 }
448 for file in variants(identifier, root) {
449 if !files.contains(&file) {
450 files.push(file);
451 }
452 }
453 }
454 }
455 files
456}
457
458fn owner_target<'a>(
465 project: &'a qk_config::Project,
466 target: &'a str,
467) -> (&'a str, Option<&'a str>) {
468 let non_atomized = |definition: &'a qk_config::Target| {
469 definition
470 .extra
471 .get("metadata")
472 .and_then(|metadata| metadata.get("nonAtomizedTarget"))
473 .and_then(Value::as_str)
474 };
475 if let Some((name, definition)) = project.targets.get_key_value(target)
476 && let Some(non_atomized) = non_atomized(definition)
477 {
478 return (name, Some(non_atomized));
479 }
480 let groups = project
481 .extra
482 .get("metadata")
483 .and_then(|metadata| metadata.get("targetGroups"))
484 .and_then(Value::as_object);
485 for group in groups.into_iter().flat_map(|groups| groups.values()) {
486 let members: Vec<&str> = group
487 .as_array()
488 .into_iter()
489 .flatten()
490 .filter_map(Value::as_str)
491 .collect();
492 if !members.contains(&target) {
493 continue;
494 }
495 for member in members {
496 if let Some((name, definition)) = project.targets.get_key_value(member)
497 && let Some(non_atomized) = non_atomized(definition)
498 {
499 return (name, Some(non_atomized));
500 }
501 }
502 }
503 (target, None)
504}
505
506pub const INVOCATIONS: &str = "QK_TASK_INVOCATIONS";
508
509pub fn invocation(task: &Task) -> String {
512 let mut text = task.id.clone();
513 for arg in &task.args {
514 text.push('\u{1f}');
515 text.push_str(arg);
516 }
517 text.replace('\n', " ")
518}
519
520pub fn enclosing_invocations() -> Vec<String> {
522 std::env::var(INVOCATIONS)
523 .map(|chain| chain.lines().map(str::to_owned).collect())
524 .unwrap_or_default()
525}
526
527pub const ROOT_DOTENV_FILES: [&str; 3] = [".local.env", ".env.local", ".env"];
530
531fn task_environment(
538 workspace: &Workspace,
539 task: &Task,
540 base_env: &BTreeMap<OsString, OsString>,
541 environment: Option<&mut qk_config::env::Environment>,
542 loaded: &mut Vec<PathBuf>,
543) -> Result<(BTreeMap<OsString, OsString>, BTreeMap<OsString, OsString>)> {
544 let mut env = base_env.clone();
545 let project = &workspace.projects[&task.project];
546 if loads_dotenv(base_env) {
547 for file in [".env", ".local.env", ".env.local"] {
551 for (name, value) in dotenv::read_alone(&workspace.root.join(file))? {
552 if env.get(&name) == Some(&value) {
553 env.remove(&name);
554 }
555 }
556 }
557 }
558 if let Some(environment) = environment {
561 let mut provided = BTreeMap::new();
562 let mut keys = Vec::new();
563 for provider in environment.providers() {
564 let (variables, key) = providers::load(provider, base_env, &workspace.root)
565 .with_context(|| format!("cannot build the environment of {}", task.id))?;
566 provided.extend(variables);
567 keys.push(key);
568 }
569 environment.provide(provided, keys);
570 environment
571 .filter(&mut env)
572 .with_context(|| format!("cannot build the environment of {}", task.id))?;
573 if environment.builds_path() {
574 let path = bins::path(
575 environment,
576 base_env.get(&path_key(base_env)),
577 &workspace.root,
578 &project.root,
579 )
580 .with_context(|| format!("cannot build the PATH of {}", task.id))?;
581 env.insert(path_key(&env), path);
582 }
583 }
584 if loads_dotenv(base_env) {
585 let (target, non_atomized) = owner_target(project, &task.target);
586 let files: Vec<PathBuf> = dotenv_files(
587 &project.root,
588 target,
589 task.configuration.as_deref(),
590 non_atomized,
591 )
592 .into_iter()
593 .map(|file| workspace.root.join(file))
594 .collect();
595 loaded.extend(dotenv::load(&files, &mut env)?);
596 }
597 let runtime_env = env.clone();
598 for name in [
600 "NX_SET_CLI",
601 "NX_ORIGINAL_FORCE_COLOR",
602 "NX_TERMINAL_OUTPUT_PATH",
603 "NX_STREAM_OUTPUT",
604 "NX_PREFIX_OUTPUT",
605 ready_signal::SOCKET_ENV,
608 ready_signal::TASK_ENV,
609 ready_signal::URL_ENV,
610 ] {
611 env.remove(std::ffi::OsStr::new(name));
612 }
613 let mut set = |name: &str, value: OsString| {
614 env.insert(name.into(), value);
615 };
616 set("NX_WORKSPACE_ROOT", workspace.root.clone().into_os_string());
617 set("NX_TASK_TARGET_PROJECT", task.project.clone().into());
618 set("NX_TASK_TARGET_TARGET", task.target.clone().into());
619 set("LERNA_PACKAGE_NAME", task.project.clone().into());
620 set("NX_TUI", "false".into());
621 let ancestors = match base_env.get(std::ffi::OsStr::new("NX_INVOCATION_ANCESTOR_PIDS")) {
623 Some(inherited) if !inherited.is_empty() => {
624 format!("{},{}", inherited.to_string_lossy(), std::process::id())
625 }
626 _ => std::process::id().to_string(),
627 };
628 set("NX_INVOCATION_ANCESTOR_PIDS", ancestors.into());
629 let mut chain = base_env
632 .get(std::ffi::OsStr::new(INVOCATIONS))
633 .map(|chain| chain.to_string_lossy().into_owned())
634 .unwrap_or_default();
635 if !chain.is_empty() {
636 chain.push('\n');
637 }
638 chain.push_str(&invocation(task));
639 set(INVOCATIONS, chain.into());
640 if task.target == "test" {
641 set("NX_TERMINAL_CAPTURE_STDERR", "true".into());
642 }
643 match &task.configuration {
644 Some(configuration) => set("NX_TASK_TARGET_CONFIGURATION", configuration.clone().into()),
645 None => {
646 env.remove(std::ffi::OsStr::new("NX_TASK_TARGET_CONFIGURATION"));
647 }
648 }
649 Ok((env, runtime_env))
650}
651
652fn loads_dotenv(env: &BTreeMap<OsString, OsString>) -> bool {
655 env.get(std::ffi::OsStr::new("NX_LOAD_DOT_ENV_FILES"))
656 .is_none_or(|value| value != "false")
657}
658
659pub fn environment(workspace: &Path) -> Result<BTreeMap<OsString, OsString>> {
663 let mut values: BTreeMap<OsString, OsString> = std::env::vars_os().collect();
664 if loads_dotenv(&values) {
665 let files: Vec<PathBuf> = ROOT_DOTENV_FILES
666 .iter()
667 .map(|file| workspace.join(file))
668 .collect();
669 dotenv::load(&files, &mut values)?;
670 }
671 Ok(values)
672}
673
674pub fn prepare(
675 workspace: &Workspace,
676 task: &Task,
677 base_env: &BTreeMap<OsString, OsString>,
678) -> Result<PreparedTask> {
679 let definition = &task.definition;
680 let executor = definition
681 .executor
682 .as_deref()
683 .context("target has no executor or command")?;
684 let project = &workspace.projects[&task.project];
685 let run_commands = executor == "nx:run-commands";
686 if !matches!(executor, "nx:run-commands" | "nx:run-script" | "nx:noop") {
687 bail!("unsupported executor {executor:?}");
688 }
689 let options = if run_commands {
691 run_commands_overrides(&definition.options, &task.args, project)?
692 } else {
693 definition.options.clone()
694 };
695 let options = &options;
696 let empty = Map::new();
697 let mut interpolation = if run_commands {
698 interpolate::Interpolation::new(&task.args, options)?
699 } else {
700 interpolate::Interpolation::new(&[], &empty)?
701 };
702 let mut environment = qk_config::env::Environment::of(&workspace.config, definition)
703 .with_context(|| format!("invalid environment for {}", task.id))?;
704 let mut dotenv = Vec::new();
705 let (mut env, mut runtime_env) =
706 task_environment(workspace, task, base_env, environment.as_mut(), &mut dotenv)?;
707 if let Some(file) = options.get("envFile").filter(|_| run_commands) {
711 let file = file.as_str().context("envFile must be a string")?;
712 let path = workspace.root.join(file);
713 if loads_dotenv(&runtime_env) {
714 if !path.is_file() {
715 bail!("envFile {file} does not exist");
716 }
717 dotenv::load(std::slice::from_ref(&path), &mut runtime_env)?;
718 dotenv.extend(dotenv::load(std::slice::from_ref(&path), &mut env)?);
719 }
720 }
721 if let Some(values) = options.get("env").filter(|_| run_commands) {
722 for (name, value) in values.as_object().context("env must be an object")? {
723 if name.is_empty() || name.contains(['=', '\0']) {
724 bail!("invalid environment variable name");
725 }
726 let value: OsString = value
727 .as_str()
728 .context("environment values must be strings")?
729 .into();
730 let name = environment_key(&env, name.into());
731 runtime_env.insert(name.clone(), value.clone());
732 env.insert(name, value);
733 }
734 }
735 interpolation.set_env(&env);
736 let mut execution = BTreeMap::new();
737 execution.insert(
738 "FORCE_COLOR".into(),
739 env.get(std::ffi::OsStr::new("FORCE_COLOR"))
740 .cloned()
741 .unwrap_or_else(|| "true".into()),
742 );
743 if run_commands && boolean(options.get("color"), false, "color")? {
744 execution.insert("FORCE_COLOR".into(), "true".into());
745 }
746 let cwd = match options.get("cwd").filter(|_| run_commands) {
749 Some(cwd) => cwd.as_str().context("cwd must be a string")?,
750 None if run_commands => ".",
751 None => &project.root,
752 };
753 let cwd = workspace.root.join(cwd);
754 let mut resolved = PathBuf::new();
755 for part in cwd.components() {
756 match part {
757 std::path::Component::ParentDir => {
758 resolved.pop();
759 }
760 std::path::Component::CurDir => {}
761 _ => resolved.push(part.as_os_str()),
762 }
763 }
764 let mut paths = Vec::new();
766 for directory in resolved.ancestors() {
767 paths.push(directory.join("node_modules/.bin"));
768 }
769 let node_path = NodePath::new(&workspace.root, base_env, paths.len());
770 let path_key = path_key(&env);
771 if let Some(path) = env.get(&path_key) {
772 paths.extend(std::env::split_paths(path));
773 }
774 let path = std::env::join_paths(paths).context("cannot construct task PATH")?;
775 env.insert(path_key.clone(), path.clone());
776 runtime_env.insert(path_key, path);
777 let ready_when: Vec<String> = match options.get("readyWhen").filter(|_| run_commands) {
778 None | Some(Value::Null) => Vec::new(),
779 Some(Value::String(text)) => vec![text.clone()],
780 Some(value) => serde_json::from_value(value.clone())
781 .context("readyWhen must be a string or an array of strings")?,
782 };
783 let parallel = if run_commands && options.get("command").is_some_and(merge_truthy) {
786 !ready_when.is_empty()
787 } else if run_commands {
788 boolean(options.get("parallel"), true, "parallel")?
789 } else {
790 true
791 };
792 let (mut pseudo_terminal, tty) = if run_commands {
793 (
794 boolean(options.get("usePty"), true, "usePty")?
795 && base_env
796 .get(std::ffi::OsStr::new("NX_NATIVE_COMMAND_RUNNER"))
797 .is_none_or(|value| value != "false"),
798 boolean(options.get("tty"), true, "tty")?,
799 )
800 } else {
801 (true, true)
802 };
803 let mut decorations = Vec::new();
804 if !ready_when.is_empty() && !parallel {
805 bail!("readyWhen can only be used when parallel is true");
806 }
807 let mut commands = Vec::new();
808 match executor {
809 "nx:noop" => {}
810 "nx:run-script" => {
811 let script = options
812 .get("script")
813 .and_then(Value::as_str)
814 .context("run-script requires options.script")?;
815 let package = workspace
816 .packages
817 .get(&task.project)
818 .context("run-script requires a package.json")?;
819 if !package.scripts.contains_key(script) {
820 bail!("package.json has no script {script:?}");
821 }
822 let manager = package_manager(workspace)?;
823 let args = interpolate::quoted_arguments(&task.args)?;
826 let script = interpolate::quote(script)?;
827 let mut command = match manager {
830 "yarn" => format!("yarn {script}"),
831 manager => format!("{manager} run {script}"),
832 };
833 if !args.is_empty() {
834 if matches!(manager, "npm" | "bun") {
835 command.push_str(" --");
836 }
837 command.push(' ');
838 command.push_str(&args);
839 }
840 commands.push(command);
841 }
842 "nx:run-commands" => {
843 let forward = boolean(options.get("forwardAllArgs"), true, "forwardAllArgs")?;
844 if let Some(command) = options
846 .get("command")
847 .filter(|command| merge_truthy(command))
848 {
849 let command = match command {
851 Value::String(command) => command.clone(),
852 Value::Array(words) => words
853 .iter()
854 .map(|word| word.as_str().context("command words must be strings"))
855 .collect::<Result<Vec<_>>>()?
856 .join(" "),
857 _ => bail!("command must be a string or an array of strings"),
858 };
859 commands.push(interpolation.command(&command, forward)?);
860 } else if let Some(values) = options.get("commands") {
861 for value in values.as_array().context("commands must be an array")? {
862 let (command, forward) = if let Some(command) = value.as_str() {
863 (command, forward)
864 } else {
865 let value = value
866 .as_object()
867 .context("commands entries must be strings or objects")?;
868 for key in value.keys() {
869 if !matches!(
870 key.as_str(),
871 "command"
872 | "forwardAllArgs"
873 | "prefix"
874 | "prefixColor"
875 | "color"
876 | "bgColor"
877 | "description"
878 ) {
879 bail!("unsupported command entry field {key:?}");
880 }
881 }
882 let field =
883 |name: &str, background: Option<bool>| -> Result<Option<String>> {
884 let Some(value) = value.get(name) else {
885 return Ok(None);
886 };
887 let text = value.as_str().with_context(|| {
888 format!("command entry {name} must be a string")
889 })?;
890 if let Some(background) = background
891 && !Decoration::valid(text, background)
892 {
893 bail!("command entry {name} {text:?} is not a colour");
894 }
895 Ok(Some(text.to_owned()))
896 };
897 let decoration = Decoration {
898 prefix: field("prefix", None)?,
899 prefix_color: field("prefixColor", Some(false))?,
900 color: field("color", Some(false))?,
901 bg_color: field("bgColor", Some(true))?,
902 };
903 if !decoration.is_empty() && !parallel {
904 bail!(
905 "prefix, prefixColor, color and bgColor can only be set when parallel is true"
906 );
907 }
908 decorations.resize(commands.len(), Decoration::default());
909 decorations.push(decoration);
910 (
911 value
912 .get("command")
913 .and_then(Value::as_str)
914 .context("command entry requires a command string")?,
915 boolean(value.get("forwardAllArgs"), forward, "forwardAllArgs")?,
916 )
917 };
918 commands.push(interpolation.command(command, forward)?);
919 }
920 } else {
921 bail!("run-commands requires command or commands");
922 }
923 if commands.iter().any(|command| command.trim().is_empty()) {
925 bail!("run-commands requires nonempty commands");
926 }
927 }
928 _ => bail!("unsupported executor {executor:?}"),
929 }
930 pseudo_terminal &= commands.len() == 1 || !parallel;
932 let ready_inputs = ready_inputs(workspace, task, options)?.map(std::sync::Arc::new);
933 let mut prepared = PreparedTask {
934 id: task.id.clone(),
935 commands,
936 parallel,
937 cwd,
938 env,
939 dotenv,
940 runtime_env,
941 environment,
942 node_path: Some(node_path),
943 execution,
944 ready_when,
945 ready: Default::default(),
946 ready_check: None,
947 ready_inputs,
948 verdict: Default::default(),
949 processes: Default::default(),
950 decorations,
951 interactive: false,
952 pseudo_terminal,
953 tty,
954 sandbox: None,
955 recording: None,
956 scratch: Vec::new(),
957 display: Display::default(),
958 };
959 prepared.ready_check = prepared.resolve_ready_check(None);
960 Ok(prepared)
961}
962
963#[derive(Debug)]
966pub struct ReadyInputs {
967 config: Value,
968 workspace_root: String,
969 project_root: String,
970 project_name: String,
971 options: Map<String, Value>,
972 options_json: String,
974}
975
976fn ready_inputs(
979 workspace: &Workspace,
980 task: &Task,
981 options: &Map<String, Value>,
982) -> Result<Option<ReadyInputs>> {
983 let Some(ready_when) = task
984 .definition
985 .extra
986 .get("readyWhen")
987 .filter(|value| !value.is_null() && task.definition.continuous == Some(true))
988 else {
989 return Ok(None);
990 };
991 let mut options = options.clone();
992 let project = &workspace.projects[&task.project];
993 for (key, value) in interpolate::yargs(&task.args, false, false) {
994 if key == "_" {
995 continue;
996 }
997 let value = match value {
999 Value::String(text) => Value::String(qk_config::interpolate_override(&text, project)?),
1000 value => value,
1001 };
1002 if key.contains('-') {
1003 options.insert(interpolate::camel_case(&key), value.clone());
1004 }
1005 options.insert(key, value);
1006 }
1007 Ok(Some(ReadyInputs {
1008 config: ready_when.clone(),
1009 workspace_root: workspace
1010 .root
1011 .to_str()
1012 .context("workspace path must be UTF-8")?
1013 .to_owned(),
1014 project_root: project.root.clone(),
1015 project_name: task.project.clone(),
1016 options_json: serde_json::to_string(&options)?,
1017 options,
1018 }))
1019}
1020
1021impl PreparedTask {
1022 pub fn resolve_ready_check(
1028 &self,
1029 url: Option<&str>,
1030 ) -> Option<std::result::Result<readiness::ReadyCheck, String>> {
1031 let inputs = self.ready_inputs.as_deref()?;
1032 let mut full_env = self.env.clone();
1033 if let Some(url) = url {
1034 full_env.insert(ready_signal::URL_ENV.into(), url.into());
1035 }
1036 let env: BTreeMap<String, String> = full_env
1037 .iter()
1038 .filter_map(|(name, value)| {
1039 Some((name.to_str()?.to_owned(), value.to_str()?.to_owned()))
1040 })
1041 .collect();
1042 let scope = readiness::Scope {
1043 workspace_root: &inputs.workspace_root,
1044 project_root: &inputs.project_root,
1045 project_name: &inputs.project_name,
1046 options: &inputs.options,
1047 env: &env,
1048 };
1049 let mut check = readiness::resolve(&self.id, &inputs.config, &scope);
1050 if let Ok(Some(readiness::ReadyCheck {
1053 probe: readiness::Probe::Command { env: probe_env, .. },
1054 ..
1055 })) = &mut check
1056 {
1057 *probe_env = full_env;
1058 probe_env.insert("NX_TASK_OPTIONS".into(), inputs.options_json.clone().into());
1059 }
1060 check.map_err(|error| format!("{error:#}")).transpose()
1061 }
1062}
1063
1064const OPTION_FLAGS: &[&str] = &[
1066 "command",
1067 "commands",
1068 "color",
1069 "parallel",
1070 "readyWhen",
1071 "cwd",
1072 "args",
1073 "envFile",
1074 "__unparsed__",
1075 "env",
1076 "usePty",
1077 "streamOutput",
1078 "verbose",
1079 "forwardAllArgs",
1080 "tty",
1081];
1082
1083fn run_commands_overrides(
1087 options: &serde_json::Map<String, Value>,
1088 args: &[String],
1089 project: &qk_config::Project,
1090) -> Result<serde_json::Map<String, Value>> {
1091 const BOOLEAN: &[&str] = &[
1092 "color",
1093 "parallel",
1094 "usePty",
1095 "streamOutput",
1096 "verbose",
1097 "forwardAllArgs",
1098 "tty",
1099 ];
1100 let mut options = options.clone();
1101 let mut ready = Vec::new();
1102 let mut args = args.iter().peekable();
1103 while let Some(arg) = args.next() {
1104 let Some(flag) = arg.strip_prefix("--") else {
1105 continue;
1106 };
1107 let (key, value) = match flag.split_once('=') {
1108 Some((key, value)) => (key, Some(value.to_owned())),
1109 None => (flag, None),
1110 };
1111 let (key, negated) = match key.strip_prefix("no-") {
1112 Some(key) if OPTION_FLAGS.contains(&key) => (key, true),
1113 _ => (key, false),
1114 };
1115 let (base, field) = match key.split_once('.') {
1116 Some((base, field)) => (base, Some(field)),
1117 None => (key, None),
1118 };
1119 if !OPTION_FLAGS.contains(&base) {
1120 continue;
1121 }
1122 let boolean = BOOLEAN.contains(&base);
1123 let value = match value {
1124 Some(value) => value,
1125 None if negated => "false".into(),
1126 None if !boolean && args.peek().is_some_and(|next| !next.starts_with('-')) => {
1127 args.next().unwrap().clone()
1128 }
1129 None => "true".into(),
1130 };
1131 let value = if boolean {
1132 Value::Bool(value != "false")
1133 } else {
1134 Value::String(qk_config::interpolate_override(&value, project)?)
1136 };
1137 match (base, field) {
1138 ("env", Some(name)) => {
1139 let env = options
1140 .entry("env")
1141 .or_insert_with(|| Value::Object(Default::default()));
1142 if let Value::Object(env) = env {
1143 env.insert(name.to_owned(), value);
1144 }
1145 }
1146 ("readyWhen", None) => ready.push(value),
1147 ("__unparsed__" | "commands", _) | (_, Some(_)) => {}
1149 (base, None) => {
1150 options.insert(base.to_owned(), value);
1151 }
1152 }
1153 }
1154 if !ready.is_empty() {
1155 let ready = if ready.len() == 1 {
1156 ready.pop().unwrap()
1157 } else {
1158 Value::Array(ready)
1159 };
1160 options.insert("readyWhen".into(), ready);
1161 }
1162 Ok(options)
1163}
1164
1165fn merge_truthy(value: &Value) -> bool {
1167 match value {
1168 Value::Null => false,
1169 Value::Bool(flag) => *flag,
1170 Value::Number(number) => number.as_f64().is_some_and(|number| number != 0.0),
1171 Value::String(text) => !text.is_empty(),
1172 Value::Array(_) | Value::Object(_) => true,
1173 }
1174}
1175
1176fn boolean(value: Option<&Value>, default: bool, name: &str) -> Result<bool> {
1177 value
1178 .map(|value| {
1179 value
1180 .as_bool()
1181 .with_context(|| format!("{name} must be boolean"))
1182 })
1183 .transpose()
1184 .map(|value| value.unwrap_or(default))
1185}
1186
1187fn package_manager(workspace: &Workspace) -> Result<&'static str> {
1188 workspace.package_manager_name()
1189}
1190
1191#[cfg(all(test, windows))]
1192mod dotenv_tests {
1193 #[test]
1194 fn colon_targets_keep_configuration_and_generic_dotenv_files() {
1195 let files = super::dotenv_files("app", "test:unit:watch", Some("ci"), None);
1196 assert!(files.iter().all(|file| !file.contains(':')));
1197 assert!(files.contains(&"app/.env.ci".to_owned()));
1198 assert!(files.contains(&"app/.env".to_owned()));
1199 assert!(files.contains(&".env.ci".to_owned()));
1200 assert!(files.contains(&".env".to_owned()));
1201 }
1202}
1203
1204#[cfg(test)]
1205mod loaded_tests {
1206 use super::*;
1207
1208 #[test]
1212 fn lists_the_dotenv_files_it_loaded() {
1213 let temp = tempfile::tempdir().unwrap();
1214 let root = temp.path();
1215 let absolute = root.join("config/absolute.env");
1216 std::fs::write(root.join("nx.json"), "{}").unwrap();
1217 std::fs::write(
1218 root.join("project.json"),
1219 serde_json::json!({"name": "app", "targets": {
1220 "plain": {"command": "true", "options": {"envFile": "config/variables.txt"}},
1221 "absolute": {"command": "true", "options": {"envFile": absolute}},
1222 "off": {"command": "true", "options": {"envFile": "config/variables.txt"}}
1223 }})
1224 .to_string(),
1225 )
1226 .unwrap();
1227 std::fs::create_dir_all(root.join("config")).unwrap();
1228 std::fs::write(root.join("config/variables.txt"), "A=1\n").unwrap();
1229 std::fs::write(&absolute, "B=1\n").unwrap();
1230 std::fs::write(root.join(".env.off"), "NX_LOAD_DOT_ENV_FILES=false\n").unwrap();
1231 std::fs::write(root.join(".env"), "C=1\n").unwrap();
1232 let workspace = Workspace::load(root).unwrap();
1233 let requests = ["app:plain", "app:absolute", "app:off"]
1234 .map(|request| qk_taskgraph::Request::parse(request).unwrap());
1235 let graph = qk_taskgraph::TaskGraph::build(&workspace, &requests).unwrap();
1236 let loaded = |id: &str| {
1237 prepare(&workspace, &graph.tasks[id], &BTreeMap::new())
1238 .unwrap()
1239 .dotenv
1240 };
1241 let plain = loaded("app:plain");
1242 assert!(plain.contains(&workspace.root.join(".env")), "{plain:?}");
1243 assert!(
1245 !plain.contains(&workspace.root.join(".env.local")),
1246 "{plain:?}"
1247 );
1248 assert_eq!(
1249 plain.last(),
1250 Some(&workspace.root.join("config/variables.txt"))
1251 );
1252 assert_eq!(loaded("app:absolute").last(), Some(&absolute));
1253 let off = loaded("app:off");
1254 assert!(off.contains(&workspace.root.join(".env.off")), "{off:?}");
1255 assert!(
1256 !off.contains(&workspace.root.join("config/variables.txt")),
1257 "{off:?}"
1258 );
1259 }
1260}