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    // 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        processes: Default::default(),
718        decorations,
719        interactive: false,
720        sandbox: None,
721        recording: None,
722        display: Display::default(),
723    })
724}
725
726/// The run-commands options Nx reads, which a task's arguments may set too.
727const OPTION_FLAGS: &[&str] = &[
728    "command",
729    "commands",
730    "color",
731    "parallel",
732    "readyWhen",
733    "cwd",
734    "args",
735    "envFile",
736    "__unparsed__",
737    "env",
738    "usePty",
739    "streamOutput",
740    "verbose",
741    "forwardAllArgs",
742    "tty",
743];
744
745/// The target's options with the task's arguments that name a run-commands
746/// option applied, and the arguments left for the command. As in Nx,
747/// `--cwd=dir`, `--no-parallel` or `--env.NAME=value` set the option instead
748/// of reaching the command.
749fn run_commands_overrides(
750    options: &serde_json::Map<String, Value>,
751    args: &[String],
752) -> (serde_json::Map<String, Value>, Vec<String>) {
753    const BOOLEAN: &[&str] = &[
754        "color",
755        "parallel",
756        "usePty",
757        "streamOutput",
758        "verbose",
759        "forwardAllArgs",
760        "tty",
761    ];
762    let mut options = options.clone();
763    let mut rest = Vec::new();
764    let mut ready = Vec::new();
765    let mut args = args.iter().peekable();
766    while let Some(arg) = args.next() {
767        let Some(flag) = arg.strip_prefix("--") else {
768            rest.push(arg.clone());
769            continue;
770        };
771        let (key, value) = match flag.split_once('=') {
772            Some((key, value)) => (key, Some(value.to_owned())),
773            None => (flag, None),
774        };
775        let (key, negated) = match key.strip_prefix("no-") {
776            Some(key) if OPTION_FLAGS.contains(&key) => (key, true),
777            _ => (key, false),
778        };
779        let (base, field) = match key.split_once('.') {
780            Some((base, field)) => (base, Some(field)),
781            None => (key, None),
782        };
783        if !OPTION_FLAGS.contains(&base) {
784            rest.push(arg.clone());
785            continue;
786        }
787        let boolean = BOOLEAN.contains(&base);
788        let value = match value {
789            Some(value) => value,
790            None if negated => "false".into(),
791            None if !boolean && args.peek().is_some_and(|next| !next.starts_with('-')) => {
792                args.next().unwrap().clone()
793            }
794            None => "true".into(),
795        };
796        let value = if boolean {
797            Value::Bool(value != "false")
798        } else {
799            Value::String(value)
800        };
801        match (base, field) {
802            ("env", Some(name)) => {
803                let env = options
804                    .entry("env")
805                    .or_insert_with(|| Value::Object(Default::default()));
806                if let Value::Object(env) = env {
807                    env.insert(name.to_owned(), value);
808                }
809            }
810            ("readyWhen", None) => ready.push(value),
811            // Nx sets these itself, and a list cannot come from a flag.
812            ("__unparsed__" | "commands", _) | (_, Some(_)) => {}
813            (base, None) => {
814                options.insert(base.to_owned(), value);
815            }
816        }
817    }
818    if !ready.is_empty() {
819        let ready = if ready.len() == 1 {
820            ready.pop().unwrap()
821        } else {
822            Value::Array(ready)
823        };
824        options.insert("readyWhen".into(), ready);
825    }
826    (options, rest)
827}
828
829fn boolean(value: Option<&Value>, default: bool, name: &str) -> Result<bool> {
830    value
831        .map(|value| {
832            value
833                .as_bool()
834                .with_context(|| format!("{name} must be boolean"))
835        })
836        .transpose()
837        .map(|value| value.unwrap_or(default))
838}
839
840/// The package manager, as Nx detects it: nx.json `cli.packageManager`,
841/// then the lockfile, then the package manager qk was invoked through. qk
842/// also reads the root `packageManager` before that last step.
843fn package_manager(workspace: &Workspace) -> Result<&'static str> {
844    let known = |name: &str| -> Result<&'static str> {
845        Ok(match name {
846            "npm" => "npm",
847            "pnpm" => "pnpm",
848            "yarn" => "yarn",
849            "bun" => "bun",
850            other => bail!("unknown package manager {other:?}"),
851        })
852    };
853    if let Some(name) = workspace
854        .config
855        .extra
856        .get("cli")
857        .and_then(|cli| cli.get("packageManager"))
858        .and_then(Value::as_str)
859    {
860        return known(name);
861    }
862    let root = &workspace.root;
863    for (file, manager) in [
864        ("bun.lockb", "bun"),
865        ("bun.lock", "bun"),
866        ("yarn.lock", "yarn"),
867        ("pnpm-lock.yaml", "pnpm"),
868        ("package-lock.json", "npm"),
869    ] {
870        if root.join(file).is_file() {
871            return Ok(manager);
872        }
873    }
874    if let Some(declared) = &workspace.package_manager {
875        return known(declared.split('@').next().unwrap_or(declared));
876    }
877    if root.join("pnpm-workspace.yaml").is_file() {
878        return Ok("pnpm");
879    }
880    let agent = std::env::var("npm_config_user_agent").unwrap_or_default();
881    for manager in ["pnpm", "yarn", "bun"] {
882        if agent.starts_with(&format!("{manager}/")) {
883            return Ok(manager);
884        }
885    }
886    Ok("npm")
887}
888
889#[cfg(all(test, windows))]
890mod dotenv_tests {
891    #[test]
892    fn colon_targets_keep_configuration_and_generic_dotenv_files() {
893        let files = super::dotenv_files("app", "test:unit:watch", Some("ci"));
894        assert!(files.iter().all(|file| !file.contains(':')));
895        assert!(files.contains(&"app/.env.ci".to_owned()));
896        assert!(files.contains(&"app/.env".to_owned()));
897        assert!(files.contains(&".env.ci".to_owned()));
898        assert!(files.contains(&".env".to_owned()));
899    }
900}