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/// Reports a `qk:` warning to the installed sink; by default it is written to
24/// stderr in one write, so task processes sharing stderr cannot split it.
25#[macro_export]
26macro_rules! status {
27    ($($arg:tt)*) => {{
28        $crate::report::warning(&::std::format!($($arg)*));
29    }};
30}
31
32/// How a sandboxed task's commands are held to what it declares.
33#[derive(Clone, Debug)]
34pub enum Confinement {
35    /// macOS: a Seatbelt profile, through `sandbox-exec`.
36    Seatbelt(PathBuf),
37    /// Linux: a Landlock rule set's descriptor, which each command restricts
38    /// itself to before it starts. It stays open while the run lasts.
39    Landlock(i32),
40}
41
42/// An external syscall recorder wrapping each command, with separate trace files.
43#[derive(Clone, Debug)]
44pub struct Recording {
45    pub program: PathBuf,
46    pub directory: PathBuf,
47}
48
49impl Recording {
50    /// The same invocation is used by preflight and actual task commands.
51    pub fn command(&self, index: usize, text: &str) -> std::process::Command {
52        let mut command = std::process::Command::new(&self.program);
53        // No read/write buffers or exec argument/environment arrays.
54        command
55            .args([
56                "-ff",
57                "-ttt",
58                "-yy",
59                "-qq",
60                "-s",
61                "4096",
62                "-e",
63                "trace=%file,%process,getdents64,fchdir,close,dup,dup2,dup3,getcwd",
64                "-e",
65                "raw=execve,execveat",
66                "-o",
67            ])
68            .arg(self.directory.join(format!("command-{index}")))
69            .args(["--", "/bin/sh", "-c", text]);
70        command
71    }
72}
73
74#[derive(Clone, Debug)]
75pub struct PreparedTask {
76    pub id: String,
77    pub commands: Vec<String>,
78    pub parallel: bool,
79    pub cwd: PathBuf,
80    pub env: BTreeMap<OsString, OsString>,
81    /// Variables set for the process after `env` that are not part of what
82    /// the task is: nothing reads them to key it, such as a thread count.
83    pub execution: BTreeMap<OsString, OsString>,
84    /// Nx's `readyWhen`: text whose appearance in the output, every piece of
85    /// it, makes the task ready while its commands keep running.
86    pub ready_when: Vec<String>,
87    /// Set once all of `ready_when` has appeared.
88    pub ready: std::sync::Arc<std::sync::atomic::AtomicBool>,
89    /// Each command's prefix and colours, by position; empty without any.
90    pub decorations: Vec<Decoration>,
91    /// Whether the task reads qk's stdin, and has the terminal while it runs.
92    /// The runner sets it for the one task a run is for, when that task has a
93    /// single command whose output goes straight to the terminal.
94    pub interactive: bool,
95    /// What the commands run under, for `--sandbox`.
96    pub sandbox: Option<Confinement>,
97    pub recording: Option<Recording>,
98    /// How the task's output is shown; the runner sets it per output style.
99    pub display: Display,
100}
101
102/// The dotenv files Nx loads for a task, most specific first: in the
103/// project root, then the workspace root, for the target and configuration
104/// (`.env.<target>.<configuration>`, `.env.<configuration>`, `.env.<target>`),
105/// then `.env.local`, `.local.env` and `.env`, each also in its `.local` and
106/// `.<name>.env` spellings.
107fn dotenv_files(project_root: &str, target: &str, configuration: Option<&str>) -> Vec<String> {
108    let mut identifiers = Vec::new();
109    if let Some(configuration) = configuration {
110        identifiers.push(format!("{target}.{configuration}"));
111        identifiers.push(configuration.to_owned());
112    }
113    identifiers.push(target.to_owned());
114    identifiers.push(String::new());
115    let variants = |identifier: &str, root: &str| -> Vec<String> {
116        let prefix = if root.is_empty() || root == "." {
117            String::new()
118        } else {
119            format!("{root}/")
120        };
121        if identifier.is_empty() {
122            [".env.local", ".local.env", ".env"]
123                .map(|name| format!("{prefix}{name}"))
124                .to_vec()
125        } else {
126            [
127                format!(".env.{identifier}.local"),
128                format!(".env.{identifier}"),
129                format!(".{identifier}.local.env"),
130                format!(".{identifier}.env"),
131            ]
132            .map(|name| format!("{prefix}{name}"))
133            .to_vec()
134        }
135    };
136    let mut files = Vec::new();
137    for root in [project_root, ""] {
138        for identifier in &identifiers {
139            for file in variants(identifier, root) {
140                if !files.contains(&file) {
141                    files.push(file);
142                }
143            }
144        }
145    }
146    files
147}
148
149/// A task's environment as Nx builds it: the process environment, then each
150/// of the task's dotenv files without overriding what is already set, then
151/// the variables Nx sets for every task. A target's `env` applies on top.
152fn task_environment(
153    workspace: &Workspace,
154    task: &Task,
155    base_env: &BTreeMap<OsString, OsString>,
156) -> Result<BTreeMap<OsString, OsString>> {
157    let mut env = base_env.clone();
158    let root = &workspace.projects[&task.project].root;
159    let files = if loads_dotenv(base_env) {
160        dotenv_files(root, &task.target, task.configuration.as_deref())
161    } else {
162        Vec::new()
163    };
164    for file in files {
165        for (name, value) in read_dotenv(&workspace.root.join(file))? {
166            env.entry(name).or_insert(value);
167        }
168    }
169    let force_color = env
170        .get(std::ffi::OsStr::new("FORCE_COLOR"))
171        .cloned()
172        .unwrap_or_else(|| "true".into());
173    let mut set = |name: &str, value: OsString| {
174        env.insert(name.into(), value);
175    };
176    set("FORCE_COLOR", force_color);
177    set("NX_WORKSPACE_ROOT", workspace.root.clone().into_os_string());
178    set("NX_TASK_TARGET_PROJECT", task.project.clone().into());
179    set("NX_TASK_TARGET_TARGET", task.target.clone().into());
180    set("LERNA_PACKAGE_NAME", task.project.clone().into());
181    set("NX_TUI", "false".into());
182    match &task.configuration {
183        Some(configuration) => set("NX_TASK_TARGET_CONFIGURATION", configuration.clone().into()),
184        None => {
185            env.remove(std::ffi::OsStr::new("NX_TASK_TARGET_CONFIGURATION"));
186        }
187    }
188    Ok(env)
189}
190
191/// Whether dotenv files load: as in Nx, `NX_LOAD_DOT_ENV_FILES=false` turns
192/// them all off.
193fn loads_dotenv(env: &BTreeMap<OsString, OsString>) -> bool {
194    env.get(std::ffi::OsStr::new("NX_LOAD_DOT_ENV_FILES"))
195        .is_none_or(|value| value != "false")
196}
197
198/// A dotenv file's entries, or none when it does not exist.
199fn read_dotenv(path: &Path) -> Result<Vec<(OsString, OsString)>> {
200    let file = match std::fs::File::open(path) {
201        Ok(file) => file,
202        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
203        Err(error) => return Err(error).with_context(|| format!("cannot read {}", path.display())),
204    };
205    dotenvy::from_read_iter(file)
206        .enumerate()
207        .map(|(index, entry)| {
208            // Dotenv parser errors can contain secret values; report only location.
209            let (name, value) = entry.map_err(|_| {
210                anyhow::anyhow!("invalid dotenv entry {} in {}", index + 1, path.display())
211            })?;
212            Ok((name.into(), value.into()))
213        })
214        .collect()
215}
216
217/// Read dotenv files into a child environment without changing process globals.
218/// Existing process variables win over .env.local, which wins over .env.
219pub fn environment(workspace: &Path) -> Result<BTreeMap<OsString, OsString>> {
220    let mut values = BTreeMap::new();
221    let process: BTreeMap<OsString, OsString> = std::env::vars_os().collect();
222    let names: &[&str] = if loads_dotenv(&process) {
223        &[".env", ".env.local"]
224    } else {
225        &[]
226    };
227    for name in names {
228        let path = workspace.join(name);
229        let file = match std::fs::File::open(&path) {
230            Ok(file) => file,
231            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
232            Err(error) => {
233                return Err(error).with_context(|| format!("cannot read {}", path.display()));
234            }
235        };
236        for (index, entry) in dotenvy::from_read_iter(file).enumerate() {
237            // Dotenv parser errors can contain secret values; report only location.
238            let (name, value) = entry.map_err(|_| {
239                anyhow::anyhow!("invalid dotenv entry {} in {}", index + 1, path.display())
240            })?;
241            values.insert(name.into(), value.into());
242        }
243    }
244    values.extend(std::env::vars_os());
245    Ok(values)
246}
247
248pub fn prepare(
249    workspace: &Workspace,
250    task: &Task,
251    base_env: &BTreeMap<OsString, OsString>,
252) -> Result<PreparedTask> {
253    let definition = &task.definition;
254    let executor = definition
255        .executor
256        .as_deref()
257        .context("target has no executor or command")?;
258    let (options, args) = if executor == "nx:run-commands" {
259        run_commands_overrides(&definition.options, &task.args)
260    } else {
261        (definition.options.clone(), task.args.clone())
262    };
263    let options = &options;
264    // Options Nx's run-commands knows; any other scalar option is forwarded to
265    // the command as `--name=value`, and object values are ignored, as in Nx.
266    const RUN_COMMANDS: &[&str] = &[
267        "command",
268        "commands",
269        "color",
270        "no-color",
271        "parallel",
272        "no-parallel",
273        "readyWhen",
274        "cwd",
275        "args",
276        "envFile",
277        "__unparsed__",
278        "env",
279        "usePty",
280        "streamOutput",
281        "verbose",
282        "forwardAllArgs",
283        "tty",
284    ];
285    let mut forwarded = BTreeMap::new();
286    if executor == "nx:run-commands" {
287        for (key, value) in options {
288            if RUN_COMMANDS.contains(&key.as_str()) {
289                continue;
290            }
291            let value = match value {
292                Value::String(text) => text.clone(),
293                Value::Number(number) => number.to_string(),
294                Value::Bool(flag) => flag.to_string(),
295                _ => continue,
296            };
297            forwarded.insert(key.clone(), value);
298        }
299    }
300    let extra = match options.get("args") {
301        None | Some(Value::Null) => None,
302        Some(Value::String(text)) => Some(text.clone()),
303        Some(Value::Array(words)) => Some(
304            words
305                .iter()
306                .map(|word| word.as_str().context("args entries must be strings"))
307                .collect::<Result<Vec<_>>>()?
308                .join(" "),
309        ),
310        Some(_) => bail!("args must be a string or an array of strings"),
311    };
312    let interpolation =
313        interpolate::Interpolation::new(workspace, task, args, &forwarded, extra.as_deref())?;
314    let mut env = task_environment(workspace, task, base_env)?;
315    // As in Nx, `envFile` sets what is not set yet, beneath `env`.
316    if let Some(file) = options.get("envFile") {
317        let file = interpolation.text(file.as_str().context("envFile must be a string")?)?;
318        let path = workspace.root.join(&file);
319        if loads_dotenv(base_env) {
320            if !path.is_file() {
321                bail!("envFile {file} does not exist");
322            }
323            for (name, value) in read_dotenv(&path)? {
324                env.entry(name).or_insert(value);
325            }
326        }
327    }
328    for values in [definition.extra.get("env"), options.get("env")]
329        .into_iter()
330        .flatten()
331    {
332        for (name, value) in values.as_object().context("env must be an object")? {
333            if name.is_empty() || name.contains(['=', '\0']) {
334                bail!("invalid environment variable name");
335            }
336            let value = value
337                .as_str()
338                .context("environment values must be strings")?;
339            env.insert(name.into(), interpolation.text(value)?.into());
340        }
341    }
342    if executor == "nx:run-commands" && boolean(options.get("color"), false, "color")? {
343        env.insert("FORCE_COLOR".into(), "true".into());
344    }
345    for key in options.keys() {
346        let allowed = match executor {
347            // Every run-commands option is supported. `tty`, `usePty`,
348            // `streamOutput` and `verbose` have no effect: qk never gives
349            // commands a terminal, and Nx sets the others itself.
350            "nx:run-commands" => true,
351            "nx:run-script" => matches!(key.as_str(), "script" | "env" | "cwd"),
352            "nx:noop" => true,
353            _ => bail!("unsupported executor {executor:?}"),
354        };
355        if !allowed {
356            bail!("unsupported {executor} option {key:?}");
357        }
358    }
359    let default_cwd = if executor == "nx:run-script" {
360        &workspace.projects[&task.project].root
361    } else {
362        "."
363    };
364    let cwd = options
365        .get("cwd")
366        .map(|value| value.as_str().context("cwd must be a string"))
367        .transpose()?
368        .unwrap_or(default_cwd);
369    let cwd = workspace.root.join(interpolation.text(cwd)?);
370    if !cwd.is_dir() {
371        bail!("working directory does not exist: {}", cwd.display());
372    }
373    // Expose local binaries even for plain run-commands targets.
374    let mut paths = Vec::new();
375    for directory in cwd
376        .ancestors()
377        .take_while(|directory| directory.starts_with(&workspace.root))
378    {
379        paths.push(directory.join("node_modules/.bin"));
380    }
381    let path_key = if cfg!(windows) {
382        env.keys()
383            .find(|key| key.to_string_lossy().eq_ignore_ascii_case("PATH"))
384            .cloned()
385            .unwrap_or_else(|| "PATH".into())
386    } else {
387        "PATH".into()
388    };
389    if let Some(path) = env.get(&path_key) {
390        paths.extend(std::env::split_paths(path));
391    }
392    env.insert(
393        path_key,
394        std::env::join_paths(paths).context("cannot construct task PATH")?,
395    );
396    let parallel = boolean(options.get("parallel"), true, "parallel")?;
397    let mut decorations = Vec::new();
398    let ready_when: Vec<String> = match options.get("readyWhen") {
399        None | Some(Value::Null) => Vec::new(),
400        Some(Value::String(text)) => vec![text.clone()],
401        Some(value) => serde_json::from_value(value.clone())
402            .context("readyWhen must be a string or an array of strings")?,
403    };
404    if !ready_when.is_empty() && options.contains_key("commands") && !parallel {
405        bail!("readyWhen can only be used when parallel is true");
406    }
407    let mut commands = Vec::new();
408    match executor {
409        "nx:noop" => {}
410        "nx:run-script" => {
411            let script = options
412                .get("script")
413                .and_then(Value::as_str)
414                .context("run-script requires options.script")?;
415            let package = workspace
416                .packages
417                .get(&task.project)
418                .context("run-script requires a package.json")?;
419            if !package.scripts.contains_key(script) {
420                bail!("package.json has no script {script:?}");
421            }
422            let manager = package_manager(workspace)?;
423            let args = interpolation.arguments()?;
424            let script = interpolate::quote(script)?;
425            // Nx's `run` for each: npm and bun need `--` before arguments,
426            // and yarn runs a script by name.
427            let mut command = match manager {
428                "yarn" => format!("yarn {script}"),
429                manager => format!("{manager} run {script}"),
430            };
431            if !args.is_empty() {
432                if matches!(manager, "npm" | "bun") {
433                    command.push_str(" --");
434                }
435                command.push(' ');
436                command.push_str(&args);
437            }
438            commands.push(command);
439        }
440        "nx:run-commands" => {
441            if options.contains_key("command") && options.contains_key("commands") {
442                bail!("specify command or commands, not both");
443            }
444            let forward = boolean(options.get("forwardAllArgs"), true, "forwardAllArgs")?;
445            if let Some(command) = options.get("command") {
446                // Nx also takes an array, joined with spaces.
447                let command = match command {
448                    Value::String(command) => command.clone(),
449                    Value::Array(words) => words
450                        .iter()
451                        .map(|word| word.as_str().context("command words must be strings"))
452                        .collect::<Result<Vec<_>>>()?
453                        .join(" "),
454                    _ => bail!("command must be a string or an array of strings"),
455                };
456                commands.push(interpolation.command(&command, forward)?);
457            } else if let Some(values) = options.get("commands") {
458                for value in values.as_array().context("commands must be an array")? {
459                    let (command, forward) = if let Some(command) = value.as_str() {
460                        (command, forward)
461                    } else {
462                        let value = value
463                            .as_object()
464                            .context("commands entries must be strings or objects")?;
465                        for key in value.keys() {
466                            if !matches!(
467                                key.as_str(),
468                                "command"
469                                    | "forwardAllArgs"
470                                    | "prefix"
471                                    | "prefixColor"
472                                    | "color"
473                                    | "bgColor"
474                                    | "description"
475                            ) {
476                                bail!("unsupported command entry field {key:?}");
477                            }
478                        }
479                        let field =
480                            |name: &str, background: Option<bool>| -> Result<Option<String>> {
481                                let Some(value) = value.get(name) else {
482                                    return Ok(None);
483                                };
484                                let text = value.as_str().with_context(|| {
485                                    format!("command entry {name} must be a string")
486                                })?;
487                                if let Some(background) = background
488                                    && !Decoration::valid(text, background)
489                                {
490                                    bail!("command entry {name} {text:?} is not a colour");
491                                }
492                                Ok(Some(text.to_owned()))
493                            };
494                        let decoration = Decoration {
495                            prefix: field("prefix", None)?,
496                            prefix_color: field("prefixColor", Some(false))?,
497                            color: field("color", Some(false))?,
498                            bg_color: field("bgColor", Some(true))?,
499                        };
500                        if !decoration.is_empty() && !parallel {
501                            bail!(
502                                "prefix, prefixColor, color and bgColor can only be set when parallel is true"
503                            );
504                        }
505                        decorations.resize(commands.len(), Decoration::default());
506                        decorations.push(decoration);
507                        (
508                            value
509                                .get("command")
510                                .and_then(Value::as_str)
511                                .context("command entry requires a command string")?,
512                            boolean(value.get("forwardAllArgs"), forward, "forwardAllArgs")?,
513                        )
514                    };
515                    commands.push(interpolation.command(command, forward)?);
516                }
517            } else {
518                bail!("run-commands requires command or commands");
519            }
520            // An empty `commands` succeeds without running anything, as in Nx.
521            if commands.iter().any(|command| command.trim().is_empty()) {
522                bail!("run-commands requires nonempty commands");
523            }
524        }
525        _ => bail!("unsupported executor {executor:?}"),
526    }
527    Ok(PreparedTask {
528        id: task.id.clone(),
529        commands,
530        parallel,
531        cwd,
532        env,
533        execution: BTreeMap::new(),
534        ready_when,
535        ready: Default::default(),
536        decorations,
537        interactive: false,
538        sandbox: None,
539        recording: None,
540        display: Display::default(),
541    })
542}
543
544/// The run-commands options Nx reads, which a task's arguments may set too.
545const OPTION_FLAGS: &[&str] = &[
546    "command",
547    "commands",
548    "color",
549    "parallel",
550    "readyWhen",
551    "cwd",
552    "args",
553    "envFile",
554    "__unparsed__",
555    "env",
556    "usePty",
557    "streamOutput",
558    "verbose",
559    "forwardAllArgs",
560    "tty",
561];
562
563/// The target's options with the task's arguments that name a run-commands
564/// option applied, and the arguments left for the command. As in Nx,
565/// `--cwd=dir`, `--no-parallel` or `--env.NAME=value` set the option instead
566/// of reaching the command.
567fn run_commands_overrides(
568    options: &serde_json::Map<String, Value>,
569    args: &[String],
570) -> (serde_json::Map<String, Value>, Vec<String>) {
571    const BOOLEAN: &[&str] = &[
572        "color",
573        "parallel",
574        "usePty",
575        "streamOutput",
576        "verbose",
577        "forwardAllArgs",
578        "tty",
579    ];
580    let mut options = options.clone();
581    let mut rest = Vec::new();
582    let mut ready = Vec::new();
583    let mut args = args.iter().peekable();
584    while let Some(arg) = args.next() {
585        let Some(flag) = arg.strip_prefix("--") else {
586            rest.push(arg.clone());
587            continue;
588        };
589        let (key, value) = match flag.split_once('=') {
590            Some((key, value)) => (key, Some(value.to_owned())),
591            None => (flag, None),
592        };
593        let (key, negated) = match key.strip_prefix("no-") {
594            Some(key) if OPTION_FLAGS.contains(&key) => (key, true),
595            _ => (key, false),
596        };
597        let (base, field) = match key.split_once('.') {
598            Some((base, field)) => (base, Some(field)),
599            None => (key, None),
600        };
601        if !OPTION_FLAGS.contains(&base) {
602            rest.push(arg.clone());
603            continue;
604        }
605        let boolean = BOOLEAN.contains(&base);
606        let value = match value {
607            Some(value) => value,
608            None if negated => "false".into(),
609            None if !boolean && args.peek().is_some_and(|next| !next.starts_with('-')) => {
610                args.next().unwrap().clone()
611            }
612            None => "true".into(),
613        };
614        let value = if boolean {
615            Value::Bool(value != "false")
616        } else {
617            Value::String(value)
618        };
619        match (base, field) {
620            ("env", Some(name)) => {
621                let env = options
622                    .entry("env")
623                    .or_insert_with(|| Value::Object(Default::default()));
624                if let Value::Object(env) = env {
625                    env.insert(name.to_owned(), value);
626                }
627            }
628            ("readyWhen", None) => ready.push(value),
629            // Nx sets these itself, and a list cannot come from a flag.
630            ("__unparsed__" | "commands", _) | (_, Some(_)) => {}
631            (base, None) => {
632                options.insert(base.to_owned(), value);
633            }
634        }
635    }
636    if !ready.is_empty() {
637        let ready = if ready.len() == 1 {
638            ready.pop().unwrap()
639        } else {
640            Value::Array(ready)
641        };
642        options.insert("readyWhen".into(), ready);
643    }
644    (options, rest)
645}
646
647fn boolean(value: Option<&Value>, default: bool, name: &str) -> Result<bool> {
648    value
649        .map(|value| {
650            value
651                .as_bool()
652                .with_context(|| format!("{name} must be boolean"))
653        })
654        .transpose()
655        .map(|value| value.unwrap_or(default))
656}
657
658/// The package manager, as Nx detects it: nx.json `cli.packageManager`,
659/// then the lockfile, then the package manager qk was invoked through. qk
660/// also reads the root `packageManager` before that last step.
661fn package_manager(workspace: &Workspace) -> Result<&'static str> {
662    let known = |name: &str| -> Result<&'static str> {
663        Ok(match name {
664            "npm" => "npm",
665            "pnpm" => "pnpm",
666            "yarn" => "yarn",
667            "bun" => "bun",
668            other => bail!("unknown package manager {other:?}"),
669        })
670    };
671    if let Some(name) = workspace
672        .config
673        .extra
674        .get("cli")
675        .and_then(|cli| cli.get("packageManager"))
676        .and_then(Value::as_str)
677    {
678        return known(name);
679    }
680    let root = &workspace.root;
681    for (file, manager) in [
682        ("bun.lockb", "bun"),
683        ("bun.lock", "bun"),
684        ("yarn.lock", "yarn"),
685        ("pnpm-lock.yaml", "pnpm"),
686        ("package-lock.json", "npm"),
687    ] {
688        if root.join(file).is_file() {
689            return Ok(manager);
690        }
691    }
692    if let Some(declared) = &workspace.package_manager {
693        return known(declared.split('@').next().unwrap_or(declared));
694    }
695    if root.join("pnpm-workspace.yaml").is_file() {
696        return Ok("pnpm");
697    }
698    let agent = std::env::var("npm_config_user_agent").unwrap_or_default();
699    for manager in ["pnpm", "yarn", "bun"] {
700        if agent.starts_with(&format!("{manager}/")) {
701            return Ok(manager);
702        }
703    }
704    Ok("npm")
705}