Skip to main content

qk_executor/
lib.rs

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