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    pub program: PathBuf,
216    pub directory: PathBuf,
217    /// Whether strace filters the traced calls in the kernel, with seccomp,
218    /// so untraced calls run at full speed. strace 5.3 and later can.
219    pub seccomp: bool,
220}
221
222impl Recording {
223    /// strace on `PATH`, checked to record child processes, with seccomp
224    /// filtering where it supports it. Its trial traces go in `directory`.
225    pub fn probe(directory: &Path) -> anyhow::Result<Self> {
226        let path = std::env::var_os("PATH").context("recording needs strace on PATH")?;
227        let program = std::env::split_paths(&path)
228            .map(|directory| directory.join("strace"))
229            .find(|candidate| candidate.is_file())
230            .context("recording needs strace on PATH; install strace and retry")?
231            .canonicalize()?;
232        let mut failure = String::new();
233        for seccomp in [true, false] {
234            let recording = Self {
235                program: program.clone(),
236                directory: directory.to_owned(),
237                seccomp,
238            };
239            let probe = recording
240                .command(0, "exec /bin/true")
241                .output()
242                .context("cannot probe strace")?;
243            if probe.status.success() {
244                return Ok(recording);
245            }
246            failure = String::from_utf8_lossy(&probe.stderr).into_owned();
247        }
248        anyhow::bail!("strace cannot record child processes: {failure}")
249    }
250
251    /// The same settings, with traces in `directory`.
252    pub fn in_directory(&self, directory: PathBuf) -> Self {
253        Self {
254            directory,
255            ..self.clone()
256        }
257    }
258
259    /// The same invocation is used by preflight and actual task commands.
260    pub fn command(&self, index: usize, text: &str) -> std::process::Command {
261        let mut command = std::process::Command::new(&self.program);
262        // No read/write buffers or exec argument/environment arrays.
263        if self.seccomp {
264            command.arg("--seccomp-bpf");
265        }
266        command
267            .args([
268                "-ff",
269                "-ttt",
270                "-yy",
271                "-qq",
272                "-s",
273                "4096",
274                "-e",
275                "trace=%file,%process,getdents64,fchdir,close,dup,dup2,dup3,getcwd",
276                "-e",
277                "raw=execve,execveat",
278                "-o",
279            ])
280            .arg(self.directory.join(format!("command-{index}")))
281            .args(["--", "/bin/sh", "-c", text]);
282        command
283    }
284}
285
286#[derive(Clone, Debug)]
287pub struct PreparedTask {
288    pub id: String,
289    pub commands: Vec<String>,
290    pub parallel: bool,
291    pub cwd: PathBuf,
292    pub env: BTreeMap<OsString, OsString>,
293    /// Dotenv, explicit settings and local binary lookup without task metadata.
294    pub runtime_env: BTreeMap<OsString, OsString>,
295    pub node_path: Option<NodePath>,
296    /// Variables set for the process after `env` that are not part of what
297    /// the task is: nothing reads them to key it, such as a thread count.
298    pub execution: BTreeMap<OsString, OsString>,
299    /// Nx's `readyWhen`: text whose appearance in the output, every piece of
300    /// it, makes the task ready while its commands keep running.
301    pub ready_when: Vec<String>,
302    /// Set once all of `ready_when` has appeared.
303    pub ready: std::sync::Arc<std::sync::atomic::AtomicBool>,
304    /// A continuous target's own `readyWhen`, which tasks depending on it
305    /// with `waitFor: "ready"` wait for, or why it is invalid.
306    pub ready_check: Option<std::result::Result<readiness::ReadyCheck, String>>,
307    /// What `ready_check` concluded.
308    pub verdict: std::sync::Arc<readiness::Verdict>,
309    /// The process ids of its commands while they run, from which the runner
310    /// measures the memory the task uses.
311    pub processes: std::sync::Arc<std::sync::Mutex<std::collections::BTreeSet<u32>>>,
312    /// Each command's prefix and colours, by position; empty without any.
313    pub decorations: Vec<Decoration>,
314    /// Whether the task reads qk's stdin, and has the terminal while it runs.
315    /// The runner sets it for the one task a run is for, when that task has a
316    /// single command whose output goes straight to the terminal.
317    pub interactive: bool,
318    /// Whether captured commands may run under a pseudo-terminal, which they
319    /// do when qk's output is a terminal: Nx's `usePty`. Commands whose
320    /// output is hashed set it to false, so keys never depend on a terminal.
321    pub pseudo_terminal: bool,
322    /// Nx's `tty`: whether qk's terminal is in raw input mode while the
323    /// interactive task reads it through a pseudo-terminal.
324    pub tty: bool,
325    /// What the commands run under, for `--sandbox`.
326    pub sandbox: Option<Confinement>,
327    pub recording: Option<Recording>,
328    /// How the task's output is shown; the runner sets it per output style.
329    pub display: Display,
330}
331
332/// The dotenv files Nx loads for a task, most specific first: in the
333/// project root, then the workspace root, for the target and configuration
334/// (`.env.<target>.<configuration>`, `.env.<configuration>`, `.env.<target>`),
335/// then `.env.local`, `.local.env` and `.env`, each also in its `.local` and
336/// `.<name>.env` spellings. An atomized target also reads the files of the
337/// target it was split from, after its own of the same kind.
338fn dotenv_files(
339    project_root: &str,
340    target: &str,
341    configuration: Option<&str>,
342    non_atomized: Option<&str>,
343) -> Vec<String> {
344    let mut identifiers = Vec::new();
345    if let Some(configuration) = configuration {
346        identifiers.push(format!("{target}.{configuration}"));
347        if let Some(non_atomized) = non_atomized {
348            identifiers.push(format!("{non_atomized}.{configuration}"));
349        }
350        identifiers.push(configuration.to_owned());
351    }
352    identifiers.push(target.to_owned());
353    if let Some(non_atomized) = non_atomized {
354        identifiers.push(non_atomized.to_owned());
355    }
356    identifiers.push(String::new());
357    let variants = |identifier: &str, root: &str| -> Vec<String> {
358        let prefix = if root.is_empty() || root == "." {
359            String::new()
360        } else {
361            format!("{root}/")
362        };
363        if identifier.is_empty() {
364            [".env.local", ".local.env", ".env"]
365                .map(|name| format!("{prefix}{name}"))
366                .to_vec()
367        } else {
368            [
369                format!(".env.{identifier}.local"),
370                format!(".env.{identifier}"),
371                format!(".{identifier}.local.env"),
372                format!(".{identifier}.env"),
373            ]
374            .map(|name| format!("{prefix}{name}"))
375            .to_vec()
376        }
377    };
378    let mut files = Vec::new();
379    for root in [project_root, ""] {
380        for identifier in &identifiers {
381            // Colons in target names cannot name regular dotenv files on Windows.
382            #[cfg(windows)]
383            if identifier.contains(':') {
384                continue;
385            }
386            for file in variants(identifier, root) {
387                if !files.contains(&file) {
388                    files.push(file);
389                }
390            }
391        }
392    }
393    files
394}
395
396/// The target whose dotenv files a task reads, and the target it was
397/// atomized from, as Nx's getOwnerTargetForTask finds them: through the
398/// project's `metadata.targetGroups`, a target of the task's group that
399/// names a `metadata.nonAtomizedTarget`. Unlike Nx, a task that names one
400/// itself is its own owner, so each atomized target reads its own files
401/// rather than the first of its group's.
402fn owner_target<'a>(
403    project: &'a qk_config::Project,
404    target: &'a str,
405) -> (&'a str, Option<&'a str>) {
406    let non_atomized = |definition: &'a qk_config::Target| {
407        definition
408            .extra
409            .get("metadata")
410            .and_then(|metadata| metadata.get("nonAtomizedTarget"))
411            .and_then(Value::as_str)
412    };
413    if let Some((name, definition)) = project.targets.get_key_value(target)
414        && let Some(non_atomized) = non_atomized(definition)
415    {
416        return (name, Some(non_atomized));
417    }
418    let groups = project
419        .extra
420        .get("metadata")
421        .and_then(|metadata| metadata.get("targetGroups"))
422        .and_then(Value::as_object);
423    for group in groups.into_iter().flat_map(|groups| groups.values()) {
424        let members: Vec<&str> = group
425            .as_array()
426            .into_iter()
427            .flatten()
428            .filter_map(Value::as_str)
429            .collect();
430        if !members.contains(&target) {
431            continue;
432        }
433        for member in members {
434            if let Some((name, definition)) = project.targets.get_key_value(member)
435                && let Some(non_atomized) = non_atomized(definition)
436            {
437                return (name, Some(non_atomized));
438            }
439        }
440    }
441    (target, None)
442}
443
444/// The variable naming the tasks a task runs inside, one invocation a line.
445pub const INVOCATIONS: &str = "QK_TASK_INVOCATIONS";
446
447/// A task as an invocation: its id and arguments, which a task running inside
448/// it repeats only by recursing, as Nx's task invocation tracker tells it.
449pub fn invocation(task: &Task) -> String {
450    let mut text = task.id.clone();
451    for arg in &task.args {
452        text.push('\u{1f}');
453        text.push_str(arg);
454    }
455    text.replace('\n', " ")
456}
457
458/// The tasks this process runs inside, from [`INVOCATIONS`].
459pub fn enclosing_invocations() -> Vec<String> {
460    std::env::var(INVOCATIONS)
461        .map(|chain| chain.lines().map(str::to_owned).collect())
462        .unwrap_or_default()
463}
464
465/// The root dotenv files Nx loads into its own process at startup, in the
466/// order they take precedence.
467pub const ROOT_DOTENV_FILES: [&str; 3] = [".local.env", ".env.local", ".env"];
468
469/// A task's environment as Nx builds it: the process environment, without
470/// what the root dotenv files set in it at startup, then the task's dotenv
471/// files, loaded and expanded together, then the variables Nx sets for
472/// every task. A target's `env` applies on top.
473fn task_environment(
474    workspace: &Workspace,
475    task: &Task,
476    base_env: &BTreeMap<OsString, OsString>,
477) -> Result<(BTreeMap<OsString, OsString>, BTreeMap<OsString, OsString>)> {
478    let mut env = base_env.clone();
479    let project = &workspace.projects[&task.project];
480    if loads_dotenv(base_env) {
481        // As Nx's unloadDotEnvFiles: a variable whose value is a root file's
482        // goes, so the task's own files decide it. Nx unloads them even when
483        // it did not load them; qk keeps what the shell set then.
484        for file in [".env", ".local.env", ".env.local"] {
485            for (name, value) in dotenv::read_alone(&workspace.root.join(file))? {
486                if env.get(&name) == Some(&value) {
487                    env.remove(&name);
488                }
489            }
490        }
491        let (target, non_atomized) = owner_target(project, &task.target);
492        let files: Vec<PathBuf> = dotenv_files(
493            &project.root,
494            target,
495            task.configuration.as_deref(),
496            non_atomized,
497        )
498        .into_iter()
499        .map(|file| workspace.root.join(file))
500        .collect();
501        dotenv::load(&files, &mut env)?;
502    }
503    let runtime_env = env.clone();
504    // Nx's own variables from an enclosing run, which describe that run.
505    for name in [
506        "NX_SET_CLI",
507        "NX_ORIGINAL_FORCE_COLOR",
508        "NX_TERMINAL_OUTPUT_PATH",
509        "NX_STREAM_OUTPUT",
510        "NX_PREFIX_OUTPUT",
511        // A task of an enclosing run's signal socket and reported address:
512        // a task here neither signals for that task nor reads its address.
513        ready_signal::SOCKET_ENV,
514        ready_signal::TASK_ENV,
515        ready_signal::URL_ENV,
516    ] {
517        env.remove(std::ffi::OsStr::new(name));
518    }
519    let mut set = |name: &str, value: OsString| {
520        env.insert(name.into(), value);
521    };
522    set("NX_WORKSPACE_ROOT", workspace.root.clone().into_os_string());
523    set("NX_TASK_TARGET_PROJECT", task.project.clone().into());
524    set("NX_TASK_TARGET_TARGET", task.target.clone().into());
525    set("LERNA_PACKAGE_NAME", task.project.clone().into());
526    set("NX_TUI", "false".into());
527    // The qk and Nx processes this task runs inside, outermost first.
528    let ancestors = match base_env.get(std::ffi::OsStr::new("NX_INVOCATION_ANCESTOR_PIDS")) {
529        Some(inherited) if !inherited.is_empty() => {
530            format!("{},{}", inherited.to_string_lossy(), std::process::id())
531        }
532        _ => std::process::id().to_string(),
533    };
534    set("NX_INVOCATION_ANCESTOR_PIDS", ancestors.into());
535    // The tasks this one runs inside, outermost first, so a run it starts can
536    // tell when it would run one of them again.
537    let mut chain = base_env
538        .get(std::ffi::OsStr::new(INVOCATIONS))
539        .map(|chain| chain.to_string_lossy().into_owned())
540        .unwrap_or_default();
541    if !chain.is_empty() {
542        chain.push('\n');
543    }
544    chain.push_str(&invocation(task));
545    set(INVOCATIONS, chain.into());
546    if task.target == "test" {
547        set("NX_TERMINAL_CAPTURE_STDERR", "true".into());
548    }
549    match &task.configuration {
550        Some(configuration) => set("NX_TASK_TARGET_CONFIGURATION", configuration.clone().into()),
551        None => {
552            env.remove(std::ffi::OsStr::new("NX_TASK_TARGET_CONFIGURATION"));
553        }
554    }
555    Ok((env, runtime_env))
556}
557
558/// Whether dotenv files load: as in Nx, `NX_LOAD_DOT_ENV_FILES=false` turns
559/// them all off.
560fn loads_dotenv(env: &BTreeMap<OsString, OsString>) -> bool {
561    env.get(std::ffi::OsStr::new("NX_LOAD_DOT_ENV_FILES"))
562        .is_none_or(|value| value != "false")
563}
564
565/// The process environment with the root dotenv files loaded into it, as
566/// Nx loads them into its own process at startup, unless
567/// `NX_LOAD_DOT_ENV_FILES=false`. Variables already set win.
568pub fn environment(workspace: &Path) -> Result<BTreeMap<OsString, OsString>> {
569    let mut values: BTreeMap<OsString, OsString> = std::env::vars_os().collect();
570    if loads_dotenv(&values) {
571        let files: Vec<PathBuf> = ROOT_DOTENV_FILES
572            .iter()
573            .map(|file| workspace.join(file))
574            .collect();
575        dotenv::load(&files, &mut values)?;
576    }
577    Ok(values)
578}
579
580pub fn prepare(
581    workspace: &Workspace,
582    task: &Task,
583    base_env: &BTreeMap<OsString, OsString>,
584) -> Result<PreparedTask> {
585    let definition = &task.definition;
586    let executor = definition
587        .executor
588        .as_deref()
589        .context("target has no executor or command")?;
590    let project = &workspace.projects[&task.project];
591    let run_commands = executor == "nx:run-commands";
592    if !matches!(executor, "nx:run-commands" | "nx:run-script" | "nx:noop") {
593        bail!("unsupported executor {executor:?}");
594    }
595    // As in Nx, run-script reads only `script`, and noop nothing.
596    let options = if run_commands {
597        run_commands_overrides(&definition.options, &task.args, project)?
598    } else {
599        definition.options.clone()
600    };
601    let options = &options;
602    let empty = Map::new();
603    let mut interpolation = if run_commands {
604        interpolate::Interpolation::new(&task.args, options)?
605    } else {
606        interpolate::Interpolation::new(&[], &empty)?
607    };
608    let (mut env, mut runtime_env) = task_environment(workspace, task, base_env)?;
609    // As in Nx 24, `envFile` sets what the task's environment, dotenv files
610    // included, leaves unset or empty, beneath `env`; and the task's
611    // environment says whether it loads.
612    if let Some(file) = options.get("envFile").filter(|_| run_commands) {
613        let file = file.as_str().context("envFile must be a string")?;
614        let path = workspace.root.join(file);
615        if loads_dotenv(&runtime_env) {
616            if !path.is_file() {
617                bail!("envFile {file} does not exist");
618            }
619            dotenv::load(std::slice::from_ref(&path), &mut runtime_env)?;
620            dotenv::load(std::slice::from_ref(&path), &mut env)?;
621        }
622    }
623    // A target-level `env` is a qk extension, for every executor.
624    let option_env = options.get("env").filter(|_| run_commands);
625    for values in [definition.extra.get("env"), option_env]
626        .into_iter()
627        .flatten()
628    {
629        for (name, value) in values.as_object().context("env must be an object")? {
630            if name.is_empty() || name.contains(['=', '\0']) {
631                bail!("invalid environment variable name");
632            }
633            let value: OsString = value
634                .as_str()
635                .context("environment values must be strings")?
636                .into();
637            let name = environment_key(&env, name.into());
638            runtime_env.insert(name.clone(), value.clone());
639            env.insert(name, value);
640        }
641    }
642    interpolation.set_env(&env);
643    let mut execution = BTreeMap::new();
644    execution.insert(
645        "FORCE_COLOR".into(),
646        env.get(std::ffi::OsStr::new("FORCE_COLOR"))
647            .cloned()
648            .unwrap_or_else(|| "true".into()),
649    );
650    if run_commands && boolean(options.get("color"), false, "color")? {
651        execution.insert("FORCE_COLOR".into(), "true".into());
652    }
653    // run-commands runs where `cwd` says, the workspace root by default, and
654    // run-script in the project root.
655    let cwd = match options.get("cwd").filter(|_| run_commands) {
656        Some(cwd) => cwd.as_str().context("cwd must be a string")?,
657        None if run_commands => ".",
658        None => &project.root,
659    };
660    let cwd = workspace.root.join(cwd);
661    let mut resolved = PathBuf::new();
662    for part in cwd.components() {
663        match part {
664            std::path::Component::ParentDir => {
665                resolved.pop();
666            }
667            std::path::Component::CurDir => {}
668            _ => resolved.push(part.as_os_str()),
669        }
670    }
671    // Expose local binaries even for plain run-commands targets.
672    let mut paths = Vec::new();
673    for directory in resolved.ancestors() {
674        paths.push(directory.join("node_modules/.bin"));
675    }
676    let node_path = NodePath::new(&workspace.root, base_env, paths.len());
677    let path_key = path_key(&env);
678    if let Some(path) = env.get(&path_key) {
679        paths.extend(std::env::split_paths(path));
680    }
681    let path = std::env::join_paths(paths).context("cannot construct task PATH")?;
682    env.insert(path_key.clone(), path.clone());
683    runtime_env.insert(path_key, path);
684    let ready_when: Vec<String> = match options.get("readyWhen").filter(|_| run_commands) {
685        None | Some(Value::Null) => Vec::new(),
686        Some(Value::String(text)) => vec![text.clone()],
687        Some(value) => serde_json::from_value(value.clone())
688            .context("readyWhen must be a string or an array of strings")?,
689    };
690    // As in Nx, a single `command` runs in parallel exactly when it has
691    // `readyWhen`, whatever `parallel` says.
692    let parallel = if run_commands && options.get("command").is_some_and(merge_truthy) {
693        !ready_when.is_empty()
694    } else if run_commands {
695        boolean(options.get("parallel"), true, "parallel")?
696    } else {
697        true
698    };
699    let (mut pseudo_terminal, tty) = if run_commands {
700        (
701            boolean(options.get("usePty"), true, "usePty")?
702                && base_env
703                    .get(std::ffi::OsStr::new("NX_NATIVE_COMMAND_RUNNER"))
704                    .is_none_or(|value| value != "false"),
705            boolean(options.get("tty"), true, "tty")?,
706        )
707    } else {
708        (true, true)
709    };
710    let mut decorations = Vec::new();
711    if !ready_when.is_empty() && !parallel {
712        bail!("readyWhen can only be used when parallel is true");
713    }
714    let mut commands = Vec::new();
715    match executor {
716        "nx:noop" => {}
717        "nx:run-script" => {
718            let script = options
719                .get("script")
720                .and_then(Value::as_str)
721                .context("run-script requires options.script")?;
722            let package = workspace
723                .packages
724                .get(&task.project)
725                .context("run-script requires a package.json")?;
726            if !package.scripts.contains_key(script) {
727                bail!("package.json has no script {script:?}");
728            }
729            let manager = package_manager(workspace)?;
730            // Nx joins the arguments as they are; qk quotes each, so that
731            // one with a space or a shell character stays one argument.
732            let args = interpolate::quoted_arguments(&task.args)?;
733            let script = interpolate::quote(script)?;
734            // Nx's `run` for each: npm and bun need `--` before arguments,
735            // and yarn runs a script by name.
736            let mut command = match manager {
737                "yarn" => format!("yarn {script}"),
738                manager => format!("{manager} run {script}"),
739            };
740            if !args.is_empty() {
741                if matches!(manager, "npm" | "bun") {
742                    command.push_str(" --");
743                }
744                command.push(' ');
745                command.push_str(&args);
746            }
747            commands.push(command);
748        }
749        "nx:run-commands" => {
750            let forward = boolean(options.get("forwardAllArgs"), true, "forwardAllArgs")?;
751            // As in Nx, `command` wins over `commands`.
752            if let Some(command) = options
753                .get("command")
754                .filter(|command| merge_truthy(command))
755            {
756                // Nx also takes an array, joined with spaces.
757                let command = match command {
758                    Value::String(command) => command.clone(),
759                    Value::Array(words) => words
760                        .iter()
761                        .map(|word| word.as_str().context("command words must be strings"))
762                        .collect::<Result<Vec<_>>>()?
763                        .join(" "),
764                    _ => bail!("command must be a string or an array of strings"),
765                };
766                commands.push(interpolation.command(&command, forward)?);
767            } else if let Some(values) = options.get("commands") {
768                for value in values.as_array().context("commands must be an array")? {
769                    let (command, forward) = if let Some(command) = value.as_str() {
770                        (command, forward)
771                    } else {
772                        let value = value
773                            .as_object()
774                            .context("commands entries must be strings or objects")?;
775                        for key in value.keys() {
776                            if !matches!(
777                                key.as_str(),
778                                "command"
779                                    | "forwardAllArgs"
780                                    | "prefix"
781                                    | "prefixColor"
782                                    | "color"
783                                    | "bgColor"
784                                    | "description"
785                            ) {
786                                bail!("unsupported command entry field {key:?}");
787                            }
788                        }
789                        let field =
790                            |name: &str, background: Option<bool>| -> Result<Option<String>> {
791                                let Some(value) = value.get(name) else {
792                                    return Ok(None);
793                                };
794                                let text = value.as_str().with_context(|| {
795                                    format!("command entry {name} must be a string")
796                                })?;
797                                if let Some(background) = background
798                                    && !Decoration::valid(text, background)
799                                {
800                                    bail!("command entry {name} {text:?} is not a colour");
801                                }
802                                Ok(Some(text.to_owned()))
803                            };
804                        let decoration = Decoration {
805                            prefix: field("prefix", None)?,
806                            prefix_color: field("prefixColor", Some(false))?,
807                            color: field("color", Some(false))?,
808                            bg_color: field("bgColor", Some(true))?,
809                        };
810                        if !decoration.is_empty() && !parallel {
811                            bail!(
812                                "prefix, prefixColor, color and bgColor can only be set when parallel is true"
813                            );
814                        }
815                        decorations.resize(commands.len(), Decoration::default());
816                        decorations.push(decoration);
817                        (
818                            value
819                                .get("command")
820                                .and_then(Value::as_str)
821                                .context("command entry requires a command string")?,
822                            boolean(value.get("forwardAllArgs"), forward, "forwardAllArgs")?,
823                        )
824                    };
825                    commands.push(interpolation.command(command, forward)?);
826                }
827            } else {
828                bail!("run-commands requires command or commands");
829            }
830            // An empty `commands` succeeds without running anything, as in Nx.
831            if commands.iter().any(|command| command.trim().is_empty()) {
832                bail!("run-commands requires nonempty commands");
833            }
834        }
835        _ => bail!("unsupported executor {executor:?}"),
836    }
837    // As in Nx, commands run side by side share qk's output through pipes.
838    pseudo_terminal &= commands.len() == 1 || !parallel;
839    let ready_check = ready_check(workspace, task, options, &env)?;
840    Ok(PreparedTask {
841        id: task.id.clone(),
842        commands,
843        parallel,
844        cwd,
845        env,
846        runtime_env,
847        node_path: Some(node_path),
848        execution,
849        ready_when,
850        ready: Default::default(),
851        ready_check,
852        verdict: Default::default(),
853        processes: Default::default(),
854        decorations,
855        interactive: false,
856        pseudo_terminal,
857        tty,
858        sandbox: None,
859        recording: None,
860        display: Display::default(),
861    })
862}
863
864/// A continuous target's `readyWhen`, resolved against the options and the
865/// environment the task runs with, as Nx 24 resolves it. `null` turns off one
866/// a target default sets. A target that is not continuous has none.
867fn ready_check(
868    workspace: &Workspace,
869    task: &Task,
870    options: &Map<String, Value>,
871    env: &BTreeMap<OsString, OsString>,
872) -> Result<Option<std::result::Result<readiness::ReadyCheck, String>>> {
873    let Some(ready_when) = task
874        .definition
875        .extra
876        .get("readyWhen")
877        .filter(|value| !value.is_null() && task.definition.continuous == Some(true))
878    else {
879        return Ok(None);
880    };
881    let mut options = options.clone();
882    let project = &workspace.projects[&task.project];
883    for (key, value) in interpolate::yargs(&task.args, false, false) {
884        if key == "_" {
885            continue;
886        }
887        // As in Nx, an argument's tokens resolve for the task's project.
888        let value = match value {
889            Value::String(text) => Value::String(qk_config::interpolate_override(&text, project)?),
890            value => value,
891        };
892        if key.contains('-') {
893            options.insert(interpolate::camel_case(&key), value.clone());
894        }
895        options.insert(key, value);
896    }
897    let full_env = env;
898    let env: BTreeMap<String, String> = env
899        .iter()
900        .filter_map(|(name, value)| Some((name.to_str()?.to_owned(), value.to_str()?.to_owned())))
901        .collect();
902    let scope = readiness::Scope {
903        workspace_root: workspace
904            .root
905            .to_str()
906            .context("workspace path must be UTF-8")?,
907        project_root: &workspace.projects[&task.project].root,
908        project_name: &task.project,
909        options: &options,
910        env: &env,
911    };
912    let mut check = readiness::resolve(&task.id, ready_when, &scope);
913    // As in Nx 24, a command check runs as the task's commands do, and
914    // reads the options they run with.
915    if let Ok(Some(readiness::ReadyCheck {
916        probe: readiness::Probe::Command { env: probe_env, .. },
917        ..
918    })) = &mut check
919    {
920        *probe_env = full_env.clone();
921        probe_env.insert(
922            "NX_TASK_OPTIONS".into(),
923            serde_json::to_string(&options)?.into(),
924        );
925    }
926    Ok(check.map_err(|error| format!("{error:#}")).transpose())
927}
928
929/// The run-commands options Nx reads, which a task's arguments may set too.
930const OPTION_FLAGS: &[&str] = &[
931    "command",
932    "commands",
933    "color",
934    "parallel",
935    "readyWhen",
936    "cwd",
937    "args",
938    "envFile",
939    "__unparsed__",
940    "env",
941    "usePty",
942    "streamOutput",
943    "verbose",
944    "forwardAllArgs",
945    "tty",
946];
947
948/// The target's options with the task's arguments that name a run-commands
949/// option applied. As in Nx, `--cwd=dir`, `--no-parallel` or
950/// `--env.NAME=value` set the option rather than reaching the command.
951fn run_commands_overrides(
952    options: &serde_json::Map<String, Value>,
953    args: &[String],
954    project: &qk_config::Project,
955) -> Result<serde_json::Map<String, Value>> {
956    const BOOLEAN: &[&str] = &[
957        "color",
958        "parallel",
959        "usePty",
960        "streamOutput",
961        "verbose",
962        "forwardAllArgs",
963        "tty",
964    ];
965    let mut options = options.clone();
966    let mut ready = Vec::new();
967    let mut args = args.iter().peekable();
968    while let Some(arg) = args.next() {
969        let Some(flag) = arg.strip_prefix("--") else {
970            continue;
971        };
972        let (key, value) = match flag.split_once('=') {
973            Some((key, value)) => (key, Some(value.to_owned())),
974            None => (flag, None),
975        };
976        let (key, negated) = match key.strip_prefix("no-") {
977            Some(key) if OPTION_FLAGS.contains(&key) => (key, true),
978            _ => (key, false),
979        };
980        let (base, field) = match key.split_once('.') {
981            Some((base, field)) => (base, Some(field)),
982            None => (key, None),
983        };
984        if !OPTION_FLAGS.contains(&base) {
985            continue;
986        }
987        let boolean = BOOLEAN.contains(&base);
988        let value = match value {
989            Some(value) => value,
990            None if negated => "false".into(),
991            None if !boolean && args.peek().is_some_and(|next| !next.starts_with('-')) => {
992                args.next().unwrap().clone()
993            }
994            None => "true".into(),
995        };
996        let value = if boolean {
997            Value::Bool(value != "false")
998        } else {
999            // As in Nx, an argument's tokens resolve for the task's project.
1000            Value::String(qk_config::interpolate_override(&value, project)?)
1001        };
1002        match (base, field) {
1003            ("env", Some(name)) => {
1004                let env = options
1005                    .entry("env")
1006                    .or_insert_with(|| Value::Object(Default::default()));
1007                if let Value::Object(env) = env {
1008                    env.insert(name.to_owned(), value);
1009                }
1010            }
1011            ("readyWhen", None) => ready.push(value),
1012            // Nx sets these itself, and a list cannot come from a flag.
1013            ("__unparsed__" | "commands", _) | (_, Some(_)) => {}
1014            (base, None) => {
1015                options.insert(base.to_owned(), value);
1016            }
1017        }
1018    }
1019    if !ready.is_empty() {
1020        let ready = if ready.len() == 1 {
1021            ready.pop().unwrap()
1022        } else {
1023            Value::Array(ready)
1024        };
1025        options.insert("readyWhen".into(), ready);
1026    }
1027    Ok(options)
1028}
1029
1030/// JavaScript truthiness, which decides what Nx reads as set.
1031fn merge_truthy(value: &Value) -> bool {
1032    match value {
1033        Value::Null => false,
1034        Value::Bool(flag) => *flag,
1035        Value::Number(number) => number.as_f64().is_some_and(|number| number != 0.0),
1036        Value::String(text) => !text.is_empty(),
1037        Value::Array(_) | Value::Object(_) => true,
1038    }
1039}
1040
1041fn boolean(value: Option<&Value>, default: bool, name: &str) -> Result<bool> {
1042    value
1043        .map(|value| {
1044            value
1045                .as_bool()
1046                .with_context(|| format!("{name} must be boolean"))
1047        })
1048        .transpose()
1049        .map(|value| value.unwrap_or(default))
1050}
1051
1052fn package_manager(workspace: &Workspace) -> Result<&'static str> {
1053    workspace.package_manager_name()
1054}
1055
1056#[cfg(all(test, windows))]
1057mod dotenv_tests {
1058    #[test]
1059    fn colon_targets_keep_configuration_and_generic_dotenv_files() {
1060        let files = super::dotenv_files("app", "test:unit:watch", Some("ci"), None);
1061        assert!(files.iter().all(|file| !file.contains(':')));
1062        assert!(files.contains(&"app/.env.ci".to_owned()));
1063        assert!(files.contains(&"app/.env".to_owned()));
1064        assert!(files.contains(&".env.ci".to_owned()));
1065        assert!(files.contains(&".env".to_owned()));
1066    }
1067}