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    /// Each command's prefix and colours, by position; empty without any.
255    pub decorations: Vec<Decoration>,
256    /// Whether the task reads qk's stdin, and has the terminal while it runs.
257    /// The runner sets it for the one task a run is for, when that task has a
258    /// single command whose output goes straight to the terminal.
259    pub interactive: bool,
260    /// What the commands run under, for `--sandbox`.
261    pub sandbox: Option<Confinement>,
262    pub recording: Option<Recording>,
263    /// How the task's output is shown; the runner sets it per output style.
264    pub display: Display,
265}
266
267/// The dotenv files Nx loads for a task, most specific first: in the
268/// project root, then the workspace root, for the target and configuration
269/// (`.env.<target>.<configuration>`, `.env.<configuration>`, `.env.<target>`),
270/// then `.env.local`, `.local.env` and `.env`, each also in its `.local` and
271/// `.<name>.env` spellings.
272fn dotenv_files(project_root: &str, target: &str, configuration: Option<&str>) -> Vec<String> {
273    let mut identifiers = Vec::new();
274    if let Some(configuration) = configuration {
275        identifiers.push(format!("{target}.{configuration}"));
276        identifiers.push(configuration.to_owned());
277    }
278    identifiers.push(target.to_owned());
279    identifiers.push(String::new());
280    let variants = |identifier: &str, root: &str| -> Vec<String> {
281        let prefix = if root.is_empty() || root == "." {
282            String::new()
283        } else {
284            format!("{root}/")
285        };
286        if identifier.is_empty() {
287            [".env.local", ".local.env", ".env"]
288                .map(|name| format!("{prefix}{name}"))
289                .to_vec()
290        } else {
291            [
292                format!(".env.{identifier}.local"),
293                format!(".env.{identifier}"),
294                format!(".{identifier}.local.env"),
295                format!(".{identifier}.env"),
296            ]
297            .map(|name| format!("{prefix}{name}"))
298            .to_vec()
299        }
300    };
301    let mut files = Vec::new();
302    for root in [project_root, ""] {
303        for identifier in &identifiers {
304            // Colons in target names cannot name regular dotenv files on Windows.
305            #[cfg(windows)]
306            if identifier.contains(':') {
307                continue;
308            }
309            for file in variants(identifier, root) {
310                if !files.contains(&file) {
311                    files.push(file);
312                }
313            }
314        }
315    }
316    files
317}
318
319/// A task's environment as Nx builds it: the process environment, then each
320/// of the task's dotenv files without overriding what is already set, then
321/// the variables Nx sets for every task. A target's `env` applies on top.
322fn task_environment(
323    workspace: &Workspace,
324    task: &Task,
325    base_env: &BTreeMap<OsString, OsString>,
326) -> Result<(BTreeMap<OsString, OsString>, BTreeMap<OsString, OsString>)> {
327    let mut env = base_env.clone();
328    let root = &workspace.projects[&task.project].root;
329    let files = if loads_dotenv(base_env) {
330        dotenv_files(root, &task.target, task.configuration.as_deref())
331    } else {
332        Vec::new()
333    };
334    for file in files {
335        for (name, value) in read_dotenv(&workspace.root.join(file))? {
336            let name = environment_key(&env, name);
337            env.entry(name).or_insert(value);
338        }
339    }
340    let runtime_env = env.clone();
341    let mut set = |name: &str, value: OsString| {
342        env.insert(name.into(), value);
343    };
344    set("NX_WORKSPACE_ROOT", workspace.root.clone().into_os_string());
345    set("NX_TASK_TARGET_PROJECT", task.project.clone().into());
346    set("NX_TASK_TARGET_TARGET", task.target.clone().into());
347    set("LERNA_PACKAGE_NAME", task.project.clone().into());
348    set("NX_TUI", "false".into());
349    match &task.configuration {
350        Some(configuration) => set("NX_TASK_TARGET_CONFIGURATION", configuration.clone().into()),
351        None => {
352            env.remove(std::ffi::OsStr::new("NX_TASK_TARGET_CONFIGURATION"));
353        }
354    }
355    Ok((env, runtime_env))
356}
357
358/// Whether dotenv files load: as in Nx, `NX_LOAD_DOT_ENV_FILES=false` turns
359/// them all off.
360fn loads_dotenv(env: &BTreeMap<OsString, OsString>) -> bool {
361    env.get(std::ffi::OsStr::new("NX_LOAD_DOT_ENV_FILES"))
362        .is_none_or(|value| value != "false")
363}
364
365/// A dotenv file's entries, or none when it does not exist.
366fn read_dotenv(path: &Path) -> Result<Vec<(OsString, OsString)>> {
367    let file = match std::fs::File::open(path) {
368        Ok(file) => file,
369        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
370        Err(error) => return Err(error).with_context(|| format!("cannot read {}", path.display())),
371    };
372    dotenvy::from_read_iter(file)
373        .enumerate()
374        .map(|(index, entry)| {
375            // Dotenv parser errors can contain secret values; report only location.
376            let (name, value) = entry.map_err(|_| {
377                anyhow::anyhow!("invalid dotenv entry {} in {}", index + 1, path.display())
378            })?;
379            Ok((name.into(), value.into()))
380        })
381        .collect()
382}
383
384/// Read dotenv files into a child environment without changing process globals.
385/// Existing process variables win over .env.local, which wins over .env.
386pub fn environment(workspace: &Path) -> Result<BTreeMap<OsString, OsString>> {
387    let mut values = BTreeMap::new();
388    let process: BTreeMap<OsString, OsString> = std::env::vars_os().collect();
389    let names: &[&str] = if loads_dotenv(&process) {
390        &[".env", ".env.local"]
391    } else {
392        &[]
393    };
394    for name in names {
395        let path = workspace.join(name);
396        let file = match std::fs::File::open(&path) {
397            Ok(file) => file,
398            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
399            Err(error) => {
400                return Err(error).with_context(|| format!("cannot read {}", path.display()));
401            }
402        };
403        for (index, entry) in dotenvy::from_read_iter(file).enumerate() {
404            // Dotenv parser errors can contain secret values; report only location.
405            let (name, value) = entry.map_err(|_| {
406                anyhow::anyhow!("invalid dotenv entry {} in {}", index + 1, path.display())
407            })?;
408            values.insert(name.into(), value.into());
409        }
410    }
411    values.extend(std::env::vars_os());
412    Ok(values)
413}
414
415pub fn prepare(
416    workspace: &Workspace,
417    task: &Task,
418    base_env: &BTreeMap<OsString, OsString>,
419) -> Result<PreparedTask> {
420    let definition = &task.definition;
421    let executor = definition
422        .executor
423        .as_deref()
424        .context("target has no executor or command")?;
425    let (options, args) = if executor == "nx:run-commands" {
426        run_commands_overrides(&definition.options, &task.args)
427    } else {
428        (definition.options.clone(), task.args.clone())
429    };
430    let options = &options;
431    // Options Nx's run-commands knows; any other scalar option is forwarded to
432    // the command as `--name=value`, and object values are ignored, as in Nx.
433    const RUN_COMMANDS: &[&str] = &[
434        "command",
435        "commands",
436        "color",
437        "no-color",
438        "parallel",
439        "no-parallel",
440        "readyWhen",
441        "cwd",
442        "args",
443        "envFile",
444        "__unparsed__",
445        "env",
446        "usePty",
447        "streamOutput",
448        "verbose",
449        "forwardAllArgs",
450        "tty",
451    ];
452    let mut forwarded = BTreeMap::new();
453    if executor == "nx:run-commands" {
454        for (key, value) in options {
455            if RUN_COMMANDS.contains(&key.as_str()) {
456                continue;
457            }
458            let value = match value {
459                Value::String(text) => text.clone(),
460                Value::Number(number) => number.to_string(),
461                Value::Bool(flag) => flag.to_string(),
462                _ => continue,
463            };
464            forwarded.insert(key.clone(), value);
465        }
466    }
467    let extra = match options.get("args") {
468        None | Some(Value::Null) => None,
469        Some(Value::String(text)) => Some(text.clone()),
470        Some(Value::Array(words)) => Some(
471            words
472                .iter()
473                .map(|word| word.as_str().context("args entries must be strings"))
474                .collect::<Result<Vec<_>>>()?
475                .join(" "),
476        ),
477        Some(_) => bail!("args must be a string or an array of strings"),
478    };
479    let interpolation =
480        interpolate::Interpolation::new(workspace, task, args, &forwarded, extra.as_deref())?;
481    let (mut env, mut runtime_env) = task_environment(workspace, task, base_env)?;
482    // As in Nx, `envFile` sets what is not set yet, beneath `env`.
483    if let Some(file) = options.get("envFile") {
484        let file = interpolation.text(file.as_str().context("envFile must be a string")?)?;
485        let path = workspace.root.join(&file);
486        if loads_dotenv(base_env) {
487            if !path.is_file() {
488                bail!("envFile {file} does not exist");
489            }
490            for (name, value) in read_dotenv(&path)? {
491                let name = environment_key(&env, name);
492                runtime_env.entry(name.clone()).or_insert(value.clone());
493                env.entry(name).or_insert(value);
494            }
495        }
496    }
497    for values in [definition.extra.get("env"), options.get("env")]
498        .into_iter()
499        .flatten()
500    {
501        for (name, value) in values.as_object().context("env must be an object")? {
502            if name.is_empty() || name.contains(['=', '\0']) {
503                bail!("invalid environment variable name");
504            }
505            let value = value
506                .as_str()
507                .context("environment values must be strings")?;
508            let value: OsString = interpolation.text(value)?.into();
509            let name = environment_key(&env, name.into());
510            runtime_env.insert(name.clone(), value.clone());
511            env.insert(name, value);
512        }
513    }
514    let mut execution = BTreeMap::new();
515    execution.insert(
516        "FORCE_COLOR".into(),
517        env.get(std::ffi::OsStr::new("FORCE_COLOR"))
518            .cloned()
519            .unwrap_or_else(|| "true".into()),
520    );
521    if executor == "nx:run-commands" && boolean(options.get("color"), false, "color")? {
522        execution.insert("FORCE_COLOR".into(), "true".into());
523    }
524    for key in options.keys() {
525        let allowed = match executor {
526            // Every run-commands option is supported. `tty`, `usePty`,
527            // `streamOutput` and `verbose` have no effect: qk never gives
528            // commands a terminal, and Nx sets the others itself.
529            "nx:run-commands" => true,
530            "nx:run-script" => matches!(key.as_str(), "script" | "env" | "cwd"),
531            "nx:noop" => true,
532            _ => bail!("unsupported executor {executor:?}"),
533        };
534        if !allowed {
535            bail!("unsupported {executor} option {key:?}");
536        }
537    }
538    let default_cwd = if executor == "nx:run-script" {
539        &workspace.projects[&task.project].root
540    } else {
541        "."
542    };
543    let cwd = options
544        .get("cwd")
545        .map(|value| value.as_str().context("cwd must be a string"))
546        .transpose()?
547        .unwrap_or(default_cwd);
548    let cwd = workspace.root.join(interpolation.text(cwd)?);
549    let mut resolved = PathBuf::new();
550    for part in cwd.components() {
551        match part {
552            std::path::Component::ParentDir => {
553                resolved.pop();
554            }
555            std::path::Component::CurDir => {}
556            _ => resolved.push(part.as_os_str()),
557        }
558    }
559    if !cwd.is_dir() {
560        bail!("working directory does not exist: {}", cwd.display());
561    }
562    // Expose local binaries even for plain run-commands targets.
563    let mut paths = Vec::new();
564    for directory in resolved.ancestors() {
565        paths.push(directory.join("node_modules/.bin"));
566    }
567    let node_path = NodePath::new(&workspace.root, base_env, paths.len());
568    let path_key = path_key(&env);
569    if let Some(path) = env.get(&path_key) {
570        paths.extend(std::env::split_paths(path));
571    }
572    let path = std::env::join_paths(paths).context("cannot construct task PATH")?;
573    env.insert(path_key.clone(), path.clone());
574    runtime_env.insert(path_key, path);
575    let parallel = boolean(options.get("parallel"), true, "parallel")?;
576    let mut decorations = Vec::new();
577    let ready_when: Vec<String> = match options.get("readyWhen") {
578        None | Some(Value::Null) => Vec::new(),
579        Some(Value::String(text)) => vec![text.clone()],
580        Some(value) => serde_json::from_value(value.clone())
581            .context("readyWhen must be a string or an array of strings")?,
582    };
583    if !ready_when.is_empty() && options.contains_key("commands") && !parallel {
584        bail!("readyWhen can only be used when parallel is true");
585    }
586    let mut commands = Vec::new();
587    match executor {
588        "nx:noop" => {}
589        "nx:run-script" => {
590            let script = options
591                .get("script")
592                .and_then(Value::as_str)
593                .context("run-script requires options.script")?;
594            let package = workspace
595                .packages
596                .get(&task.project)
597                .context("run-script requires a package.json")?;
598            if !package.scripts.contains_key(script) {
599                bail!("package.json has no script {script:?}");
600            }
601            let manager = package_manager(workspace)?;
602            let args = interpolation.arguments()?;
603            let script = interpolate::quote(script)?;
604            // Nx's `run` for each: npm and bun need `--` before arguments,
605            // and yarn runs a script by name.
606            let mut command = match manager {
607                "yarn" => format!("yarn {script}"),
608                manager => format!("{manager} run {script}"),
609            };
610            if !args.is_empty() {
611                if matches!(manager, "npm" | "bun") {
612                    command.push_str(" --");
613                }
614                command.push(' ');
615                command.push_str(&args);
616            }
617            commands.push(command);
618        }
619        "nx:run-commands" => {
620            if options.contains_key("command") && options.contains_key("commands") {
621                bail!("specify command or commands, not both");
622            }
623            let forward = boolean(options.get("forwardAllArgs"), true, "forwardAllArgs")?;
624            if let Some(command) = options.get("command") {
625                // Nx also takes an array, joined with spaces.
626                let command = match command {
627                    Value::String(command) => command.clone(),
628                    Value::Array(words) => words
629                        .iter()
630                        .map(|word| word.as_str().context("command words must be strings"))
631                        .collect::<Result<Vec<_>>>()?
632                        .join(" "),
633                    _ => bail!("command must be a string or an array of strings"),
634                };
635                commands.push(interpolation.command(&command, forward)?);
636            } else if let Some(values) = options.get("commands") {
637                for value in values.as_array().context("commands must be an array")? {
638                    let (command, forward) = if let Some(command) = value.as_str() {
639                        (command, forward)
640                    } else {
641                        let value = value
642                            .as_object()
643                            .context("commands entries must be strings or objects")?;
644                        for key in value.keys() {
645                            if !matches!(
646                                key.as_str(),
647                                "command"
648                                    | "forwardAllArgs"
649                                    | "prefix"
650                                    | "prefixColor"
651                                    | "color"
652                                    | "bgColor"
653                                    | "description"
654                            ) {
655                                bail!("unsupported command entry field {key:?}");
656                            }
657                        }
658                        let field =
659                            |name: &str, background: Option<bool>| -> Result<Option<String>> {
660                                let Some(value) = value.get(name) else {
661                                    return Ok(None);
662                                };
663                                let text = value.as_str().with_context(|| {
664                                    format!("command entry {name} must be a string")
665                                })?;
666                                if let Some(background) = background
667                                    && !Decoration::valid(text, background)
668                                {
669                                    bail!("command entry {name} {text:?} is not a colour");
670                                }
671                                Ok(Some(text.to_owned()))
672                            };
673                        let decoration = Decoration {
674                            prefix: field("prefix", None)?,
675                            prefix_color: field("prefixColor", Some(false))?,
676                            color: field("color", Some(false))?,
677                            bg_color: field("bgColor", Some(true))?,
678                        };
679                        if !decoration.is_empty() && !parallel {
680                            bail!(
681                                "prefix, prefixColor, color and bgColor can only be set when parallel is true"
682                            );
683                        }
684                        decorations.resize(commands.len(), Decoration::default());
685                        decorations.push(decoration);
686                        (
687                            value
688                                .get("command")
689                                .and_then(Value::as_str)
690                                .context("command entry requires a command string")?,
691                            boolean(value.get("forwardAllArgs"), forward, "forwardAllArgs")?,
692                        )
693                    };
694                    commands.push(interpolation.command(command, forward)?);
695                }
696            } else {
697                bail!("run-commands requires command or commands");
698            }
699            // An empty `commands` succeeds without running anything, as in Nx.
700            if commands.iter().any(|command| command.trim().is_empty()) {
701                bail!("run-commands requires nonempty commands");
702            }
703        }
704        _ => bail!("unsupported executor {executor:?}"),
705    }
706    Ok(PreparedTask {
707        id: task.id.clone(),
708        commands,
709        parallel,
710        cwd,
711        env,
712        runtime_env,
713        node_path: Some(node_path),
714        execution,
715        ready_when,
716        ready: Default::default(),
717        decorations,
718        interactive: false,
719        sandbox: None,
720        recording: None,
721        display: Display::default(),
722    })
723}
724
725/// The run-commands options Nx reads, which a task's arguments may set too.
726const OPTION_FLAGS: &[&str] = &[
727    "command",
728    "commands",
729    "color",
730    "parallel",
731    "readyWhen",
732    "cwd",
733    "args",
734    "envFile",
735    "__unparsed__",
736    "env",
737    "usePty",
738    "streamOutput",
739    "verbose",
740    "forwardAllArgs",
741    "tty",
742];
743
744/// The target's options with the task's arguments that name a run-commands
745/// option applied, and the arguments left for the command. As in Nx,
746/// `--cwd=dir`, `--no-parallel` or `--env.NAME=value` set the option instead
747/// of reaching the command.
748fn run_commands_overrides(
749    options: &serde_json::Map<String, Value>,
750    args: &[String],
751) -> (serde_json::Map<String, Value>, Vec<String>) {
752    const BOOLEAN: &[&str] = &[
753        "color",
754        "parallel",
755        "usePty",
756        "streamOutput",
757        "verbose",
758        "forwardAllArgs",
759        "tty",
760    ];
761    let mut options = options.clone();
762    let mut rest = Vec::new();
763    let mut ready = Vec::new();
764    let mut args = args.iter().peekable();
765    while let Some(arg) = args.next() {
766        let Some(flag) = arg.strip_prefix("--") else {
767            rest.push(arg.clone());
768            continue;
769        };
770        let (key, value) = match flag.split_once('=') {
771            Some((key, value)) => (key, Some(value.to_owned())),
772            None => (flag, None),
773        };
774        let (key, negated) = match key.strip_prefix("no-") {
775            Some(key) if OPTION_FLAGS.contains(&key) => (key, true),
776            _ => (key, false),
777        };
778        let (base, field) = match key.split_once('.') {
779            Some((base, field)) => (base, Some(field)),
780            None => (key, None),
781        };
782        if !OPTION_FLAGS.contains(&base) {
783            rest.push(arg.clone());
784            continue;
785        }
786        let boolean = BOOLEAN.contains(&base);
787        let value = match value {
788            Some(value) => value,
789            None if negated => "false".into(),
790            None if !boolean && args.peek().is_some_and(|next| !next.starts_with('-')) => {
791                args.next().unwrap().clone()
792            }
793            None => "true".into(),
794        };
795        let value = if boolean {
796            Value::Bool(value != "false")
797        } else {
798            Value::String(value)
799        };
800        match (base, field) {
801            ("env", Some(name)) => {
802                let env = options
803                    .entry("env")
804                    .or_insert_with(|| Value::Object(Default::default()));
805                if let Value::Object(env) = env {
806                    env.insert(name.to_owned(), value);
807                }
808            }
809            ("readyWhen", None) => ready.push(value),
810            // Nx sets these itself, and a list cannot come from a flag.
811            ("__unparsed__" | "commands", _) | (_, Some(_)) => {}
812            (base, None) => {
813                options.insert(base.to_owned(), value);
814            }
815        }
816    }
817    if !ready.is_empty() {
818        let ready = if ready.len() == 1 {
819            ready.pop().unwrap()
820        } else {
821            Value::Array(ready)
822        };
823        options.insert("readyWhen".into(), ready);
824    }
825    (options, rest)
826}
827
828fn boolean(value: Option<&Value>, default: bool, name: &str) -> Result<bool> {
829    value
830        .map(|value| {
831            value
832                .as_bool()
833                .with_context(|| format!("{name} must be boolean"))
834        })
835        .transpose()
836        .map(|value| value.unwrap_or(default))
837}
838
839/// The package manager, as Nx detects it: nx.json `cli.packageManager`,
840/// then the lockfile, then the package manager qk was invoked through. qk
841/// also reads the root `packageManager` before that last step.
842fn package_manager(workspace: &Workspace) -> Result<&'static str> {
843    let known = |name: &str| -> Result<&'static str> {
844        Ok(match name {
845            "npm" => "npm",
846            "pnpm" => "pnpm",
847            "yarn" => "yarn",
848            "bun" => "bun",
849            other => bail!("unknown package manager {other:?}"),
850        })
851    };
852    if let Some(name) = workspace
853        .config
854        .extra
855        .get("cli")
856        .and_then(|cli| cli.get("packageManager"))
857        .and_then(Value::as_str)
858    {
859        return known(name);
860    }
861    let root = &workspace.root;
862    for (file, manager) in [
863        ("bun.lockb", "bun"),
864        ("bun.lock", "bun"),
865        ("yarn.lock", "yarn"),
866        ("pnpm-lock.yaml", "pnpm"),
867        ("package-lock.json", "npm"),
868    ] {
869        if root.join(file).is_file() {
870            return Ok(manager);
871        }
872    }
873    if let Some(declared) = &workspace.package_manager {
874        return known(declared.split('@').next().unwrap_or(declared));
875    }
876    if root.join("pnpm-workspace.yaml").is_file() {
877        return Ok("pnpm");
878    }
879    let agent = std::env::var("npm_config_user_agent").unwrap_or_default();
880    for manager in ["pnpm", "yarn", "bun"] {
881        if agent.starts_with(&format!("{manager}/")) {
882            return Ok(manager);
883        }
884    }
885    Ok("npm")
886}
887
888#[cfg(all(test, windows))]
889mod dotenv_tests {
890    #[test]
891    fn colon_targets_keep_configuration_and_generic_dotenv_files() {
892        let files = super::dotenv_files("app", "test:unit:watch", Some("ci"));
893        assert!(files.iter().all(|file| !file.contains(':')));
894        assert!(files.contains(&"app/.env.ci".to_owned()));
895        assert!(files.contains(&"app/.env".to_owned()));
896        assert!(files.contains(&".env.ci".to_owned()));
897        assert!(files.contains(&".env".to_owned()));
898    }
899}