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 report;
12pub mod tokens;
13
14use std::collections::BTreeMap;
15use std::ffi::OsString;
16use std::path::{Path, PathBuf};
17
18use anyhow::{Context, Result, bail};
19use qk_config::Workspace;
20use qk_taskgraph::Task;
21use serde_json::{Map, Value};
22
23pub use capture::{
24    Capture, Decoration, Display, MAX_LOG_BYTES, OutputStyle, RecordedLog, Shown, collapsed,
25    printable, read_capture, replay,
26};
27pub use env_flags::{force_colour, is_ci, is_ci_with};
28pub use process::{Outcome, execute, execute_captured, shell};
29
30#[derive(Debug)]
31struct NodeRuntime {
32    root: PathBuf,
33    env: BTreeMap<OsString, OsString>,
34    paths: Vec<PathBuf>,
35    directory: std::sync::OnceLock<Option<PathBuf>>,
36}
37
38/// The runner's Node selection, resolved only when a command starts.
39#[derive(Clone, Debug)]
40pub struct NodePath {
41    runtime: std::sync::Arc<NodeRuntime>,
42    local_entries: usize,
43}
44
45impl NodePath {
46    fn new(root: &Path, env: &BTreeMap<OsString, OsString>, local_entries: usize) -> Self {
47        type Key = (PathBuf, BTreeMap<OsString, OsString>);
48        static RUNTIMES: std::sync::OnceLock<
49            std::sync::Mutex<BTreeMap<Key, std::sync::Arc<NodeRuntime>>>,
50        > = std::sync::OnceLock::new();
51        let mut runtimes = RUNTIMES.get_or_init(Default::default).lock().unwrap();
52        let runtime = runtimes
53            .entry((root.to_owned(), env.clone()))
54            .or_insert_with(|| {
55                let paths = env
56                    .get(&path_key(env))
57                    .map(|path| std::env::split_paths(path).collect())
58                    .unwrap_or_default();
59                std::sync::Arc::new(NodeRuntime {
60                    root: root.to_owned(),
61                    env: env.clone(),
62                    paths,
63                    directory: Default::default(),
64                })
65            })
66            .clone();
67        Self {
68            runtime,
69            local_entries,
70        }
71    }
72
73    fn path(&self, env: &BTreeMap<OsString, OsString>) -> Result<Option<(OsString, OsString)>> {
74        // Select before task overrides, like process.execPath in Nx. Cache hits
75        // never call this, and concurrent commands share the same proxy probe.
76        let Some(directory) = self.runtime.directory.get_or_init(|| {
77            node_directory(&self.runtime.root, &self.runtime.paths, &self.runtime.env)
78        }) else {
79            return Ok(None);
80        };
81        let key = path_key(env);
82        let mut paths: Vec<_> = env
83            .get(&key)
84            .map(|path| std::env::split_paths(path).collect())
85            .unwrap_or_default();
86        paths.insert(self.local_entries.min(paths.len()), directory.clone());
87        Ok(Some((
88            key,
89            std::env::join_paths(paths).context("cannot construct task PATH")?,
90        )))
91    }
92}
93
94fn path_key(env: &BTreeMap<OsString, OsString>) -> OsString {
95    if cfg!(windows) {
96        env.keys()
97            .find(|key| key.to_string_lossy().eq_ignore_ascii_case("PATH"))
98            .cloned()
99            .unwrap_or_else(|| "PATH".into())
100    } else {
101        "PATH".into()
102    }
103}
104
105fn environment_key(env: &BTreeMap<OsString, OsString>, name: OsString) -> OsString {
106    if cfg!(windows) && name.to_string_lossy().eq_ignore_ascii_case("PATH") {
107        path_key(env)
108    } else {
109        name
110    }
111}
112
113fn node_directory(
114    cwd: &Path,
115    paths: &[PathBuf],
116    env: &BTreeMap<OsString, OsString>,
117) -> Option<PathBuf> {
118    let name = if cfg!(windows) { "node.exe" } else { "node" };
119    for directory in paths {
120        let candidate = cwd.join(directory).join(name);
121        let Ok(metadata) = candidate.metadata() else {
122            continue;
123        };
124        if !metadata.is_file() {
125            continue;
126        }
127        #[cfg(unix)]
128        {
129            use std::os::unix::fs::PermissionsExt;
130            if metadata.permissions().mode() & 0o111 == 0 {
131                continue;
132            }
133        }
134        // The first executable PATH match is authoritative, even if discovery fails.
135        let actual = candidate.canonicalize().ok()?;
136        if actual.file_name()? == name {
137            use std::io::Read;
138            let mut header = [0; 2];
139            let script = std::fs::File::open(&actual)
140                .and_then(|mut file| file.read_exact(&mut header))
141                .is_ok()
142                && header == *b"#!";
143            if !script {
144                return actual.parent().map(Path::to_owned);
145            }
146        }
147        return probe_node(&candidate, cwd, env);
148    }
149    None
150}
151
152fn probe_node(candidate: &Path, cwd: &Path, env: &BTreeMap<OsString, OsString>) -> Option<PathBuf> {
153    use std::io::{Read, Seek};
154    use std::process::{Command, Stdio};
155    use std::time::{Duration, Instant};
156    let mut output = tempfile::tempfile().ok()?;
157    let mut child = Command::new(candidate)
158        .args(["-p", "process.execPath"])
159        .current_dir(cwd)
160        .env_clear()
161        .envs(env)
162        .stdout(Stdio::from(output.try_clone().ok()?))
163        .stderr(Stdio::null())
164        .spawn()
165        .ok()?;
166    let started = Instant::now();
167    loop {
168        match child.try_wait() {
169            Ok(Some(status)) => {
170                if !status.success() {
171                    return None;
172                }
173                let mut bytes = Vec::new();
174                output.rewind().ok()?;
175                output.take(4096).read_to_end(&mut bytes).ok()?;
176                let path = PathBuf::from(std::str::from_utf8(&bytes).ok()?.trim());
177                return (path.is_absolute() && path.is_file())
178                    .then(|| path.parent().unwrap().to_owned());
179            }
180            Ok(None) if started.elapsed() < Duration::from_secs(2) => {
181                std::thread::sleep(Duration::from_millis(5))
182            }
183            _ => {
184                let _ = child.kill();
185                let _ = child.wait();
186                return None;
187            }
188        }
189    }
190}
191
192/// Reports a `qk:` warning to the installed sink; by default it is written to
193/// stderr in one write, so task processes sharing stderr cannot split it.
194#[macro_export]
195macro_rules! status {
196    ($($arg:tt)*) => {{
197        $crate::report::warning(&::std::format!($($arg)*));
198    }};
199}
200
201/// How a sandboxed task's commands are held to what it declares.
202#[derive(Clone, Debug)]
203pub enum Confinement {
204    /// macOS: a Seatbelt profile, through `sandbox-exec`.
205    Seatbelt(PathBuf),
206    /// Linux: a Landlock rule set's descriptor, which each command restricts
207    /// itself to before it starts. It stays open while the run lasts.
208    Landlock(i32),
209}
210
211/// An external syscall recorder wrapping each command, with separate trace files.
212#[derive(Clone, Debug)]
213pub struct Recording {
214    pub program: PathBuf,
215    pub directory: PathBuf,
216}
217
218impl Recording {
219    /// The same invocation is used by preflight and actual task commands.
220    pub fn command(&self, index: usize, text: &str) -> std::process::Command {
221        let mut command = std::process::Command::new(&self.program);
222        // No read/write buffers or exec argument/environment arrays.
223        command
224            .args([
225                "-ff",
226                "-ttt",
227                "-yy",
228                "-qq",
229                "-s",
230                "4096",
231                "-e",
232                "trace=%file,%process,getdents64,fchdir,close,dup,dup2,dup3,getcwd",
233                "-e",
234                "raw=execve,execveat",
235                "-o",
236            ])
237            .arg(self.directory.join(format!("command-{index}")))
238            .args(["--", "/bin/sh", "-c", text]);
239        command
240    }
241}
242
243#[derive(Clone, Debug)]
244pub struct PreparedTask {
245    pub id: String,
246    pub commands: Vec<String>,
247    pub parallel: bool,
248    pub cwd: PathBuf,
249    pub env: BTreeMap<OsString, OsString>,
250    /// Dotenv, explicit settings and local binary lookup without task metadata.
251    pub runtime_env: BTreeMap<OsString, OsString>,
252    pub node_path: Option<NodePath>,
253    /// Variables set for the process after `env` that are not part of what
254    /// the task is: nothing reads them to key it, such as a thread count.
255    pub execution: BTreeMap<OsString, OsString>,
256    /// Nx's `readyWhen`: text whose appearance in the output, every piece of
257    /// it, makes the task ready while its commands keep running.
258    pub ready_when: Vec<String>,
259    /// Set once all of `ready_when` has appeared.
260    pub ready: std::sync::Arc<std::sync::atomic::AtomicBool>,
261    /// A continuous target's own `readyWhen`, which tasks depending on it
262    /// with `waitFor: "ready"` wait for, or why it is invalid.
263    pub ready_check: Option<std::result::Result<readiness::ReadyCheck, String>>,
264    /// What `ready_check` concluded.
265    pub verdict: std::sync::Arc<readiness::Verdict>,
266    /// The process ids of its commands while they run, from which the runner
267    /// measures the memory the task uses.
268    pub processes: std::sync::Arc<std::sync::Mutex<std::collections::BTreeSet<u32>>>,
269    /// Each command's prefix and colours, by position; empty without any.
270    pub decorations: Vec<Decoration>,
271    /// Whether the task reads qk's stdin, and has the terminal while it runs.
272    /// The runner sets it for the one task a run is for, when that task has a
273    /// single command whose output goes straight to the terminal.
274    pub interactive: bool,
275    /// Whether captured commands may run under a pseudo-terminal, which they
276    /// do when qk's output is a terminal: Nx's `usePty`. Commands whose
277    /// output is hashed set it to false, so keys never depend on a terminal.
278    pub pseudo_terminal: bool,
279    /// Nx's `tty`: whether qk's terminal is in raw input mode while the
280    /// interactive task reads it through a pseudo-terminal.
281    pub tty: bool,
282    /// What the commands run under, for `--sandbox`.
283    pub sandbox: Option<Confinement>,
284    pub recording: Option<Recording>,
285    /// How the task's output is shown; the runner sets it per output style.
286    pub display: Display,
287}
288
289/// The dotenv files Nx loads for a task, most specific first: in the
290/// project root, then the workspace root, for the target and configuration
291/// (`.env.<target>.<configuration>`, `.env.<configuration>`, `.env.<target>`),
292/// then `.env.local`, `.local.env` and `.env`, each also in its `.local` and
293/// `.<name>.env` spellings. An atomized target also reads the files of the
294/// target it was split from, after its own of the same kind.
295fn dotenv_files(
296    project_root: &str,
297    target: &str,
298    configuration: Option<&str>,
299    non_atomized: Option<&str>,
300) -> Vec<String> {
301    let mut identifiers = Vec::new();
302    if let Some(configuration) = configuration {
303        identifiers.push(format!("{target}.{configuration}"));
304        if let Some(non_atomized) = non_atomized {
305            identifiers.push(format!("{non_atomized}.{configuration}"));
306        }
307        identifiers.push(configuration.to_owned());
308    }
309    identifiers.push(target.to_owned());
310    if let Some(non_atomized) = non_atomized {
311        identifiers.push(non_atomized.to_owned());
312    }
313    identifiers.push(String::new());
314    let variants = |identifier: &str, root: &str| -> Vec<String> {
315        let prefix = if root.is_empty() || root == "." {
316            String::new()
317        } else {
318            format!("{root}/")
319        };
320        if identifier.is_empty() {
321            [".env.local", ".local.env", ".env"]
322                .map(|name| format!("{prefix}{name}"))
323                .to_vec()
324        } else {
325            [
326                format!(".env.{identifier}.local"),
327                format!(".env.{identifier}"),
328                format!(".{identifier}.local.env"),
329                format!(".{identifier}.env"),
330            ]
331            .map(|name| format!("{prefix}{name}"))
332            .to_vec()
333        }
334    };
335    let mut files = Vec::new();
336    for root in [project_root, ""] {
337        for identifier in &identifiers {
338            // Colons in target names cannot name regular dotenv files on Windows.
339            #[cfg(windows)]
340            if identifier.contains(':') {
341                continue;
342            }
343            for file in variants(identifier, root) {
344                if !files.contains(&file) {
345                    files.push(file);
346                }
347            }
348        }
349    }
350    files
351}
352
353/// The target whose dotenv files a task reads, and the target it was
354/// atomized from, as Nx's getOwnerTargetForTask finds them: through the
355/// project's `metadata.targetGroups`, a target of the task's group that
356/// names a `metadata.nonAtomizedTarget`. Unlike Nx, a task that names one
357/// itself is its own owner, so each atomized target reads its own files
358/// rather than the first of its group's.
359fn owner_target<'a>(
360    project: &'a qk_config::Project,
361    target: &'a str,
362) -> (&'a str, Option<&'a str>) {
363    let non_atomized = |definition: &'a qk_config::Target| {
364        definition
365            .extra
366            .get("metadata")
367            .and_then(|metadata| metadata.get("nonAtomizedTarget"))
368            .and_then(Value::as_str)
369    };
370    if let Some((name, definition)) = project.targets.get_key_value(target)
371        && let Some(non_atomized) = non_atomized(definition)
372    {
373        return (name, Some(non_atomized));
374    }
375    let groups = project
376        .extra
377        .get("metadata")
378        .and_then(|metadata| metadata.get("targetGroups"))
379        .and_then(Value::as_object);
380    for group in groups.into_iter().flat_map(|groups| groups.values()) {
381        let members: Vec<&str> = group
382            .as_array()
383            .into_iter()
384            .flatten()
385            .filter_map(Value::as_str)
386            .collect();
387        if !members.contains(&target) {
388            continue;
389        }
390        for member in members {
391            if let Some((name, definition)) = project.targets.get_key_value(member)
392                && let Some(non_atomized) = non_atomized(definition)
393            {
394                return (name, Some(non_atomized));
395            }
396        }
397    }
398    (target, None)
399}
400
401/// The variable naming the tasks a task runs inside, one invocation a line.
402pub const INVOCATIONS: &str = "QK_TASK_INVOCATIONS";
403
404/// A task as an invocation: its id and arguments, which a task running inside
405/// it repeats only by recursing, as Nx's task invocation tracker tells it.
406pub fn invocation(task: &Task) -> String {
407    let mut text = task.id.clone();
408    for arg in &task.args {
409        text.push('\u{1f}');
410        text.push_str(arg);
411    }
412    text.replace('\n', " ")
413}
414
415/// The tasks this process runs inside, from [`INVOCATIONS`].
416pub fn enclosing_invocations() -> Vec<String> {
417    std::env::var(INVOCATIONS)
418        .map(|chain| chain.lines().map(str::to_owned).collect())
419        .unwrap_or_default()
420}
421
422/// The root dotenv files Nx loads into its own process at startup, in the
423/// order they take precedence.
424pub const ROOT_DOTENV_FILES: [&str; 3] = [".local.env", ".env.local", ".env"];
425
426/// A task's environment as Nx builds it: the process environment, without
427/// what the root dotenv files set in it at startup, then the task's dotenv
428/// files, loaded and expanded together, then the variables Nx sets for
429/// every task. A target's `env` applies on top.
430fn task_environment(
431    workspace: &Workspace,
432    task: &Task,
433    base_env: &BTreeMap<OsString, OsString>,
434) -> Result<(BTreeMap<OsString, OsString>, BTreeMap<OsString, OsString>)> {
435    let mut env = base_env.clone();
436    let project = &workspace.projects[&task.project];
437    if loads_dotenv(base_env) {
438        // As Nx's unloadDotEnvFiles: a variable whose value is a root file's
439        // goes, so the task's own files decide it. Nx unloads them even when
440        // it did not load them; qk keeps what the shell set then.
441        for file in [".env", ".local.env", ".env.local"] {
442            for (name, value) in dotenv::read_alone(&workspace.root.join(file))? {
443                if env.get(&name) == Some(&value) {
444                    env.remove(&name);
445                }
446            }
447        }
448        let (target, non_atomized) = owner_target(project, &task.target);
449        let files: Vec<PathBuf> = dotenv_files(
450            &project.root,
451            target,
452            task.configuration.as_deref(),
453            non_atomized,
454        )
455        .into_iter()
456        .map(|file| workspace.root.join(file))
457        .collect();
458        dotenv::load(&files, &mut env)?;
459    }
460    let runtime_env = env.clone();
461    // Nx's own variables from an enclosing run, which describe that run.
462    for name in [
463        "NX_SET_CLI",
464        "NX_ORIGINAL_FORCE_COLOR",
465        "NX_TERMINAL_OUTPUT_PATH",
466        "NX_STREAM_OUTPUT",
467        "NX_PREFIX_OUTPUT",
468    ] {
469        env.remove(std::ffi::OsStr::new(name));
470    }
471    let mut set = |name: &str, value: OsString| {
472        env.insert(name.into(), value);
473    };
474    set("NX_WORKSPACE_ROOT", workspace.root.clone().into_os_string());
475    set("NX_TASK_TARGET_PROJECT", task.project.clone().into());
476    set("NX_TASK_TARGET_TARGET", task.target.clone().into());
477    set("LERNA_PACKAGE_NAME", task.project.clone().into());
478    set("NX_TUI", "false".into());
479    // The qk and Nx processes this task runs inside, outermost first.
480    let ancestors = match base_env.get(std::ffi::OsStr::new("NX_INVOCATION_ANCESTOR_PIDS")) {
481        Some(inherited) if !inherited.is_empty() => {
482            format!("{},{}", inherited.to_string_lossy(), std::process::id())
483        }
484        _ => std::process::id().to_string(),
485    };
486    set("NX_INVOCATION_ANCESTOR_PIDS", ancestors.into());
487    // The tasks this one runs inside, outermost first, so a run it starts can
488    // tell when it would run one of them again.
489    let mut chain = base_env
490        .get(std::ffi::OsStr::new(INVOCATIONS))
491        .map(|chain| chain.to_string_lossy().into_owned())
492        .unwrap_or_default();
493    if !chain.is_empty() {
494        chain.push('\n');
495    }
496    chain.push_str(&invocation(task));
497    set(INVOCATIONS, chain.into());
498    if task.target == "test" {
499        set("NX_TERMINAL_CAPTURE_STDERR", "true".into());
500    }
501    match &task.configuration {
502        Some(configuration) => set("NX_TASK_TARGET_CONFIGURATION", configuration.clone().into()),
503        None => {
504            env.remove(std::ffi::OsStr::new("NX_TASK_TARGET_CONFIGURATION"));
505        }
506    }
507    Ok((env, runtime_env))
508}
509
510/// Whether dotenv files load: as in Nx, `NX_LOAD_DOT_ENV_FILES=false` turns
511/// them all off.
512fn loads_dotenv(env: &BTreeMap<OsString, OsString>) -> bool {
513    env.get(std::ffi::OsStr::new("NX_LOAD_DOT_ENV_FILES"))
514        .is_none_or(|value| value != "false")
515}
516
517/// The process environment with the root dotenv files loaded into it, as
518/// Nx loads them into its own process at startup, unless
519/// `NX_LOAD_DOT_ENV_FILES=false`. Variables already set win.
520pub fn environment(workspace: &Path) -> Result<BTreeMap<OsString, OsString>> {
521    let mut values: BTreeMap<OsString, OsString> = std::env::vars_os().collect();
522    if loads_dotenv(&values) {
523        let files: Vec<PathBuf> = ROOT_DOTENV_FILES
524            .iter()
525            .map(|file| workspace.join(file))
526            .collect();
527        dotenv::load(&files, &mut values)?;
528    }
529    Ok(values)
530}
531
532pub fn prepare(
533    workspace: &Workspace,
534    task: &Task,
535    base_env: &BTreeMap<OsString, OsString>,
536) -> Result<PreparedTask> {
537    let definition = &task.definition;
538    let executor = definition
539        .executor
540        .as_deref()
541        .context("target has no executor or command")?;
542    let project = &workspace.projects[&task.project];
543    let run_commands = executor == "nx:run-commands";
544    if !matches!(executor, "nx:run-commands" | "nx:run-script" | "nx:noop") {
545        bail!("unsupported executor {executor:?}");
546    }
547    // As in Nx, run-script reads only `script`, and noop nothing.
548    let options = if run_commands {
549        run_commands_overrides(&definition.options, &task.args, project)?
550    } else {
551        definition.options.clone()
552    };
553    let options = &options;
554    let empty = Map::new();
555    let mut interpolation = if run_commands {
556        interpolate::Interpolation::new(&task.args, options)?
557    } else {
558        interpolate::Interpolation::new(&[], &empty)?
559    };
560    let (mut env, mut runtime_env) = task_environment(workspace, task, base_env)?;
561    // As in Nx 24, `envFile` sets what the task's environment, dotenv files
562    // included, leaves unset or empty, beneath `env`; and the task's
563    // environment says whether it loads.
564    if let Some(file) = options.get("envFile").filter(|_| run_commands) {
565        let file = file.as_str().context("envFile must be a string")?;
566        let path = workspace.root.join(file);
567        if loads_dotenv(&runtime_env) {
568            if !path.is_file() {
569                bail!("envFile {file} does not exist");
570            }
571            dotenv::load(std::slice::from_ref(&path), &mut runtime_env)?;
572            dotenv::load(std::slice::from_ref(&path), &mut env)?;
573        }
574    }
575    // A target-level `env` is a qk extension, for every executor.
576    let option_env = options.get("env").filter(|_| run_commands);
577    for values in [definition.extra.get("env"), option_env]
578        .into_iter()
579        .flatten()
580    {
581        for (name, value) in values.as_object().context("env must be an object")? {
582            if name.is_empty() || name.contains(['=', '\0']) {
583                bail!("invalid environment variable name");
584            }
585            let value: OsString = value
586                .as_str()
587                .context("environment values must be strings")?
588                .into();
589            let name = environment_key(&env, name.into());
590            runtime_env.insert(name.clone(), value.clone());
591            env.insert(name, value);
592        }
593    }
594    interpolation.set_env(&env);
595    let mut execution = BTreeMap::new();
596    execution.insert(
597        "FORCE_COLOR".into(),
598        env.get(std::ffi::OsStr::new("FORCE_COLOR"))
599            .cloned()
600            .unwrap_or_else(|| "true".into()),
601    );
602    if run_commands && boolean(options.get("color"), false, "color")? {
603        execution.insert("FORCE_COLOR".into(), "true".into());
604    }
605    // run-commands runs where `cwd` says, the workspace root by default, and
606    // run-script in the project root.
607    let cwd = match options.get("cwd").filter(|_| run_commands) {
608        Some(cwd) => cwd.as_str().context("cwd must be a string")?,
609        None if run_commands => ".",
610        None => &project.root,
611    };
612    let cwd = workspace.root.join(cwd);
613    let mut resolved = PathBuf::new();
614    for part in cwd.components() {
615        match part {
616            std::path::Component::ParentDir => {
617                resolved.pop();
618            }
619            std::path::Component::CurDir => {}
620            _ => resolved.push(part.as_os_str()),
621        }
622    }
623    // Expose local binaries even for plain run-commands targets.
624    let mut paths = Vec::new();
625    for directory in resolved.ancestors() {
626        paths.push(directory.join("node_modules/.bin"));
627    }
628    let node_path = NodePath::new(&workspace.root, base_env, paths.len());
629    let path_key = path_key(&env);
630    if let Some(path) = env.get(&path_key) {
631        paths.extend(std::env::split_paths(path));
632    }
633    let path = std::env::join_paths(paths).context("cannot construct task PATH")?;
634    env.insert(path_key.clone(), path.clone());
635    runtime_env.insert(path_key, path);
636    let ready_when: Vec<String> = match options.get("readyWhen").filter(|_| run_commands) {
637        None | Some(Value::Null) => Vec::new(),
638        Some(Value::String(text)) => vec![text.clone()],
639        Some(value) => serde_json::from_value(value.clone())
640            .context("readyWhen must be a string or an array of strings")?,
641    };
642    // As in Nx, a single `command` runs in parallel exactly when it has
643    // `readyWhen`, whatever `parallel` says.
644    let parallel = if run_commands && options.get("command").is_some_and(merge_truthy) {
645        !ready_when.is_empty()
646    } else if run_commands {
647        boolean(options.get("parallel"), true, "parallel")?
648    } else {
649        true
650    };
651    let (mut pseudo_terminal, tty) = if run_commands {
652        (
653            boolean(options.get("usePty"), true, "usePty")?
654                && base_env
655                    .get(std::ffi::OsStr::new("NX_NATIVE_COMMAND_RUNNER"))
656                    .is_none_or(|value| value != "false"),
657            boolean(options.get("tty"), true, "tty")?,
658        )
659    } else {
660        (true, true)
661    };
662    let mut decorations = Vec::new();
663    if !ready_when.is_empty() && !parallel {
664        bail!("readyWhen can only be used when parallel is true");
665    }
666    let mut commands = Vec::new();
667    match executor {
668        "nx:noop" => {}
669        "nx:run-script" => {
670            let script = options
671                .get("script")
672                .and_then(Value::as_str)
673                .context("run-script requires options.script")?;
674            let package = workspace
675                .packages
676                .get(&task.project)
677                .context("run-script requires a package.json")?;
678            if !package.scripts.contains_key(script) {
679                bail!("package.json has no script {script:?}");
680            }
681            let manager = package_manager(workspace)?;
682            // Nx joins the arguments as they are; qk quotes each, so that
683            // one with a space or a shell character stays one argument.
684            let args = interpolate::quoted_arguments(&task.args)?;
685            let script = interpolate::quote(script)?;
686            // Nx's `run` for each: npm and bun need `--` before arguments,
687            // and yarn runs a script by name.
688            let mut command = match manager {
689                "yarn" => format!("yarn {script}"),
690                manager => format!("{manager} run {script}"),
691            };
692            if !args.is_empty() {
693                if matches!(manager, "npm" | "bun") {
694                    command.push_str(" --");
695                }
696                command.push(' ');
697                command.push_str(&args);
698            }
699            commands.push(command);
700        }
701        "nx:run-commands" => {
702            let forward = boolean(options.get("forwardAllArgs"), true, "forwardAllArgs")?;
703            // As in Nx, `command` wins over `commands`.
704            if let Some(command) = options
705                .get("command")
706                .filter(|command| merge_truthy(command))
707            {
708                // Nx also takes an array, joined with spaces.
709                let command = match command {
710                    Value::String(command) => command.clone(),
711                    Value::Array(words) => words
712                        .iter()
713                        .map(|word| word.as_str().context("command words must be strings"))
714                        .collect::<Result<Vec<_>>>()?
715                        .join(" "),
716                    _ => bail!("command must be a string or an array of strings"),
717                };
718                commands.push(interpolation.command(&command, forward)?);
719            } else if let Some(values) = options.get("commands") {
720                for value in values.as_array().context("commands must be an array")? {
721                    let (command, forward) = if let Some(command) = value.as_str() {
722                        (command, forward)
723                    } else {
724                        let value = value
725                            .as_object()
726                            .context("commands entries must be strings or objects")?;
727                        for key in value.keys() {
728                            if !matches!(
729                                key.as_str(),
730                                "command"
731                                    | "forwardAllArgs"
732                                    | "prefix"
733                                    | "prefixColor"
734                                    | "color"
735                                    | "bgColor"
736                                    | "description"
737                            ) {
738                                bail!("unsupported command entry field {key:?}");
739                            }
740                        }
741                        let field =
742                            |name: &str, background: Option<bool>| -> Result<Option<String>> {
743                                let Some(value) = value.get(name) else {
744                                    return Ok(None);
745                                };
746                                let text = value.as_str().with_context(|| {
747                                    format!("command entry {name} must be a string")
748                                })?;
749                                if let Some(background) = background
750                                    && !Decoration::valid(text, background)
751                                {
752                                    bail!("command entry {name} {text:?} is not a colour");
753                                }
754                                Ok(Some(text.to_owned()))
755                            };
756                        let decoration = Decoration {
757                            prefix: field("prefix", None)?,
758                            prefix_color: field("prefixColor", Some(false))?,
759                            color: field("color", Some(false))?,
760                            bg_color: field("bgColor", Some(true))?,
761                        };
762                        if !decoration.is_empty() && !parallel {
763                            bail!(
764                                "prefix, prefixColor, color and bgColor can only be set when parallel is true"
765                            );
766                        }
767                        decorations.resize(commands.len(), Decoration::default());
768                        decorations.push(decoration);
769                        (
770                            value
771                                .get("command")
772                                .and_then(Value::as_str)
773                                .context("command entry requires a command string")?,
774                            boolean(value.get("forwardAllArgs"), forward, "forwardAllArgs")?,
775                        )
776                    };
777                    commands.push(interpolation.command(command, forward)?);
778                }
779            } else {
780                bail!("run-commands requires command or commands");
781            }
782            // An empty `commands` succeeds without running anything, as in Nx.
783            if commands.iter().any(|command| command.trim().is_empty()) {
784                bail!("run-commands requires nonempty commands");
785            }
786        }
787        _ => bail!("unsupported executor {executor:?}"),
788    }
789    // As in Nx, commands run side by side share qk's output through pipes.
790    pseudo_terminal &= commands.len() == 1 || !parallel;
791    let ready_check = ready_check(workspace, task, options, &env)?;
792    Ok(PreparedTask {
793        id: task.id.clone(),
794        commands,
795        parallel,
796        cwd,
797        env,
798        runtime_env,
799        node_path: Some(node_path),
800        execution,
801        ready_when,
802        ready: Default::default(),
803        ready_check,
804        verdict: Default::default(),
805        processes: Default::default(),
806        decorations,
807        interactive: false,
808        pseudo_terminal,
809        tty,
810        sandbox: None,
811        recording: None,
812        display: Display::default(),
813    })
814}
815
816/// A continuous target's `readyWhen`, resolved against the options and the
817/// environment the task runs with, as Nx 24 resolves it. `null` turns off one
818/// a target default sets. A target that is not continuous has none.
819fn ready_check(
820    workspace: &Workspace,
821    task: &Task,
822    options: &Map<String, Value>,
823    env: &BTreeMap<OsString, OsString>,
824) -> Result<Option<std::result::Result<readiness::ReadyCheck, String>>> {
825    let Some(ready_when) = task
826        .definition
827        .extra
828        .get("readyWhen")
829        .filter(|value| !value.is_null() && task.definition.continuous == Some(true))
830    else {
831        return Ok(None);
832    };
833    let mut options = options.clone();
834    let project = &workspace.projects[&task.project];
835    for (key, value) in interpolate::yargs(&task.args, false, false) {
836        if key == "_" {
837            continue;
838        }
839        // As in Nx, an argument's tokens resolve for the task's project.
840        let value = match value {
841            Value::String(text) => Value::String(qk_config::interpolate_override(&text, project)?),
842            value => value,
843        };
844        if key.contains('-') {
845            options.insert(interpolate::camel_case(&key), value.clone());
846        }
847        options.insert(key, value);
848    }
849    let env: BTreeMap<String, String> = env
850        .iter()
851        .filter_map(|(name, value)| Some((name.to_str()?.to_owned(), value.to_str()?.to_owned())))
852        .collect();
853    let scope = readiness::Scope {
854        workspace_root: workspace
855            .root
856            .to_str()
857            .context("workspace path must be UTF-8")?,
858        project_root: &workspace.projects[&task.project].root,
859        project_name: &task.project,
860        options: &options,
861        env: &env,
862    };
863    Ok(readiness::resolve(&task.id, ready_when, &scope)
864        .map_err(|error| format!("{error:#}"))
865        .transpose())
866}
867
868/// The run-commands options Nx reads, which a task's arguments may set too.
869const OPTION_FLAGS: &[&str] = &[
870    "command",
871    "commands",
872    "color",
873    "parallel",
874    "readyWhen",
875    "cwd",
876    "args",
877    "envFile",
878    "__unparsed__",
879    "env",
880    "usePty",
881    "streamOutput",
882    "verbose",
883    "forwardAllArgs",
884    "tty",
885];
886
887/// The target's options with the task's arguments that name a run-commands
888/// option applied. As in Nx, `--cwd=dir`, `--no-parallel` or
889/// `--env.NAME=value` set the option rather than reaching the command.
890fn run_commands_overrides(
891    options: &serde_json::Map<String, Value>,
892    args: &[String],
893    project: &qk_config::Project,
894) -> Result<serde_json::Map<String, Value>> {
895    const BOOLEAN: &[&str] = &[
896        "color",
897        "parallel",
898        "usePty",
899        "streamOutput",
900        "verbose",
901        "forwardAllArgs",
902        "tty",
903    ];
904    let mut options = options.clone();
905    let mut ready = Vec::new();
906    let mut args = args.iter().peekable();
907    while let Some(arg) = args.next() {
908        let Some(flag) = arg.strip_prefix("--") else {
909            continue;
910        };
911        let (key, value) = match flag.split_once('=') {
912            Some((key, value)) => (key, Some(value.to_owned())),
913            None => (flag, None),
914        };
915        let (key, negated) = match key.strip_prefix("no-") {
916            Some(key) if OPTION_FLAGS.contains(&key) => (key, true),
917            _ => (key, false),
918        };
919        let (base, field) = match key.split_once('.') {
920            Some((base, field)) => (base, Some(field)),
921            None => (key, None),
922        };
923        if !OPTION_FLAGS.contains(&base) {
924            continue;
925        }
926        let boolean = BOOLEAN.contains(&base);
927        let value = match value {
928            Some(value) => value,
929            None if negated => "false".into(),
930            None if !boolean && args.peek().is_some_and(|next| !next.starts_with('-')) => {
931                args.next().unwrap().clone()
932            }
933            None => "true".into(),
934        };
935        let value = if boolean {
936            Value::Bool(value != "false")
937        } else {
938            // As in Nx, an argument's tokens resolve for the task's project.
939            Value::String(qk_config::interpolate_override(&value, project)?)
940        };
941        match (base, field) {
942            ("env", Some(name)) => {
943                let env = options
944                    .entry("env")
945                    .or_insert_with(|| Value::Object(Default::default()));
946                if let Value::Object(env) = env {
947                    env.insert(name.to_owned(), value);
948                }
949            }
950            ("readyWhen", None) => ready.push(value),
951            // Nx sets these itself, and a list cannot come from a flag.
952            ("__unparsed__" | "commands", _) | (_, Some(_)) => {}
953            (base, None) => {
954                options.insert(base.to_owned(), value);
955            }
956        }
957    }
958    if !ready.is_empty() {
959        let ready = if ready.len() == 1 {
960            ready.pop().unwrap()
961        } else {
962            Value::Array(ready)
963        };
964        options.insert("readyWhen".into(), ready);
965    }
966    Ok(options)
967}
968
969/// JavaScript truthiness, which decides what Nx reads as set.
970fn merge_truthy(value: &Value) -> bool {
971    match value {
972        Value::Null => false,
973        Value::Bool(flag) => *flag,
974        Value::Number(number) => number.as_f64().is_some_and(|number| number != 0.0),
975        Value::String(text) => !text.is_empty(),
976        Value::Array(_) | Value::Object(_) => true,
977    }
978}
979
980fn boolean(value: Option<&Value>, default: bool, name: &str) -> Result<bool> {
981    value
982        .map(|value| {
983            value
984                .as_bool()
985                .with_context(|| format!("{name} must be boolean"))
986        })
987        .transpose()
988        .map(|value| value.unwrap_or(default))
989}
990
991fn package_manager(workspace: &Workspace) -> Result<&'static str> {
992    workspace.package_manager_name()
993}
994
995#[cfg(all(test, windows))]
996mod dotenv_tests {
997    #[test]
998    fn colon_targets_keep_configuration_and_generic_dotenv_files() {
999        let files = super::dotenv_files("app", "test:unit:watch", Some("ci"), None);
1000        assert!(files.iter().all(|file| !file.contains(':')));
1001        assert!(files.contains(&"app/.env.ci".to_owned()));
1002        assert!(files.contains(&"app/.env".to_owned()));
1003        assert!(files.contains(&".env.ci".to_owned()));
1004        assert!(files.contains(&".env".to_owned()));
1005    }
1006}