Skip to main content

qk_executor/
lib.rs

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