Skip to main content

qk_executor/
lib.rs

1//! Prepare finite tasks, then execute shell commands in managed process groups.
2
3mod bins;
4mod capture;
5pub mod dotenv;
6mod env_flags;
7mod interpolate;
8mod process;
9mod providers;
10#[cfg(unix)]
11mod pty;
12pub mod readiness;
13pub mod ready_signal;
14pub mod report;
15pub mod tokens;
16
17use std::collections::BTreeMap;
18use std::ffi::OsString;
19use std::path::{Path, PathBuf};
20
21use anyhow::{Context, Result, bail};
22use qk_config::Workspace;
23use qk_taskgraph::Task;
24use serde_json::{Map, Value};
25
26pub use capture::{
27    Capture, Decoration, Display, MAX_LOG_BYTES, OutputStyle, RecordedLog, Shown, collapsed,
28    printable, read_capture, replay,
29};
30pub use env_flags::{force_colour, is_ci, is_ci_with};
31pub use process::{Outcome, execute, execute_captured, shell};
32
33#[derive(Debug)]
34struct NodeRuntime {
35    root: PathBuf,
36    env: BTreeMap<OsString, OsString>,
37    paths: Vec<PathBuf>,
38    directory: std::sync::OnceLock<Option<PathBuf>>,
39}
40
41/// The runner's Node selection, resolved only when a command starts.
42#[derive(Clone, Debug)]
43pub struct NodePath {
44    runtime: std::sync::Arc<NodeRuntime>,
45    local_entries: usize,
46}
47
48impl NodePath {
49    fn new(root: &Path, env: &BTreeMap<OsString, OsString>, local_entries: usize) -> Self {
50        type Key = (PathBuf, BTreeMap<OsString, OsString>);
51        static RUNTIMES: std::sync::OnceLock<
52            std::sync::Mutex<BTreeMap<Key, std::sync::Arc<NodeRuntime>>>,
53        > = std::sync::OnceLock::new();
54        let mut runtimes = RUNTIMES.get_or_init(Default::default).lock().unwrap();
55        let runtime = runtimes
56            .entry((root.to_owned(), env.clone()))
57            .or_insert_with(|| {
58                let paths = env
59                    .get(&path_key(env))
60                    .map(|path| std::env::split_paths(path).collect())
61                    .unwrap_or_default();
62                std::sync::Arc::new(NodeRuntime {
63                    root: root.to_owned(),
64                    env: env.clone(),
65                    paths,
66                    directory: Default::default(),
67                })
68            })
69            .clone();
70        Self {
71            runtime,
72            local_entries,
73        }
74    }
75
76    fn path(&self, env: &BTreeMap<OsString, OsString>) -> Result<Option<(OsString, OsString)>> {
77        // Select before task overrides, like process.execPath in Nx. Cache hits
78        // never call this, and concurrent commands share the same proxy probe.
79        let Some(directory) = self.runtime.directory.get_or_init(|| {
80            node_directory(&self.runtime.root, &self.runtime.paths, &self.runtime.env)
81        }) else {
82            return Ok(None);
83        };
84        let key = path_key(env);
85        let mut paths: Vec<_> = env
86            .get(&key)
87            .map(|path| std::env::split_paths(path).collect())
88            .unwrap_or_default();
89        paths.insert(self.local_entries.min(paths.len()), directory.clone());
90        Ok(Some((
91            key,
92            std::env::join_paths(paths).context("cannot construct task PATH")?,
93        )))
94    }
95}
96
97fn path_key(env: &BTreeMap<OsString, OsString>) -> OsString {
98    if cfg!(windows) {
99        env.keys()
100            .find(|key| key.to_string_lossy().eq_ignore_ascii_case("PATH"))
101            .cloned()
102            .unwrap_or_else(|| "PATH".into())
103    } else {
104        "PATH".into()
105    }
106}
107
108fn environment_key(env: &BTreeMap<OsString, OsString>, name: OsString) -> OsString {
109    if cfg!(windows) && name.to_string_lossy().eq_ignore_ascii_case("PATH") {
110        path_key(env)
111    } else {
112        name
113    }
114}
115
116fn node_directory(
117    cwd: &Path,
118    paths: &[PathBuf],
119    env: &BTreeMap<OsString, OsString>,
120) -> Option<PathBuf> {
121    let name = if cfg!(windows) { "node.exe" } else { "node" };
122    for directory in paths {
123        let candidate = cwd.join(directory).join(name);
124        let Ok(metadata) = candidate.metadata() else {
125            continue;
126        };
127        if !metadata.is_file() {
128            continue;
129        }
130        #[cfg(unix)]
131        {
132            use std::os::unix::fs::PermissionsExt;
133            if metadata.permissions().mode() & 0o111 == 0 {
134                continue;
135            }
136        }
137        // The first executable PATH match is authoritative, even if discovery fails.
138        let actual = candidate.canonicalize().ok()?;
139        if actual.file_name()? == name {
140            use std::io::Read;
141            let mut header = [0; 2];
142            let script = std::fs::File::open(&actual)
143                .and_then(|mut file| file.read_exact(&mut header))
144                .is_ok()
145                && header == *b"#!";
146            if !script {
147                return actual.parent().map(Path::to_owned);
148            }
149        }
150        return probe_node(&candidate, cwd, env);
151    }
152    None
153}
154
155fn probe_node(candidate: &Path, cwd: &Path, env: &BTreeMap<OsString, OsString>) -> Option<PathBuf> {
156    use std::io::{Read, Seek};
157    use std::process::{Command, Stdio};
158    use std::time::{Duration, Instant};
159    let mut output = tempfile::tempfile().ok()?;
160    let mut child = Command::new(candidate)
161        .args(["-p", "process.execPath"])
162        .current_dir(cwd)
163        .env_clear()
164        .envs(env)
165        .stdout(Stdio::from(output.try_clone().ok()?))
166        .stderr(Stdio::null())
167        .spawn()
168        .ok()?;
169    let started = Instant::now();
170    loop {
171        match child.try_wait() {
172            Ok(Some(status)) => {
173                if !status.success() {
174                    return None;
175                }
176                let mut bytes = Vec::new();
177                output.rewind().ok()?;
178                output.take(4096).read_to_end(&mut bytes).ok()?;
179                let path = PathBuf::from(std::str::from_utf8(&bytes).ok()?.trim());
180                return (path.is_absolute() && path.is_file())
181                    .then(|| path.parent().unwrap().to_owned());
182            }
183            Ok(None) if started.elapsed() < Duration::from_secs(2) => {
184                std::thread::sleep(Duration::from_millis(5))
185            }
186            _ => {
187                let _ = child.kill();
188                let _ = child.wait();
189                return None;
190            }
191        }
192    }
193}
194
195/// Reports a `qk:` warning to the installed sink; by default it is written to
196/// stderr in one write, so task processes sharing stderr cannot split it.
197#[macro_export]
198macro_rules! status {
199    ($($arg:tt)*) => {{
200        $crate::report::warning(&::std::format!($($arg)*));
201    }};
202}
203
204/// How a sandboxed task's commands are held to what it declares.
205#[derive(Clone, Debug)]
206pub enum Confinement {
207    /// macOS: a Seatbelt profile, through `sandbox-exec`.
208    Seatbelt(PathBuf),
209    /// Linux: a Landlock rule set's descriptor, which each command restricts
210    /// itself to before it starts. It stays open while the run lasts.
211    Landlock(i32),
212    /// Enforced, and could not be held to its plan, for this reason: its
213    /// commands fail rather than start unconfined.
214    Refused(String),
215}
216
217/// An external syscall recorder wrapping each command, with separate trace files.
218#[derive(Clone, Debug)]
219pub struct Recording {
220    /// The recorder's program: strace, or qk itself, whose helper loads the
221    /// eBPF recorder.
222    pub program: PathBuf,
223    pub directory: PathBuf,
224    /// Whether strace filters the traced calls in the kernel, with seccomp,
225    /// so untraced calls run at full speed. strace 5.3 and later can.
226    pub seccomp: bool,
227    /// The eBPF recorder, and the recordings of this task's commands it has
228    /// begun; `None` when strace records.
229    #[cfg(target_os = "linux")]
230    ebpf: Option<Ebpf>,
231}
232
233#[cfg(target_os = "linux")]
234#[derive(Clone, Debug)]
235struct Ebpf {
236    recorder: std::sync::Arc<qk_trace::Recorder>,
237    sessions: std::sync::Arc<std::sync::Mutex<Vec<qk_trace::Session>>>,
238    /// Why settling failed, kept so that a later settle, as the cache's and
239    /// then an audit's of one recording, does not find it whole.
240    failure: std::sync::Arc<std::sync::Mutex<Option<String>>>,
241    /// Whether an opened file's path is recorded with what its links lead
242    /// to, as strace records it, which costs a lookup an open.
243    resolve: bool,
244}
245
246/// The eBPF recorder, its helper started once a process: as qk itself when
247/// qk runs as root, else through `sudo -n`, so that it is never asked for a
248/// password.
249#[cfg(target_os = "linux")]
250fn ebpf_recorder() -> Result<std::sync::Arc<qk_trace::Recorder>, String> {
251    static RECORDER: std::sync::OnceLock<Result<std::sync::Arc<qk_trace::Recorder>, String>> =
252        std::sync::OnceLock::new();
253    RECORDER
254        .get_or_init(|| {
255            let qk = std::env::current_exe().map_err(|error| error.to_string())?;
256            // SAFETY: geteuid has no preconditions.
257            let helper = if unsafe { libc::geteuid() } == 0 {
258                let mut helper = std::process::Command::new(qk);
259                helper.arg("__trace-helper");
260                helper
261            } else {
262                let mut helper = std::process::Command::new("sudo");
263                helper.args(["-n", "--"]).arg(qk).arg("__trace-helper");
264                helper
265            };
266            qk_trace::Recorder::start(helper).map_err(|error| format!("{error:#}"))
267        })
268        .clone()
269}
270
271impl Recording {
272    /// The recorder `QK_RECORDER` picks: by default eBPF, where its helper
273    /// can load it, else strace on `PATH`, checked to record child
274    /// processes, with seccomp filtering where it supports it. Its trial
275    /// traces go in `directory`.
276    pub fn probe(directory: &Path) -> anyhow::Result<Self> {
277        let choice = std::env::var("QK_RECORDER").unwrap_or_default();
278        match choice.as_str() {
279            "" | "auto" | "ebpf" =>
280            {
281                #[cfg(target_os = "linux")]
282                match ebpf_recorder() {
283                    Ok(recorder) => {
284                        return Ok(Self {
285                            program: std::env::current_exe()?,
286                            directory: directory.to_owned(),
287                            seccomp: false,
288                            ebpf: Some(Ebpf {
289                                recorder,
290                                sessions: Default::default(),
291                                failure: Default::default(),
292                                resolve: false,
293                            }),
294                        });
295                    }
296                    Err(error) if choice == "ebpf" => {
297                        anyhow::bail!("cannot record with eBPF: {error}")
298                    }
299                    Err(_) => {}
300                }
301            }
302            "strace" => {}
303            other => anyhow::bail!("unknown QK_RECORDER {other:?}; use auto, ebpf or strace"),
304        }
305        let path = std::env::var_os("PATH").context("recording needs strace on PATH")?;
306        let program = std::env::split_paths(&path)
307            .map(|directory| directory.join("strace"))
308            .find(|candidate| candidate.is_file())
309            .context("recording needs strace on PATH; install strace and retry")?
310            .canonicalize()?;
311        let mut failure = String::new();
312        for seccomp in [true, false] {
313            let recording = Self {
314                program: program.clone(),
315                directory: directory.to_owned(),
316                seccomp,
317                #[cfg(target_os = "linux")]
318                ebpf: None,
319            };
320            let probe = recording
321                .command(0, "exec /bin/true", Path::new("/"))
322                .output()
323                .context("cannot probe strace")?;
324            if probe.status.success() {
325                return Ok(recording);
326            }
327            failure = String::from_utf8_lossy(&probe.stderr).into_owned();
328        }
329        anyhow::bail!("strace cannot record child processes: {failure}")
330    }
331
332    /// The same settings, with traces in `directory`.
333    /// The recorder's name, as reports give it: `ebpf` or `strace`.
334    pub fn backend(&self) -> &'static str {
335        #[cfg(target_os = "linux")]
336        if self.ebpf.is_some() {
337            return "ebpf";
338        }
339        "strace"
340    }
341
342    pub fn in_directory(&self, directory: PathBuf) -> Self {
343        Self {
344            directory,
345            #[cfg(target_os = "linux")]
346            ebpf: self.ebpf.as_ref().map(|ebpf| Ebpf {
347                recorder: ebpf.recorder.clone(),
348                sessions: Default::default(),
349                failure: Default::default(),
350                resolve: ebpf.resolve,
351            }),
352            ..self.clone()
353        }
354    }
355
356    /// The same, recording an opened file's path with what its links lead
357    /// to, as a report of what a task read shows it. strace always does.
358    pub fn resolving(self) -> Self {
359        #[cfg(target_os = "linux")]
360        {
361            let mut recording = self;
362            if let Some(ebpf) = &mut recording.ebpf {
363                ebpf.resolve = true;
364            }
365            recording
366        }
367        #[cfg(not(target_os = "linux"))]
368        self
369    }
370
371    /// Ends the recordings of the commands that have run, once they have
372    /// finished, so that their traces are whole. strace's are as soon as it
373    /// exits; the eBPF recorder's once its helper has sent every event.
374    pub fn settle(&self) -> anyhow::Result<()> {
375        #[cfg(target_os = "linux")]
376        if let Some(ebpf) = &self.ebpf {
377            if let Some(failure) = ebpf.failure.lock().unwrap().as_ref() {
378                anyhow::bail!("{failure}");
379            }
380            let sessions = std::mem::take(&mut *ebpf.sessions.lock().unwrap());
381            let mut sessions = sessions.into_iter();
382            while let Some(session) = sessions.next() {
383                if let Err(error) = ebpf.recorder.finish(session) {
384                    *ebpf.failure.lock().unwrap() = Some(format!("{error:#}"));
385                    // The rest without waiting again: a helper that did not
386                    // answer once will not now.
387                    sessions
388                        .by_ref()
389                        .for_each(|rest| ebpf.recorder.abandon(rest));
390                    return Err(error);
391                }
392            }
393        }
394        Ok(())
395    }
396
397    /// The same invocation is used by preflight and actual task commands,
398    /// which start in `cwd`.
399    pub fn command(&self, index: usize, text: &str, cwd: &Path) -> std::process::Command {
400        #[cfg(target_os = "linux")]
401        if let Some(ebpf) = &self.ebpf {
402            use std::os::unix::process::CommandExt;
403            let session = ebpf.recorder.begin(
404                &self.directory,
405                &format!("command-{index}"),
406                cwd,
407                ebpf.resolve,
408            );
409            ebpf.sessions.lock().unwrap().push(session);
410            let mut command = crate::process::shell(text);
411            // SAFETY: the hook makes one system call.
412            unsafe { command.pre_exec(ebpf.recorder.join(session)) };
413            return command;
414        }
415        let _ = cwd;
416        let mut command = std::process::Command::new(&self.program);
417        // No read/write buffers or exec argument/environment arrays.
418        if self.seccomp {
419            command.arg("--seccomp-bpf");
420        }
421        command
422            .args([
423                "-ff",
424                "-ttt",
425                "-yy",
426                "-qq",
427                "-s",
428                "4096",
429                "-e",
430                "trace=%file,%process,getdents64,fchdir,close,dup,dup2,dup3,getcwd",
431                "-e",
432                "raw=execve,execveat",
433                "-o",
434            ])
435            .arg(self.directory.join(format!("command-{index}")))
436            .args(["--", "/bin/sh", "-c", text]);
437        command
438    }
439}
440
441/// A scratch directory, below the workspace `root` it lies in, which the
442/// task keeps nothing in between runs.
443#[derive(Clone, Debug)]
444pub struct Scratch {
445    pub root: PathBuf,
446    pub directory: PathBuf,
447}
448
449#[derive(Clone, Debug)]
450pub struct PreparedTask {
451    pub id: String,
452    pub commands: Vec<String>,
453    pub parallel: bool,
454    pub cwd: PathBuf,
455    pub env: BTreeMap<OsString, OsString>,
456    /// Dotenv, explicit settings and local binary lookup without task metadata.
457    pub runtime_env: BTreeMap<OsString, OsString>,
458    pub node_path: Option<NodePath>,
459    /// The environment the task declares, `qk:env`, which its key holds;
460    /// `None` where it inherits every variable.
461    pub environment: Option<qk_config::env::Environment>,
462    /// Variables set for the process after `env` that are not part of what
463    /// the task is: nothing reads them to key it, such as a thread count.
464    pub execution: BTreeMap<OsString, OsString>,
465    /// Nx's `readyWhen`: text whose appearance in the output, every piece of
466    /// it, makes the task ready while its commands keep running.
467    pub ready_when: Vec<String>,
468    /// Set once all of `ready_when` has appeared.
469    pub ready: std::sync::Arc<std::sync::atomic::AtomicBool>,
470    /// A continuous target's own `readyWhen`, which tasks depending on it
471    /// with `waitFor: "ready"` wait for, or why it is invalid. Prepared, it is
472    /// resolved without `NX_READY_URL`; see [`PreparedTask::resolve_ready_check`].
473    pub ready_check: Option<std::result::Result<readiness::ReadyCheck, String>>,
474    /// What `ready_check` is resolved from, to resolve it again once the
475    /// servers the task waits for have reported their address.
476    pub ready_inputs: Option<std::sync::Arc<ReadyInputs>>,
477    /// What `ready_check` concluded.
478    pub verdict: std::sync::Arc<readiness::Verdict>,
479    /// The process ids of its commands while they run, from which the runner
480    /// measures the memory the task uses.
481    pub processes: std::sync::Arc<std::sync::Mutex<std::collections::BTreeSet<u32>>>,
482    /// Each command's prefix and colours, by position; empty without any.
483    pub decorations: Vec<Decoration>,
484    /// Whether the task reads qk's stdin, and has the terminal while it runs.
485    /// The runner sets it for the one task a run is for, when that task has a
486    /// single command whose output goes straight to the terminal.
487    pub interactive: bool,
488    /// Whether captured commands may run under a pseudo-terminal, which they
489    /// do when qk's output is a terminal: Nx's `usePty`. Commands whose
490    /// output is hashed set it to false, so keys never depend on a terminal.
491    pub pseudo_terminal: bool,
492    /// Nx's `tty`: whether qk's terminal is in raw input mode while the
493    /// interactive task reads it through a pseudo-terminal.
494    pub tty: bool,
495    /// What the commands run under, for `--sandbox`.
496    pub sandbox: Option<Confinement>,
497    pub recording: Option<Recording>,
498    /// Whether `recording` may key the task's result for `qk:ultracache`, as
499    /// `--record` gives it. A recording made only to audit the task does not.
500    pub recording_keys: bool,
501    /// Directories emptied before its commands run, which it keeps nothing
502    /// in between runs: its target's `qk:sandbox.scratch`.
503    pub scratch: Vec<Scratch>,
504    /// How the task's output is shown; the runner sets it per output style.
505    pub display: Display,
506}
507
508/// The dotenv files Nx loads for a task, most specific first: in the
509/// project root, then the workspace root, for the target and configuration
510/// (`.env.<target>.<configuration>`, `.env.<configuration>`, `.env.<target>`),
511/// then `.env.local`, `.local.env` and `.env`, each also in its `.local` and
512/// `.<name>.env` spellings. An atomized target also reads the files of the
513/// target it was split from, after its own of the same kind.
514fn dotenv_files(
515    project_root: &str,
516    target: &str,
517    configuration: Option<&str>,
518    non_atomized: Option<&str>,
519) -> Vec<String> {
520    let mut identifiers = Vec::new();
521    if let Some(configuration) = configuration {
522        identifiers.push(format!("{target}.{configuration}"));
523        if let Some(non_atomized) = non_atomized {
524            identifiers.push(format!("{non_atomized}.{configuration}"));
525        }
526        identifiers.push(configuration.to_owned());
527    }
528    identifiers.push(target.to_owned());
529    if let Some(non_atomized) = non_atomized {
530        identifiers.push(non_atomized.to_owned());
531    }
532    identifiers.push(String::new());
533    let variants = |identifier: &str, root: &str| -> Vec<String> {
534        let prefix = if root.is_empty() || root == "." {
535            String::new()
536        } else {
537            format!("{root}/")
538        };
539        if identifier.is_empty() {
540            [".env.local", ".local.env", ".env"]
541                .map(|name| format!("{prefix}{name}"))
542                .to_vec()
543        } else {
544            [
545                format!(".env.{identifier}.local"),
546                format!(".env.{identifier}"),
547                format!(".{identifier}.local.env"),
548                format!(".{identifier}.env"),
549            ]
550            .map(|name| format!("{prefix}{name}"))
551            .to_vec()
552        }
553    };
554    let mut files = Vec::new();
555    for root in [project_root, ""] {
556        for identifier in &identifiers {
557            // Colons in target names cannot name regular dotenv files on Windows.
558            #[cfg(windows)]
559            if identifier.contains(':') {
560                continue;
561            }
562            for file in variants(identifier, root) {
563                if !files.contains(&file) {
564                    files.push(file);
565                }
566            }
567        }
568    }
569    files
570}
571
572/// The target whose dotenv files a task reads, and the target it was
573/// atomized from, as Nx's getOwnerTargetForTask finds them: through the
574/// project's `metadata.targetGroups`, a target of the task's group that
575/// names a `metadata.nonAtomizedTarget`. Unlike Nx, a task that names one
576/// itself is its own owner, so each atomized target reads its own files
577/// rather than the first of its group's.
578fn owner_target<'a>(
579    project: &'a qk_config::Project,
580    target: &'a str,
581) -> (&'a str, Option<&'a str>) {
582    let non_atomized = |definition: &'a qk_config::Target| {
583        definition
584            .extra
585            .get("metadata")
586            .and_then(|metadata| metadata.get("nonAtomizedTarget"))
587            .and_then(Value::as_str)
588    };
589    if let Some((name, definition)) = project.targets.get_key_value(target)
590        && let Some(non_atomized) = non_atomized(definition)
591    {
592        return (name, Some(non_atomized));
593    }
594    let groups = project
595        .extra
596        .get("metadata")
597        .and_then(|metadata| metadata.get("targetGroups"))
598        .and_then(Value::as_object);
599    for group in groups.into_iter().flat_map(|groups| groups.values()) {
600        let members: Vec<&str> = group
601            .as_array()
602            .into_iter()
603            .flatten()
604            .filter_map(Value::as_str)
605            .collect();
606        if !members.contains(&target) {
607            continue;
608        }
609        for member in members {
610            if let Some((name, definition)) = project.targets.get_key_value(member)
611                && let Some(non_atomized) = non_atomized(definition)
612            {
613                return (name, Some(non_atomized));
614            }
615        }
616    }
617    (target, None)
618}
619
620/// The variable naming the tasks a task runs inside, one invocation a line.
621pub const INVOCATIONS: &str = "QK_TASK_INVOCATIONS";
622
623/// A task as an invocation: its id and arguments, which a task running inside
624/// it repeats only by recursing, as Nx's task invocation tracker tells it.
625pub fn invocation(task: &Task) -> String {
626    let mut text = task.id.clone();
627    for arg in &task.args {
628        text.push('\u{1f}');
629        text.push_str(arg);
630    }
631    text.replace('\n', " ")
632}
633
634/// The tasks this process runs inside, from [`INVOCATIONS`].
635pub fn enclosing_invocations() -> Vec<String> {
636    std::env::var(INVOCATIONS)
637        .map(|chain| chain.lines().map(str::to_owned).collect())
638        .unwrap_or_default()
639}
640
641/// The root dotenv files Nx loads into its own process at startup, in the
642/// order they take precedence.
643pub const ROOT_DOTENV_FILES: [&str; 3] = [".local.env", ".env.local", ".env"];
644
645/// A task's environment as Nx builds it: the process environment, without
646/// what the root dotenv files set in it at startup, then the task's dotenv
647/// files, loaded and expanded together, then the variables Nx sets for
648/// every task. A target's `env` applies on top. With a declared
649/// environment, `qk:env`, only what it lets through of the process
650/// environment is kept, and what it sets takes its place.
651fn task_environment(
652    workspace: &Workspace,
653    task: &Task,
654    base_env: &BTreeMap<OsString, OsString>,
655    environment: Option<&mut qk_config::env::Environment>,
656) -> Result<(BTreeMap<OsString, OsString>, BTreeMap<OsString, OsString>)> {
657    let mut env = base_env.clone();
658    let project = &workspace.projects[&task.project];
659    if loads_dotenv(base_env) {
660        // As Nx's unloadDotEnvFiles: a variable whose value is a root file's
661        // goes, so the task's own files decide it. Nx unloads them even when
662        // it did not load them; qk keeps what the shell set then.
663        for file in [".env", ".local.env", ".env.local"] {
664            for (name, value) in dotenv::read_alone(&workspace.root.join(file))? {
665                if env.get(&name) == Some(&value) {
666                    env.remove(&name);
667                }
668            }
669        }
670    }
671    // What the task inherits, before its own dotenv files and settings,
672    // which its configuration declares.
673    if let Some(environment) = environment {
674        let mut provided = BTreeMap::new();
675        let mut keys = Vec::new();
676        for provider in environment.providers() {
677            let (variables, key) = providers::load(provider, base_env, &workspace.root)
678                .with_context(|| format!("cannot build the environment of {}", task.id))?;
679            provided.extend(variables);
680            keys.push(key);
681        }
682        environment.provide(provided, keys);
683        environment
684            .filter(&mut env)
685            .with_context(|| format!("cannot build the environment of {}", task.id))?;
686        if environment.builds_path() {
687            let path = bins::path(
688                environment,
689                base_env.get(&path_key(base_env)),
690                &workspace.root,
691                &project.root,
692            )
693            .with_context(|| format!("cannot build the PATH of {}", task.id))?;
694            env.insert(path_key(&env), path);
695        }
696    }
697    if loads_dotenv(base_env) {
698        let (target, non_atomized) = owner_target(project, &task.target);
699        let files: Vec<PathBuf> = dotenv_files(
700            &project.root,
701            target,
702            task.configuration.as_deref(),
703            non_atomized,
704        )
705        .into_iter()
706        .map(|file| workspace.root.join(file))
707        .collect();
708        dotenv::load(&files, &mut env)?;
709    }
710    let runtime_env = env.clone();
711    // Nx's own variables from an enclosing run, which describe that run.
712    for name in [
713        "NX_SET_CLI",
714        "NX_ORIGINAL_FORCE_COLOR",
715        "NX_TERMINAL_OUTPUT_PATH",
716        "NX_STREAM_OUTPUT",
717        "NX_PREFIX_OUTPUT",
718        // A task of an enclosing run's signal socket and reported address:
719        // a task here neither signals for that task nor reads its address.
720        ready_signal::SOCKET_ENV,
721        ready_signal::TASK_ENV,
722        ready_signal::URL_ENV,
723    ] {
724        env.remove(std::ffi::OsStr::new(name));
725    }
726    let mut set = |name: &str, value: OsString| {
727        env.insert(name.into(), value);
728    };
729    set("NX_WORKSPACE_ROOT", workspace.root.clone().into_os_string());
730    set("NX_TASK_TARGET_PROJECT", task.project.clone().into());
731    set("NX_TASK_TARGET_TARGET", task.target.clone().into());
732    set("LERNA_PACKAGE_NAME", task.project.clone().into());
733    set("NX_TUI", "false".into());
734    // The qk and Nx processes this task runs inside, outermost first.
735    let ancestors = match base_env.get(std::ffi::OsStr::new("NX_INVOCATION_ANCESTOR_PIDS")) {
736        Some(inherited) if !inherited.is_empty() => {
737            format!("{},{}", inherited.to_string_lossy(), std::process::id())
738        }
739        _ => std::process::id().to_string(),
740    };
741    set("NX_INVOCATION_ANCESTOR_PIDS", ancestors.into());
742    // The tasks this one runs inside, outermost first, so a run it starts can
743    // tell when it would run one of them again.
744    let mut chain = base_env
745        .get(std::ffi::OsStr::new(INVOCATIONS))
746        .map(|chain| chain.to_string_lossy().into_owned())
747        .unwrap_or_default();
748    if !chain.is_empty() {
749        chain.push('\n');
750    }
751    chain.push_str(&invocation(task));
752    set(INVOCATIONS, chain.into());
753    if task.target == "test" {
754        set("NX_TERMINAL_CAPTURE_STDERR", "true".into());
755    }
756    match &task.configuration {
757        Some(configuration) => set("NX_TASK_TARGET_CONFIGURATION", configuration.clone().into()),
758        None => {
759            env.remove(std::ffi::OsStr::new("NX_TASK_TARGET_CONFIGURATION"));
760        }
761    }
762    Ok((env, runtime_env))
763}
764
765/// Whether dotenv files load: as in Nx, `NX_LOAD_DOT_ENV_FILES=false` turns
766/// them all off.
767fn loads_dotenv(env: &BTreeMap<OsString, OsString>) -> bool {
768    env.get(std::ffi::OsStr::new("NX_LOAD_DOT_ENV_FILES"))
769        .is_none_or(|value| value != "false")
770}
771
772/// The process environment with the root dotenv files loaded into it, as
773/// Nx loads them into its own process at startup, unless
774/// `NX_LOAD_DOT_ENV_FILES=false`. Variables already set win.
775pub fn environment(workspace: &Path) -> Result<BTreeMap<OsString, OsString>> {
776    let mut values: BTreeMap<OsString, OsString> = std::env::vars_os().collect();
777    if loads_dotenv(&values) {
778        let files: Vec<PathBuf> = ROOT_DOTENV_FILES
779            .iter()
780            .map(|file| workspace.join(file))
781            .collect();
782        dotenv::load(&files, &mut values)?;
783    }
784    Ok(values)
785}
786
787pub fn prepare(
788    workspace: &Workspace,
789    task: &Task,
790    base_env: &BTreeMap<OsString, OsString>,
791) -> Result<PreparedTask> {
792    let definition = &task.definition;
793    let executor = definition
794        .executor
795        .as_deref()
796        .context("target has no executor or command")?;
797    let project = &workspace.projects[&task.project];
798    let run_commands = executor == "nx:run-commands";
799    if !matches!(executor, "nx:run-commands" | "nx:run-script" | "nx:noop") {
800        bail!("unsupported executor {executor:?}");
801    }
802    // As in Nx, run-script reads only `script`, and noop nothing.
803    let options = if run_commands {
804        run_commands_overrides(&definition.options, &task.args, project)?
805    } else {
806        definition.options.clone()
807    };
808    let options = &options;
809    let empty = Map::new();
810    let mut interpolation = if run_commands {
811        interpolate::Interpolation::new(&task.args, options)?
812    } else {
813        interpolate::Interpolation::new(&[], &empty)?
814    };
815    let mut environment = qk_config::env::Environment::of(&workspace.config, definition)
816        .with_context(|| format!("invalid environment for {}", task.id))?;
817    let (mut env, mut runtime_env) =
818        task_environment(workspace, task, base_env, environment.as_mut())?;
819    // As in Nx 24, `envFile` sets what the task's environment, dotenv files
820    // included, leaves unset or empty, beneath `env`; and the task's
821    // environment says whether it loads.
822    if let Some(file) = options.get("envFile").filter(|_| run_commands) {
823        let file = file.as_str().context("envFile must be a string")?;
824        let path = workspace.root.join(file);
825        if loads_dotenv(&runtime_env) {
826            if !path.is_file() {
827                bail!("envFile {file} does not exist");
828            }
829            dotenv::load(std::slice::from_ref(&path), &mut runtime_env)?;
830            dotenv::load(std::slice::from_ref(&path), &mut env)?;
831        }
832    }
833    // A target-level `env` is a qk extension, for every executor.
834    let option_env = options.get("env").filter(|_| run_commands);
835    for values in [definition.extra.get("env"), option_env]
836        .into_iter()
837        .flatten()
838    {
839        for (name, value) in values.as_object().context("env must be an object")? {
840            if name.is_empty() || name.contains(['=', '\0']) {
841                bail!("invalid environment variable name");
842            }
843            let value: OsString = value
844                .as_str()
845                .context("environment values must be strings")?
846                .into();
847            let name = environment_key(&env, name.into());
848            runtime_env.insert(name.clone(), value.clone());
849            env.insert(name, value);
850        }
851    }
852    interpolation.set_env(&env);
853    let mut execution = BTreeMap::new();
854    execution.insert(
855        "FORCE_COLOR".into(),
856        env.get(std::ffi::OsStr::new("FORCE_COLOR"))
857            .cloned()
858            .unwrap_or_else(|| "true".into()),
859    );
860    if run_commands && boolean(options.get("color"), false, "color")? {
861        execution.insert("FORCE_COLOR".into(), "true".into());
862    }
863    // run-commands runs where `cwd` says, the workspace root by default, and
864    // run-script in the project root.
865    let cwd = match options.get("cwd").filter(|_| run_commands) {
866        Some(cwd) => cwd.as_str().context("cwd must be a string")?,
867        None if run_commands => ".",
868        None => &project.root,
869    };
870    let cwd = workspace.root.join(cwd);
871    let mut resolved = PathBuf::new();
872    for part in cwd.components() {
873        match part {
874            std::path::Component::ParentDir => {
875                resolved.pop();
876            }
877            std::path::Component::CurDir => {}
878            _ => resolved.push(part.as_os_str()),
879        }
880    }
881    // Expose local binaries even for plain run-commands targets.
882    let mut paths = Vec::new();
883    for directory in resolved.ancestors() {
884        paths.push(directory.join("node_modules/.bin"));
885    }
886    let node_path = NodePath::new(&workspace.root, base_env, paths.len());
887    let path_key = path_key(&env);
888    if let Some(path) = env.get(&path_key) {
889        paths.extend(std::env::split_paths(path));
890    }
891    let path = std::env::join_paths(paths).context("cannot construct task PATH")?;
892    env.insert(path_key.clone(), path.clone());
893    runtime_env.insert(path_key, path);
894    let ready_when: Vec<String> = match options.get("readyWhen").filter(|_| run_commands) {
895        None | Some(Value::Null) => Vec::new(),
896        Some(Value::String(text)) => vec![text.clone()],
897        Some(value) => serde_json::from_value(value.clone())
898            .context("readyWhen must be a string or an array of strings")?,
899    };
900    // As in Nx, a single `command` runs in parallel exactly when it has
901    // `readyWhen`, whatever `parallel` says.
902    let parallel = if run_commands && options.get("command").is_some_and(merge_truthy) {
903        !ready_when.is_empty()
904    } else if run_commands {
905        boolean(options.get("parallel"), true, "parallel")?
906    } else {
907        true
908    };
909    let (mut pseudo_terminal, tty) = if run_commands {
910        (
911            boolean(options.get("usePty"), true, "usePty")?
912                && base_env
913                    .get(std::ffi::OsStr::new("NX_NATIVE_COMMAND_RUNNER"))
914                    .is_none_or(|value| value != "false"),
915            boolean(options.get("tty"), true, "tty")?,
916        )
917    } else {
918        (true, true)
919    };
920    let mut decorations = Vec::new();
921    if !ready_when.is_empty() && !parallel {
922        bail!("readyWhen can only be used when parallel is true");
923    }
924    let mut commands = Vec::new();
925    match executor {
926        "nx:noop" => {}
927        "nx:run-script" => {
928            let script = options
929                .get("script")
930                .and_then(Value::as_str)
931                .context("run-script requires options.script")?;
932            let package = workspace
933                .packages
934                .get(&task.project)
935                .context("run-script requires a package.json")?;
936            if !package.scripts.contains_key(script) {
937                bail!("package.json has no script {script:?}");
938            }
939            let manager = package_manager(workspace)?;
940            // Nx joins the arguments as they are; qk quotes each, so that
941            // one with a space or a shell character stays one argument.
942            let args = interpolate::quoted_arguments(&task.args)?;
943            let script = interpolate::quote(script)?;
944            // Nx's `run` for each: npm and bun need `--` before arguments,
945            // and yarn runs a script by name.
946            let mut command = match manager {
947                "yarn" => format!("yarn {script}"),
948                manager => format!("{manager} run {script}"),
949            };
950            if !args.is_empty() {
951                if matches!(manager, "npm" | "bun") {
952                    command.push_str(" --");
953                }
954                command.push(' ');
955                command.push_str(&args);
956            }
957            commands.push(command);
958        }
959        "nx:run-commands" => {
960            let forward = boolean(options.get("forwardAllArgs"), true, "forwardAllArgs")?;
961            // As in Nx, `command` wins over `commands`.
962            if let Some(command) = options
963                .get("command")
964                .filter(|command| merge_truthy(command))
965            {
966                // Nx also takes an array, joined with spaces.
967                let command = match command {
968                    Value::String(command) => command.clone(),
969                    Value::Array(words) => words
970                        .iter()
971                        .map(|word| word.as_str().context("command words must be strings"))
972                        .collect::<Result<Vec<_>>>()?
973                        .join(" "),
974                    _ => bail!("command must be a string or an array of strings"),
975                };
976                commands.push(interpolation.command(&command, forward)?);
977            } else if let Some(values) = options.get("commands") {
978                for value in values.as_array().context("commands must be an array")? {
979                    let (command, forward) = if let Some(command) = value.as_str() {
980                        (command, forward)
981                    } else {
982                        let value = value
983                            .as_object()
984                            .context("commands entries must be strings or objects")?;
985                        for key in value.keys() {
986                            if !matches!(
987                                key.as_str(),
988                                "command"
989                                    | "forwardAllArgs"
990                                    | "prefix"
991                                    | "prefixColor"
992                                    | "color"
993                                    | "bgColor"
994                                    | "description"
995                            ) {
996                                bail!("unsupported command entry field {key:?}");
997                            }
998                        }
999                        let field =
1000                            |name: &str, background: Option<bool>| -> Result<Option<String>> {
1001                                let Some(value) = value.get(name) else {
1002                                    return Ok(None);
1003                                };
1004                                let text = value.as_str().with_context(|| {
1005                                    format!("command entry {name} must be a string")
1006                                })?;
1007                                if let Some(background) = background
1008                                    && !Decoration::valid(text, background)
1009                                {
1010                                    bail!("command entry {name} {text:?} is not a colour");
1011                                }
1012                                Ok(Some(text.to_owned()))
1013                            };
1014                        let decoration = Decoration {
1015                            prefix: field("prefix", None)?,
1016                            prefix_color: field("prefixColor", Some(false))?,
1017                            color: field("color", Some(false))?,
1018                            bg_color: field("bgColor", Some(true))?,
1019                        };
1020                        if !decoration.is_empty() && !parallel {
1021                            bail!(
1022                                "prefix, prefixColor, color and bgColor can only be set when parallel is true"
1023                            );
1024                        }
1025                        decorations.resize(commands.len(), Decoration::default());
1026                        decorations.push(decoration);
1027                        (
1028                            value
1029                                .get("command")
1030                                .and_then(Value::as_str)
1031                                .context("command entry requires a command string")?,
1032                            boolean(value.get("forwardAllArgs"), forward, "forwardAllArgs")?,
1033                        )
1034                    };
1035                    commands.push(interpolation.command(command, forward)?);
1036                }
1037            } else {
1038                bail!("run-commands requires command or commands");
1039            }
1040            // An empty `commands` succeeds without running anything, as in Nx.
1041            if commands.iter().any(|command| command.trim().is_empty()) {
1042                bail!("run-commands requires nonempty commands");
1043            }
1044        }
1045        _ => bail!("unsupported executor {executor:?}"),
1046    }
1047    // As in Nx, commands run side by side share qk's output through pipes.
1048    pseudo_terminal &= commands.len() == 1 || !parallel;
1049    let ready_inputs = ready_inputs(workspace, task, options)?.map(std::sync::Arc::new);
1050    let mut prepared = PreparedTask {
1051        id: task.id.clone(),
1052        commands,
1053        parallel,
1054        cwd,
1055        env,
1056        runtime_env,
1057        environment,
1058        node_path: Some(node_path),
1059        execution,
1060        ready_when,
1061        ready: Default::default(),
1062        ready_check: None,
1063        ready_inputs,
1064        verdict: Default::default(),
1065        processes: Default::default(),
1066        decorations,
1067        interactive: false,
1068        pseudo_terminal,
1069        tty,
1070        sandbox: None,
1071        recording: None,
1072        recording_keys: false,
1073        scratch: Vec::new(),
1074        display: Display::default(),
1075    };
1076    prepared.ready_check = prepared.resolve_ready_check(None);
1077    Ok(prepared)
1078}
1079
1080/// A continuous target's `readyWhen` and what its placeholders read but the
1081/// environment: the options the task runs with, its arguments applied.
1082#[derive(Debug)]
1083pub struct ReadyInputs {
1084    config: Value,
1085    workspace_root: String,
1086    project_root: String,
1087    project_name: String,
1088    options: Map<String, Value>,
1089    /// `options` as a command check reads them, in `NX_TASK_OPTIONS`.
1090    options_json: String,
1091}
1092
1093/// A continuous target's `readyWhen`, as Nx 24 reads it. `null` turns off one
1094/// a target default sets. A target that is not continuous has none.
1095fn ready_inputs(
1096    workspace: &Workspace,
1097    task: &Task,
1098    options: &Map<String, Value>,
1099) -> Result<Option<ReadyInputs>> {
1100    let Some(ready_when) = task
1101        .definition
1102        .extra
1103        .get("readyWhen")
1104        .filter(|value| !value.is_null() && task.definition.continuous == Some(true))
1105    else {
1106        return Ok(None);
1107    };
1108    let mut options = options.clone();
1109    let project = &workspace.projects[&task.project];
1110    for (key, value) in interpolate::yargs(&task.args, false, false) {
1111        if key == "_" {
1112            continue;
1113        }
1114        // As in Nx, an argument's tokens resolve for the task's project.
1115        let value = match value {
1116            Value::String(text) => Value::String(qk_config::interpolate_override(&text, project)?),
1117            value => value,
1118        };
1119        if key.contains('-') {
1120            options.insert(interpolate::camel_case(&key), value.clone());
1121        }
1122        options.insert(key, value);
1123    }
1124    Ok(Some(ReadyInputs {
1125        config: ready_when.clone(),
1126        workspace_root: workspace
1127            .root
1128            .to_str()
1129            .context("workspace path must be UTF-8")?
1130            .to_owned(),
1131        project_root: project.root.clone(),
1132        project_name: task.project.clone(),
1133        options_json: serde_json::to_string(&options)?,
1134        options,
1135    }))
1136}
1137
1138impl PreparedTask {
1139    /// The task's own `readyWhen`, resolved against its options and the
1140    /// environment it runs with, with `url` as `NX_READY_URL`. As in Nx
1141    /// 24.0.1, a task waiting for a server that reports its address resolves
1142    /// it once that server is ready, so its placeholders and a command check
1143    /// read the address.
1144    pub fn resolve_ready_check(
1145        &self,
1146        url: Option<&str>,
1147    ) -> Option<std::result::Result<readiness::ReadyCheck, String>> {
1148        let inputs = self.ready_inputs.as_deref()?;
1149        let mut full_env = self.env.clone();
1150        if let Some(url) = url {
1151            full_env.insert(ready_signal::URL_ENV.into(), url.into());
1152        }
1153        let env: BTreeMap<String, String> = full_env
1154            .iter()
1155            .filter_map(|(name, value)| {
1156                Some((name.to_str()?.to_owned(), value.to_str()?.to_owned()))
1157            })
1158            .collect();
1159        let scope = readiness::Scope {
1160            workspace_root: &inputs.workspace_root,
1161            project_root: &inputs.project_root,
1162            project_name: &inputs.project_name,
1163            options: &inputs.options,
1164            env: &env,
1165        };
1166        let mut check = readiness::resolve(&self.id, &inputs.config, &scope);
1167        // As in Nx 24, a command check runs as the task's commands do, and
1168        // reads the options they run with.
1169        if let Ok(Some(readiness::ReadyCheck {
1170            probe: readiness::Probe::Command { env: probe_env, .. },
1171            ..
1172        })) = &mut check
1173        {
1174            *probe_env = full_env;
1175            probe_env.insert("NX_TASK_OPTIONS".into(), inputs.options_json.clone().into());
1176        }
1177        check.map_err(|error| format!("{error:#}")).transpose()
1178    }
1179}
1180
1181/// The run-commands options Nx reads, which a task's arguments may set too.
1182const OPTION_FLAGS: &[&str] = &[
1183    "command",
1184    "commands",
1185    "color",
1186    "parallel",
1187    "readyWhen",
1188    "cwd",
1189    "args",
1190    "envFile",
1191    "__unparsed__",
1192    "env",
1193    "usePty",
1194    "streamOutput",
1195    "verbose",
1196    "forwardAllArgs",
1197    "tty",
1198];
1199
1200/// The target's options with the task's arguments that name a run-commands
1201/// option applied. As in Nx, `--cwd=dir`, `--no-parallel` or
1202/// `--env.NAME=value` set the option rather than reaching the command.
1203fn run_commands_overrides(
1204    options: &serde_json::Map<String, Value>,
1205    args: &[String],
1206    project: &qk_config::Project,
1207) -> Result<serde_json::Map<String, Value>> {
1208    const BOOLEAN: &[&str] = &[
1209        "color",
1210        "parallel",
1211        "usePty",
1212        "streamOutput",
1213        "verbose",
1214        "forwardAllArgs",
1215        "tty",
1216    ];
1217    let mut options = options.clone();
1218    let mut ready = Vec::new();
1219    let mut args = args.iter().peekable();
1220    while let Some(arg) = args.next() {
1221        let Some(flag) = arg.strip_prefix("--") else {
1222            continue;
1223        };
1224        let (key, value) = match flag.split_once('=') {
1225            Some((key, value)) => (key, Some(value.to_owned())),
1226            None => (flag, None),
1227        };
1228        let (key, negated) = match key.strip_prefix("no-") {
1229            Some(key) if OPTION_FLAGS.contains(&key) => (key, true),
1230            _ => (key, false),
1231        };
1232        let (base, field) = match key.split_once('.') {
1233            Some((base, field)) => (base, Some(field)),
1234            None => (key, None),
1235        };
1236        if !OPTION_FLAGS.contains(&base) {
1237            continue;
1238        }
1239        let boolean = BOOLEAN.contains(&base);
1240        let value = match value {
1241            Some(value) => value,
1242            None if negated => "false".into(),
1243            None if !boolean && args.peek().is_some_and(|next| !next.starts_with('-')) => {
1244                args.next().unwrap().clone()
1245            }
1246            None => "true".into(),
1247        };
1248        let value = if boolean {
1249            Value::Bool(value != "false")
1250        } else {
1251            // As in Nx, an argument's tokens resolve for the task's project.
1252            Value::String(qk_config::interpolate_override(&value, project)?)
1253        };
1254        match (base, field) {
1255            ("env", Some(name)) => {
1256                let env = options
1257                    .entry("env")
1258                    .or_insert_with(|| Value::Object(Default::default()));
1259                if let Value::Object(env) = env {
1260                    env.insert(name.to_owned(), value);
1261                }
1262            }
1263            ("readyWhen", None) => ready.push(value),
1264            // Nx sets these itself, and a list cannot come from a flag.
1265            ("__unparsed__" | "commands", _) | (_, Some(_)) => {}
1266            (base, None) => {
1267                options.insert(base.to_owned(), value);
1268            }
1269        }
1270    }
1271    if !ready.is_empty() {
1272        let ready = if ready.len() == 1 {
1273            ready.pop().unwrap()
1274        } else {
1275            Value::Array(ready)
1276        };
1277        options.insert("readyWhen".into(), ready);
1278    }
1279    Ok(options)
1280}
1281
1282/// JavaScript truthiness, which decides what Nx reads as set.
1283fn merge_truthy(value: &Value) -> bool {
1284    match value {
1285        Value::Null => false,
1286        Value::Bool(flag) => *flag,
1287        Value::Number(number) => number.as_f64().is_some_and(|number| number != 0.0),
1288        Value::String(text) => !text.is_empty(),
1289        Value::Array(_) | Value::Object(_) => true,
1290    }
1291}
1292
1293fn boolean(value: Option<&Value>, default: bool, name: &str) -> Result<bool> {
1294    value
1295        .map(|value| {
1296            value
1297                .as_bool()
1298                .with_context(|| format!("{name} must be boolean"))
1299        })
1300        .transpose()
1301        .map(|value| value.unwrap_or(default))
1302}
1303
1304fn package_manager(workspace: &Workspace) -> Result<&'static str> {
1305    workspace.package_manager_name()
1306}
1307
1308#[cfg(all(test, windows))]
1309mod dotenv_tests {
1310    #[test]
1311    fn colon_targets_keep_configuration_and_generic_dotenv_files() {
1312        let files = super::dotenv_files("app", "test:unit:watch", Some("ci"), None);
1313        assert!(files.iter().all(|file| !file.contains(':')));
1314        assert!(files.contains(&"app/.env.ci".to_owned()));
1315        assert!(files.contains(&"app/.env".to_owned()));
1316        assert!(files.contains(&".env.ci".to_owned()));
1317        assert!(files.contains(&".env".to_owned()));
1318    }
1319}