Skip to main content

qk_executor/
lib.rs

1//! Prepare finite tasks, then execute shell commands in managed process groups.
2
3mod bins;
4mod capture;
5pub mod dotenv;
6mod env_flags;
7mod interpolate;
8pub mod landlock;
9#[cfg(target_os = "linux")]
10pub mod overlay;
11mod process;
12mod providers;
13#[cfg(unix)]
14mod pty;
15pub mod readiness;
16pub mod ready_signal;
17mod recording;
18pub mod report;
19pub mod tokens;
20
21use std::collections::BTreeMap;
22use std::ffi::OsString;
23use std::path::{Path, PathBuf};
24
25use anyhow::{Context, Result, bail};
26use qk_config::Workspace;
27use qk_taskgraph::Task;
28use serde_json::{Map, Value};
29
30pub use capture::{
31    Capture, Decoration, Display, MAX_LOG_BYTES, OutputStyle, RecordedLog, Shown, collapsed,
32    printable, read_capture, replay,
33};
34pub use env_flags::{force_colour, is_ci, is_ci_with};
35pub use process::{Outcome, execute, execute_captured, shell};
36pub use recording::{Choice, Probed, Recording};
37
38#[derive(Debug)]
39struct NodeRuntime {
40    root: PathBuf,
41    env: BTreeMap<OsString, OsString>,
42    paths: Vec<PathBuf>,
43    directory: std::sync::OnceLock<Option<PathBuf>>,
44}
45
46/// The runner's Node selection, resolved only when a command starts.
47#[derive(Clone, Debug)]
48pub struct NodePath {
49    runtime: std::sync::Arc<NodeRuntime>,
50    local_entries: usize,
51}
52
53impl NodePath {
54    fn new(root: &Path, env: &BTreeMap<OsString, OsString>, local_entries: usize) -> Self {
55        type Key = (PathBuf, BTreeMap<OsString, OsString>);
56        static RUNTIMES: std::sync::OnceLock<
57            std::sync::Mutex<BTreeMap<Key, std::sync::Arc<NodeRuntime>>>,
58        > = std::sync::OnceLock::new();
59        let mut runtimes = RUNTIMES.get_or_init(Default::default).lock().unwrap();
60        let runtime = runtimes
61            .entry((root.to_owned(), env.clone()))
62            .or_insert_with(|| {
63                let paths = env
64                    .get(&path_key(env))
65                    .map(|path| std::env::split_paths(path).collect())
66                    .unwrap_or_default();
67                std::sync::Arc::new(NodeRuntime {
68                    root: root.to_owned(),
69                    env: env.clone(),
70                    paths,
71                    directory: Default::default(),
72                })
73            })
74            .clone();
75        Self {
76            runtime,
77            local_entries,
78        }
79    }
80
81    fn path(&self, env: &BTreeMap<OsString, OsString>) -> Result<Option<(OsString, OsString)>> {
82        // Select before task overrides, like process.execPath in Nx. Cache hits
83        // never call this, and concurrent commands share the same proxy probe.
84        let Some(directory) = self.runtime.directory.get_or_init(|| {
85            node_directory(&self.runtime.root, &self.runtime.paths, &self.runtime.env)
86        }) else {
87            return Ok(None);
88        };
89        let key = path_key(env);
90        let mut paths: Vec<_> = env
91            .get(&key)
92            .map(|path| std::env::split_paths(path).collect())
93            .unwrap_or_default();
94        paths.insert(self.local_entries.min(paths.len()), directory.clone());
95        Ok(Some((
96            key,
97            std::env::join_paths(paths).context("cannot construct task PATH")?,
98        )))
99    }
100}
101
102fn path_key(env: &BTreeMap<OsString, OsString>) -> OsString {
103    if cfg!(windows) {
104        env.keys()
105            .find(|key| key.to_string_lossy().eq_ignore_ascii_case("PATH"))
106            .cloned()
107            .unwrap_or_else(|| "PATH".into())
108    } else {
109        "PATH".into()
110    }
111}
112
113fn environment_key(env: &BTreeMap<OsString, OsString>, name: OsString) -> OsString {
114    if cfg!(windows) && name.to_string_lossy().eq_ignore_ascii_case("PATH") {
115        path_key(env)
116    } else {
117        name
118    }
119}
120
121fn node_directory(
122    cwd: &Path,
123    paths: &[PathBuf],
124    env: &BTreeMap<OsString, OsString>,
125) -> Option<PathBuf> {
126    let name = if cfg!(windows) { "node.exe" } else { "node" };
127    for directory in paths {
128        let candidate = cwd.join(directory).join(name);
129        let Ok(metadata) = candidate.metadata() else {
130            continue;
131        };
132        if !metadata.is_file() {
133            continue;
134        }
135        #[cfg(unix)]
136        {
137            use std::os::unix::fs::PermissionsExt;
138            if metadata.permissions().mode() & 0o111 == 0 {
139                continue;
140            }
141        }
142        // The first executable PATH match is authoritative, even if discovery fails.
143        let actual = candidate.canonicalize().ok()?;
144        if actual.file_name()? == name {
145            use std::io::Read;
146            let mut header = [0; 2];
147            let script = std::fs::File::open(&actual)
148                .and_then(|mut file| file.read_exact(&mut header))
149                .is_ok()
150                && header == *b"#!";
151            if !script {
152                return actual.parent().map(Path::to_owned);
153            }
154        }
155        return probe_node(&candidate, cwd, env);
156    }
157    None
158}
159
160fn probe_node(candidate: &Path, cwd: &Path, env: &BTreeMap<OsString, OsString>) -> Option<PathBuf> {
161    use std::io::{Read, Seek};
162    use std::process::{Command, Stdio};
163    use std::time::{Duration, Instant};
164    let mut output = tempfile::tempfile().ok()?;
165    let mut child = Command::new(candidate)
166        .args(["-p", "process.execPath"])
167        .current_dir(cwd)
168        .env_clear()
169        .envs(env)
170        .stdout(Stdio::from(output.try_clone().ok()?))
171        .stderr(Stdio::null())
172        .spawn()
173        .ok()?;
174    let started = Instant::now();
175    loop {
176        match child.try_wait() {
177            Ok(Some(status)) => {
178                if !status.success() {
179                    return None;
180                }
181                let mut bytes = Vec::new();
182                output.rewind().ok()?;
183                output.take(4096).read_to_end(&mut bytes).ok()?;
184                let path = PathBuf::from(std::str::from_utf8(&bytes).ok()?.trim());
185                return (path.is_absolute() && path.is_file())
186                    .then(|| path.parent().unwrap().to_owned());
187            }
188            Ok(None) if started.elapsed() < Duration::from_secs(2) => {
189                std::thread::sleep(Duration::from_millis(5))
190            }
191            _ => {
192                let _ = child.kill();
193                let _ = child.wait();
194                return None;
195            }
196        }
197    }
198}
199
200/// Reports a `qk:` warning to the installed sink; by default it is written to
201/// stderr in one write, so task processes sharing stderr cannot split it.
202#[macro_export]
203macro_rules! status {
204    ($($arg:tt)*) => {{
205        $crate::report::warning(&::std::format!($($arg)*));
206    }};
207}
208
209/// How a sandboxed task's commands are held to what it declares.
210#[derive(Clone, Debug)]
211pub enum Confinement {
212    /// macOS: a Seatbelt profile, through `sandbox-exec`.
213    Seatbelt(PathBuf),
214    /// Linux: a Landlock rule set's descriptor, which each command restricts
215    /// itself to before it starts. It stays open while the run lasts.
216    Landlock(i32),
217    /// Enforced, and could not be held to its plan, for this reason: its
218    /// commands fail rather than start unconfined.
219    Refused(String),
220    /// Linux: each command runs in its own user and mount namespace over an
221    /// overlay of the workspace, whose upper layer the sandbox reads when the
222    /// task ends.
223    Overlay(std::sync::Arc<Overlay>),
224}
225
226/// Where a task's overlays live, and what passes through them to the
227/// workspace.
228#[derive(Debug)]
229pub struct Overlay {
230    /// The workspace, which the overlay is mounted over.
231    pub workspace: PathBuf,
232    /// The task's directory: each command's layers in `command-<index>`.
233    pub directory: PathBuf,
234    /// Paths in the workspace bound through, so that what a command writes
235    /// there reaches the workspace directly.
236    pub passthrough: Vec<PathBuf>,
237    /// The Landlock rules each command restricts itself to once the overlay
238    /// is mounted, which reads must keep to.
239    pub rules: OverlayRules,
240    /// Where the task may write: what its commands wrote there is applied to
241    /// the workspace as they end, and the rest is not.
242    pub keep: Keep,
243    /// Set once the commands' writes are applied.
244    pub settled: std::sync::OnceLock<Settled>,
245}
246
247/// Where a task over an overlay may write.
248#[derive(Clone, Debug, Default)]
249pub struct Keep {
250    /// Paths beneath which it may write.
251    pub paths: Vec<PathBuf>,
252    /// Names of directories beneath which it may write wherever they lie in
253    /// the workspace, there as the task started or made by it.
254    pub names: Vec<std::ffi::OsString>,
255}
256
257impl Keep {
258    /// Whether it may write `target`, in the workspace at `root`.
259    pub fn allows(&self, root: &std::path::Path, target: &std::path::Path) -> bool {
260        self.paths.iter().any(|path| target.starts_with(path))
261            || target.strip_prefix(root).is_ok_and(|relative| {
262                relative
263                    .components()
264                    .any(|part| self.names.iter().any(|name| part.as_os_str() == name))
265            })
266    }
267
268    /// Whether a path it may write beneath lies beneath `target`. Directories
269    /// it names by name are left out: those there as the task started are
270    /// bound through the overlay, which never holds them.
271    pub fn holds(&self, target: &std::path::Path) -> bool {
272        self.paths.iter().any(|path| path.starts_with(target))
273    }
274}
275
276/// What applying a task's overlay to the workspace did not apply.
277#[derive(Debug, Default)]
278pub struct Settled {
279    /// Workspace paths written outside where the task may write.
280    pub discarded: std::collections::BTreeSet<String>,
281    /// What could not be read or applied, and why: outputs that may be
282    /// missing from the workspace.
283    pub failed: Vec<String>,
284    /// Directories `keep` names by name that applying made in the
285    /// workspace where no directory stood, in place of nothing, a file or a
286    /// link, but for those beneath another such directory.
287    pub made: Vec<PathBuf>,
288}
289
290impl Overlay {
291    /// Applies what the task's commands wrote within `keep` to the
292    /// workspace, once, as they end: before the cache reads its outputs and
293    /// before any task depending on it starts.
294    pub fn settle(&self) -> &Settled {
295        self.settled.get_or_init(|| {
296            #[cfg(target_os = "linux")]
297            {
298                overlay::settle(self)
299            }
300            #[cfg(not(target_os = "linux"))]
301            {
302                Settled::default()
303            }
304        })
305    }
306}
307
308/// A Landlock rule set to build in a command's namespace. Paths outside the
309/// workspace, which the overlay leaves as they are, are opened beforehand;
310/// those in it are opened there, as the overlay's.
311#[derive(Debug, Default)]
312pub struct OverlayRules {
313    /// The rights the rule set handles.
314    pub handled: landlock::Rights,
315    /// Opened outside the namespace: descriptors, held open until the task
316    /// ends, with the rights beneath each.
317    #[cfg(unix)]
318    pub outside: Vec<(std::os::fd::OwnedFd, landlock::Rights)>,
319    /// The rights everywhere in the workspace, where writes reach the overlay
320    /// and are judged as the task ends.
321    pub workspace: landlock::Rights,
322    /// Paths in the workspace with the rights beneath each, and how each is
323    /// opened.
324    pub inside: Vec<(PathBuf, landlock::Rights, landlock::Open)>,
325}
326
327/// A scratch directory, below the workspace `root` it lies in, which the
328/// task keeps nothing in between runs.
329#[derive(Clone, Debug)]
330pub struct Scratch {
331    pub root: PathBuf,
332    pub directory: PathBuf,
333}
334
335#[derive(Clone, Debug)]
336pub struct PreparedTask {
337    pub id: String,
338    pub commands: Vec<String>,
339    pub parallel: bool,
340    pub cwd: PathBuf,
341    pub env: BTreeMap<OsString, OsString>,
342    /// Dotenv, explicit settings and local binary lookup without task metadata.
343    pub runtime_env: BTreeMap<OsString, OsString>,
344    /// The dotenv files its environment was loaded from, `envFile` among
345    /// them, as absolute paths, whether or not each exists.
346    pub dotenv: Vec<PathBuf>,
347    pub node_path: Option<NodePath>,
348    /// The environment the task declares, `qk:env`, which its key holds;
349    /// `None` where it inherits every variable.
350    pub environment: Option<qk_config::env::Environment>,
351    /// Variables set for the process after `env` that are not part of what
352    /// the task is: nothing reads them to key it, such as a thread count.
353    pub execution: BTreeMap<OsString, OsString>,
354    /// Nx's `readyWhen`: text whose appearance in the output, every piece of
355    /// it, makes the task ready while its commands keep running.
356    pub ready_when: Vec<String>,
357    /// Set once all of `ready_when` has appeared.
358    pub ready: std::sync::Arc<std::sync::atomic::AtomicBool>,
359    /// A continuous target's own `readyWhen`, which tasks depending on it
360    /// with `waitFor: "ready"` wait for, or why it is invalid. Prepared, it is
361    /// resolved without `NX_READY_URL`; see [`PreparedTask::resolve_ready_check`].
362    pub ready_check: Option<std::result::Result<readiness::ReadyCheck, String>>,
363    /// What `ready_check` is resolved from, to resolve it again once the
364    /// servers the task waits for have reported their address.
365    pub ready_inputs: Option<std::sync::Arc<ReadyInputs>>,
366    /// What `ready_check` concluded.
367    pub verdict: std::sync::Arc<readiness::Verdict>,
368    /// The process ids of its commands while they run, from which the runner
369    /// measures the memory the task uses.
370    pub processes: std::sync::Arc<std::sync::Mutex<std::collections::BTreeSet<u32>>>,
371    /// Each command's prefix and colours, by position; empty without any.
372    pub decorations: Vec<Decoration>,
373    /// Whether the task reads qk's stdin, and has the terminal while it runs.
374    /// The runner sets it for the one task a run is for, when that task has a
375    /// single command whose output goes straight to the terminal.
376    pub interactive: bool,
377    /// Whether captured commands may run under a pseudo-terminal, which they
378    /// do when qk's output is a terminal: Nx's `usePty`. Commands whose
379    /// output is hashed set it to false, so keys never depend on a terminal.
380    pub pseudo_terminal: bool,
381    /// Nx's `tty`: whether qk's terminal is in raw input mode while the
382    /// interactive task reads it through a pseudo-terminal.
383    pub tty: bool,
384    /// What the commands run under, for `--sandbox`.
385    pub sandbox: Option<Confinement>,
386    pub recording: Option<Recording>,
387    /// Directories emptied before its commands run, which it keeps nothing
388    /// in between runs: its target's `qk:sandbox.scratch`.
389    pub scratch: Vec<Scratch>,
390    /// How the task's output is shown; the runner sets it per output style.
391    pub display: Display,
392}
393
394/// The dotenv files Nx loads for a task, most specific first: in the
395/// project root, then the workspace root, for the target and configuration
396/// (`.env.<target>.<configuration>`, `.env.<configuration>`, `.env.<target>`),
397/// then `.env.local`, `.local.env` and `.env`, each also in its `.local` and
398/// `.<name>.env` spellings. An atomized target also reads the files of the
399/// target it was split from, after its own of the same kind.
400fn dotenv_files(
401    project_root: &str,
402    target: &str,
403    configuration: Option<&str>,
404    non_atomized: Option<&str>,
405) -> Vec<String> {
406    let mut identifiers = Vec::new();
407    if let Some(configuration) = configuration {
408        identifiers.push(format!("{target}.{configuration}"));
409        if let Some(non_atomized) = non_atomized {
410            identifiers.push(format!("{non_atomized}.{configuration}"));
411        }
412        identifiers.push(configuration.to_owned());
413    }
414    identifiers.push(target.to_owned());
415    if let Some(non_atomized) = non_atomized {
416        identifiers.push(non_atomized.to_owned());
417    }
418    identifiers.push(String::new());
419    let variants = |identifier: &str, root: &str| -> Vec<String> {
420        let prefix = if root.is_empty() || root == "." {
421            String::new()
422        } else {
423            format!("{root}/")
424        };
425        if identifier.is_empty() {
426            [".env.local", ".local.env", ".env"]
427                .map(|name| format!("{prefix}{name}"))
428                .to_vec()
429        } else {
430            [
431                format!(".env.{identifier}.local"),
432                format!(".env.{identifier}"),
433                format!(".{identifier}.local.env"),
434                format!(".{identifier}.env"),
435            ]
436            .map(|name| format!("{prefix}{name}"))
437            .to_vec()
438        }
439    };
440    let mut files = Vec::new();
441    for root in [project_root, ""] {
442        for identifier in &identifiers {
443            // Colons in target names cannot name regular dotenv files on Windows.
444            #[cfg(windows)]
445            if identifier.contains(':') {
446                continue;
447            }
448            for file in variants(identifier, root) {
449                if !files.contains(&file) {
450                    files.push(file);
451                }
452            }
453        }
454    }
455    files
456}
457
458/// The target whose dotenv files a task reads, and the target it was
459/// atomized from, as Nx's getOwnerTargetForTask finds them: through the
460/// project's `metadata.targetGroups`, a target of the task's group that
461/// names a `metadata.nonAtomizedTarget`. Unlike Nx, a task that names one
462/// itself is its own owner, so each atomized target reads its own files
463/// rather than the first of its group's.
464fn owner_target<'a>(
465    project: &'a qk_config::Project,
466    target: &'a str,
467) -> (&'a str, Option<&'a str>) {
468    let non_atomized = |definition: &'a qk_config::Target| {
469        definition
470            .extra
471            .get("metadata")
472            .and_then(|metadata| metadata.get("nonAtomizedTarget"))
473            .and_then(Value::as_str)
474    };
475    if let Some((name, definition)) = project.targets.get_key_value(target)
476        && let Some(non_atomized) = non_atomized(definition)
477    {
478        return (name, Some(non_atomized));
479    }
480    let groups = project
481        .extra
482        .get("metadata")
483        .and_then(|metadata| metadata.get("targetGroups"))
484        .and_then(Value::as_object);
485    for group in groups.into_iter().flat_map(|groups| groups.values()) {
486        let members: Vec<&str> = group
487            .as_array()
488            .into_iter()
489            .flatten()
490            .filter_map(Value::as_str)
491            .collect();
492        if !members.contains(&target) {
493            continue;
494        }
495        for member in members {
496            if let Some((name, definition)) = project.targets.get_key_value(member)
497                && let Some(non_atomized) = non_atomized(definition)
498            {
499                return (name, Some(non_atomized));
500            }
501        }
502    }
503    (target, None)
504}
505
506/// The variable naming the tasks a task runs inside, one invocation a line.
507pub const INVOCATIONS: &str = "QK_TASK_INVOCATIONS";
508
509/// A task as an invocation: its id and arguments, which a task running inside
510/// it repeats only by recursing, as Nx's task invocation tracker tells it.
511pub fn invocation(task: &Task) -> String {
512    let mut text = task.id.clone();
513    for arg in &task.args {
514        text.push('\u{1f}');
515        text.push_str(arg);
516    }
517    text.replace('\n', " ")
518}
519
520/// The tasks this process runs inside, from [`INVOCATIONS`].
521pub fn enclosing_invocations() -> Vec<String> {
522    std::env::var(INVOCATIONS)
523        .map(|chain| chain.lines().map(str::to_owned).collect())
524        .unwrap_or_default()
525}
526
527/// The root dotenv files Nx loads into its own process at startup, in the
528/// order they take precedence.
529pub const ROOT_DOTENV_FILES: [&str; 3] = [".local.env", ".env.local", ".env"];
530
531/// A task's environment as Nx builds it: the process environment, without
532/// what the root dotenv files set in it at startup, then the task's dotenv
533/// files, loaded and expanded together, then the variables Nx sets for
534/// every task. A target's `env` applies on top. With a declared
535/// environment, `qk:env`, only what it lets through of the process
536/// environment is kept, and what it sets takes its place.
537fn task_environment(
538    workspace: &Workspace,
539    task: &Task,
540    base_env: &BTreeMap<OsString, OsString>,
541    environment: Option<&mut qk_config::env::Environment>,
542    loaded: &mut Vec<PathBuf>,
543) -> Result<(BTreeMap<OsString, OsString>, BTreeMap<OsString, OsString>)> {
544    let mut env = base_env.clone();
545    let project = &workspace.projects[&task.project];
546    if loads_dotenv(base_env) {
547        // As Nx's unloadDotEnvFiles: a variable whose value is a root file's
548        // goes, so the task's own files decide it. Nx unloads them even when
549        // it did not load them; qk keeps what the shell set then.
550        for file in [".env", ".local.env", ".env.local"] {
551            for (name, value) in dotenv::read_alone(&workspace.root.join(file))? {
552                if env.get(&name) == Some(&value) {
553                    env.remove(&name);
554                }
555            }
556        }
557    }
558    // What the task inherits, before its own dotenv files and settings,
559    // which its configuration declares.
560    if let Some(environment) = environment {
561        let mut provided = BTreeMap::new();
562        let mut keys = Vec::new();
563        for provider in environment.providers() {
564            let (variables, key) = providers::load(provider, base_env, &workspace.root)
565                .with_context(|| format!("cannot build the environment of {}", task.id))?;
566            provided.extend(variables);
567            keys.push(key);
568        }
569        environment.provide(provided, keys);
570        environment
571            .filter(&mut env)
572            .with_context(|| format!("cannot build the environment of {}", task.id))?;
573        if environment.builds_path() {
574            let path = bins::path(
575                environment,
576                base_env.get(&path_key(base_env)),
577                &workspace.root,
578                &project.root,
579            )
580            .with_context(|| format!("cannot build the PATH of {}", task.id))?;
581            env.insert(path_key(&env), path);
582        }
583    }
584    if loads_dotenv(base_env) {
585        let (target, non_atomized) = owner_target(project, &task.target);
586        let files: Vec<PathBuf> = dotenv_files(
587            &project.root,
588            target,
589            task.configuration.as_deref(),
590            non_atomized,
591        )
592        .into_iter()
593        .map(|file| workspace.root.join(file))
594        .collect();
595        loaded.extend(dotenv::load(&files, &mut env)?);
596    }
597    let runtime_env = env.clone();
598    // Nx's own variables from an enclosing run, which describe that run.
599    for name in [
600        "NX_SET_CLI",
601        "NX_ORIGINAL_FORCE_COLOR",
602        "NX_TERMINAL_OUTPUT_PATH",
603        "NX_STREAM_OUTPUT",
604        "NX_PREFIX_OUTPUT",
605        // A task of an enclosing run's signal socket and reported address:
606        // a task here neither signals for that task nor reads its address.
607        ready_signal::SOCKET_ENV,
608        ready_signal::TASK_ENV,
609        ready_signal::URL_ENV,
610    ] {
611        env.remove(std::ffi::OsStr::new(name));
612    }
613    let mut set = |name: &str, value: OsString| {
614        env.insert(name.into(), value);
615    };
616    set("NX_WORKSPACE_ROOT", workspace.root.clone().into_os_string());
617    set("NX_TASK_TARGET_PROJECT", task.project.clone().into());
618    set("NX_TASK_TARGET_TARGET", task.target.clone().into());
619    set("LERNA_PACKAGE_NAME", task.project.clone().into());
620    set("NX_TUI", "false".into());
621    // The qk and Nx processes this task runs inside, outermost first.
622    let ancestors = match base_env.get(std::ffi::OsStr::new("NX_INVOCATION_ANCESTOR_PIDS")) {
623        Some(inherited) if !inherited.is_empty() => {
624            format!("{},{}", inherited.to_string_lossy(), std::process::id())
625        }
626        _ => std::process::id().to_string(),
627    };
628    set("NX_INVOCATION_ANCESTOR_PIDS", ancestors.into());
629    // The tasks this one runs inside, outermost first, so a run it starts can
630    // tell when it would run one of them again.
631    let mut chain = base_env
632        .get(std::ffi::OsStr::new(INVOCATIONS))
633        .map(|chain| chain.to_string_lossy().into_owned())
634        .unwrap_or_default();
635    if !chain.is_empty() {
636        chain.push('\n');
637    }
638    chain.push_str(&invocation(task));
639    set(INVOCATIONS, chain.into());
640    if task.target == "test" {
641        set("NX_TERMINAL_CAPTURE_STDERR", "true".into());
642    }
643    match &task.configuration {
644        Some(configuration) => set("NX_TASK_TARGET_CONFIGURATION", configuration.clone().into()),
645        None => {
646            env.remove(std::ffi::OsStr::new("NX_TASK_TARGET_CONFIGURATION"));
647        }
648    }
649    Ok((env, runtime_env))
650}
651
652/// Whether dotenv files load: as in Nx, `NX_LOAD_DOT_ENV_FILES=false` turns
653/// them all off.
654fn loads_dotenv(env: &BTreeMap<OsString, OsString>) -> bool {
655    env.get(std::ffi::OsStr::new("NX_LOAD_DOT_ENV_FILES"))
656        .is_none_or(|value| value != "false")
657}
658
659/// The process environment with the root dotenv files loaded into it, as
660/// Nx loads them into its own process at startup, unless
661/// `NX_LOAD_DOT_ENV_FILES=false`. Variables already set win.
662pub fn environment(workspace: &Path) -> Result<BTreeMap<OsString, OsString>> {
663    let mut values: BTreeMap<OsString, OsString> = std::env::vars_os().collect();
664    if loads_dotenv(&values) {
665        let files: Vec<PathBuf> = ROOT_DOTENV_FILES
666            .iter()
667            .map(|file| workspace.join(file))
668            .collect();
669        dotenv::load(&files, &mut values)?;
670    }
671    Ok(values)
672}
673
674pub fn prepare(
675    workspace: &Workspace,
676    task: &Task,
677    base_env: &BTreeMap<OsString, OsString>,
678) -> Result<PreparedTask> {
679    let definition = &task.definition;
680    let executor = definition
681        .executor
682        .as_deref()
683        .context("target has no executor or command")?;
684    let project = &workspace.projects[&task.project];
685    let run_commands = executor == "nx:run-commands";
686    if !matches!(executor, "nx:run-commands" | "nx:run-script" | "nx:noop") {
687        bail!("unsupported executor {executor:?}");
688    }
689    // As in Nx, run-script reads only `script`, and noop nothing.
690    let options = if run_commands {
691        run_commands_overrides(&definition.options, &task.args, project)?
692    } else {
693        definition.options.clone()
694    };
695    let options = &options;
696    let empty = Map::new();
697    let mut interpolation = if run_commands {
698        interpolate::Interpolation::new(&task.args, options)?
699    } else {
700        interpolate::Interpolation::new(&[], &empty)?
701    };
702    let mut environment = qk_config::env::Environment::of(&workspace.config, definition)
703        .with_context(|| format!("invalid environment for {}", task.id))?;
704    let mut dotenv = Vec::new();
705    let (mut env, mut runtime_env) =
706        task_environment(workspace, task, base_env, environment.as_mut(), &mut dotenv)?;
707    // As in Nx 24, `envFile` sets what the task's environment, dotenv files
708    // included, leaves unset or empty, beneath `env`; and the task's
709    // environment says whether it loads.
710    if let Some(file) = options.get("envFile").filter(|_| run_commands) {
711        let file = file.as_str().context("envFile must be a string")?;
712        let path = workspace.root.join(file);
713        if loads_dotenv(&runtime_env) {
714            if !path.is_file() {
715                bail!("envFile {file} does not exist");
716            }
717            dotenv::load(std::slice::from_ref(&path), &mut runtime_env)?;
718            dotenv.extend(dotenv::load(std::slice::from_ref(&path), &mut env)?);
719        }
720    }
721    if let Some(values) = options.get("env").filter(|_| run_commands) {
722        for (name, value) in values.as_object().context("env must be an object")? {
723            if name.is_empty() || name.contains(['=', '\0']) {
724                bail!("invalid environment variable name");
725            }
726            let value: OsString = value
727                .as_str()
728                .context("environment values must be strings")?
729                .into();
730            let name = environment_key(&env, name.into());
731            runtime_env.insert(name.clone(), value.clone());
732            env.insert(name, value);
733        }
734    }
735    interpolation.set_env(&env);
736    let mut execution = BTreeMap::new();
737    execution.insert(
738        "FORCE_COLOR".into(),
739        env.get(std::ffi::OsStr::new("FORCE_COLOR"))
740            .cloned()
741            .unwrap_or_else(|| "true".into()),
742    );
743    if run_commands && boolean(options.get("color"), false, "color")? {
744        execution.insert("FORCE_COLOR".into(), "true".into());
745    }
746    // run-commands runs where `cwd` says, the workspace root by default, and
747    // run-script in the project root.
748    let cwd = match options.get("cwd").filter(|_| run_commands) {
749        Some(cwd) => cwd.as_str().context("cwd must be a string")?,
750        None if run_commands => ".",
751        None => &project.root,
752    };
753    let cwd = workspace.root.join(cwd);
754    let mut resolved = PathBuf::new();
755    for part in cwd.components() {
756        match part {
757            std::path::Component::ParentDir => {
758                resolved.pop();
759            }
760            std::path::Component::CurDir => {}
761            _ => resolved.push(part.as_os_str()),
762        }
763    }
764    // Expose local binaries even for plain run-commands targets.
765    let mut paths = Vec::new();
766    for directory in resolved.ancestors() {
767        paths.push(directory.join("node_modules/.bin"));
768    }
769    let node_path = NodePath::new(&workspace.root, base_env, paths.len());
770    let path_key = path_key(&env);
771    if let Some(path) = env.get(&path_key) {
772        paths.extend(std::env::split_paths(path));
773    }
774    let path = std::env::join_paths(paths).context("cannot construct task PATH")?;
775    env.insert(path_key.clone(), path.clone());
776    runtime_env.insert(path_key, path);
777    let ready_when: Vec<String> = match options.get("readyWhen").filter(|_| run_commands) {
778        None | Some(Value::Null) => Vec::new(),
779        Some(Value::String(text)) => vec![text.clone()],
780        Some(value) => serde_json::from_value(value.clone())
781            .context("readyWhen must be a string or an array of strings")?,
782    };
783    // As in Nx, a single `command` runs in parallel exactly when it has
784    // `readyWhen`, whatever `parallel` says.
785    let parallel = if run_commands && options.get("command").is_some_and(merge_truthy) {
786        !ready_when.is_empty()
787    } else if run_commands {
788        boolean(options.get("parallel"), true, "parallel")?
789    } else {
790        true
791    };
792    let (mut pseudo_terminal, tty) = if run_commands {
793        (
794            boolean(options.get("usePty"), true, "usePty")?
795                && base_env
796                    .get(std::ffi::OsStr::new("NX_NATIVE_COMMAND_RUNNER"))
797                    .is_none_or(|value| value != "false"),
798            boolean(options.get("tty"), true, "tty")?,
799        )
800    } else {
801        (true, true)
802    };
803    let mut decorations = Vec::new();
804    if !ready_when.is_empty() && !parallel {
805        bail!("readyWhen can only be used when parallel is true");
806    }
807    let mut commands = Vec::new();
808    match executor {
809        "nx:noop" => {}
810        "nx:run-script" => {
811            let script = options
812                .get("script")
813                .and_then(Value::as_str)
814                .context("run-script requires options.script")?;
815            let package = workspace
816                .packages
817                .get(&task.project)
818                .context("run-script requires a package.json")?;
819            if !package.scripts.contains_key(script) {
820                bail!("package.json has no script {script:?}");
821            }
822            let manager = package_manager(workspace)?;
823            // Nx joins the arguments as they are; qk quotes each, so that
824            // one with a space or a shell character stays one argument.
825            let args = interpolate::quoted_arguments(&task.args)?;
826            let script = interpolate::quote(script)?;
827            // Nx's `run` for each: npm and bun need `--` before arguments,
828            // and yarn runs a script by name.
829            let mut command = match manager {
830                "yarn" => format!("yarn {script}"),
831                manager => format!("{manager} run {script}"),
832            };
833            if !args.is_empty() {
834                if matches!(manager, "npm" | "bun") {
835                    command.push_str(" --");
836                }
837                command.push(' ');
838                command.push_str(&args);
839            }
840            commands.push(command);
841        }
842        "nx:run-commands" => {
843            let forward = boolean(options.get("forwardAllArgs"), true, "forwardAllArgs")?;
844            // As in Nx, `command` wins over `commands`.
845            if let Some(command) = options
846                .get("command")
847                .filter(|command| merge_truthy(command))
848            {
849                // Nx also takes an array, joined with spaces.
850                let command = match command {
851                    Value::String(command) => command.clone(),
852                    Value::Array(words) => words
853                        .iter()
854                        .map(|word| word.as_str().context("command words must be strings"))
855                        .collect::<Result<Vec<_>>>()?
856                        .join(" "),
857                    _ => bail!("command must be a string or an array of strings"),
858                };
859                commands.push(interpolation.command(&command, forward)?);
860            } else if let Some(values) = options.get("commands") {
861                for value in values.as_array().context("commands must be an array")? {
862                    let (command, forward) = if let Some(command) = value.as_str() {
863                        (command, forward)
864                    } else {
865                        let value = value
866                            .as_object()
867                            .context("commands entries must be strings or objects")?;
868                        for key in value.keys() {
869                            if !matches!(
870                                key.as_str(),
871                                "command"
872                                    | "forwardAllArgs"
873                                    | "prefix"
874                                    | "prefixColor"
875                                    | "color"
876                                    | "bgColor"
877                                    | "description"
878                            ) {
879                                bail!("unsupported command entry field {key:?}");
880                            }
881                        }
882                        let field =
883                            |name: &str, background: Option<bool>| -> Result<Option<String>> {
884                                let Some(value) = value.get(name) else {
885                                    return Ok(None);
886                                };
887                                let text = value.as_str().with_context(|| {
888                                    format!("command entry {name} must be a string")
889                                })?;
890                                if let Some(background) = background
891                                    && !Decoration::valid(text, background)
892                                {
893                                    bail!("command entry {name} {text:?} is not a colour");
894                                }
895                                Ok(Some(text.to_owned()))
896                            };
897                        let decoration = Decoration {
898                            prefix: field("prefix", None)?,
899                            prefix_color: field("prefixColor", Some(false))?,
900                            color: field("color", Some(false))?,
901                            bg_color: field("bgColor", Some(true))?,
902                        };
903                        if !decoration.is_empty() && !parallel {
904                            bail!(
905                                "prefix, prefixColor, color and bgColor can only be set when parallel is true"
906                            );
907                        }
908                        decorations.resize(commands.len(), Decoration::default());
909                        decorations.push(decoration);
910                        (
911                            value
912                                .get("command")
913                                .and_then(Value::as_str)
914                                .context("command entry requires a command string")?,
915                            boolean(value.get("forwardAllArgs"), forward, "forwardAllArgs")?,
916                        )
917                    };
918                    commands.push(interpolation.command(command, forward)?);
919                }
920            } else {
921                bail!("run-commands requires command or commands");
922            }
923            // An empty `commands` succeeds without running anything, as in Nx.
924            if commands.iter().any(|command| command.trim().is_empty()) {
925                bail!("run-commands requires nonempty commands");
926            }
927        }
928        _ => bail!("unsupported executor {executor:?}"),
929    }
930    // As in Nx, commands run side by side share qk's output through pipes.
931    pseudo_terminal &= commands.len() == 1 || !parallel;
932    let ready_inputs = ready_inputs(workspace, task, options)?.map(std::sync::Arc::new);
933    let mut prepared = PreparedTask {
934        id: task.id.clone(),
935        commands,
936        parallel,
937        cwd,
938        env,
939        dotenv,
940        runtime_env,
941        environment,
942        node_path: Some(node_path),
943        execution,
944        ready_when,
945        ready: Default::default(),
946        ready_check: None,
947        ready_inputs,
948        verdict: Default::default(),
949        processes: Default::default(),
950        decorations,
951        interactive: false,
952        pseudo_terminal,
953        tty,
954        sandbox: None,
955        recording: None,
956        scratch: Vec::new(),
957        display: Display::default(),
958    };
959    prepared.ready_check = prepared.resolve_ready_check(None);
960    Ok(prepared)
961}
962
963/// A continuous target's `readyWhen` and what its placeholders read but the
964/// environment: the options the task runs with, its arguments applied.
965#[derive(Debug)]
966pub struct ReadyInputs {
967    config: Value,
968    workspace_root: String,
969    project_root: String,
970    project_name: String,
971    options: Map<String, Value>,
972    /// `options` as a command check reads them, in `NX_TASK_OPTIONS`.
973    options_json: String,
974}
975
976/// A continuous target's `readyWhen`, as Nx 24 reads it. `null` turns off one
977/// a target default sets. A target that is not continuous has none.
978fn ready_inputs(
979    workspace: &Workspace,
980    task: &Task,
981    options: &Map<String, Value>,
982) -> Result<Option<ReadyInputs>> {
983    let Some(ready_when) = task
984        .definition
985        .extra
986        .get("readyWhen")
987        .filter(|value| !value.is_null() && task.definition.continuous == Some(true))
988    else {
989        return Ok(None);
990    };
991    let mut options = options.clone();
992    let project = &workspace.projects[&task.project];
993    for (key, value) in interpolate::yargs(&task.args, false, false) {
994        if key == "_" {
995            continue;
996        }
997        // As in Nx, an argument's tokens resolve for the task's project.
998        let value = match value {
999            Value::String(text) => Value::String(qk_config::interpolate_override(&text, project)?),
1000            value => value,
1001        };
1002        if key.contains('-') {
1003            options.insert(interpolate::camel_case(&key), value.clone());
1004        }
1005        options.insert(key, value);
1006    }
1007    Ok(Some(ReadyInputs {
1008        config: ready_when.clone(),
1009        workspace_root: workspace
1010            .root
1011            .to_str()
1012            .context("workspace path must be UTF-8")?
1013            .to_owned(),
1014        project_root: project.root.clone(),
1015        project_name: task.project.clone(),
1016        options_json: serde_json::to_string(&options)?,
1017        options,
1018    }))
1019}
1020
1021impl PreparedTask {
1022    /// The task's own `readyWhen`, resolved against its options and the
1023    /// environment it runs with, with `url` as `NX_READY_URL`. As in Nx
1024    /// 24.0.1, a task waiting for a server that reports its address resolves
1025    /// it once that server is ready, so its placeholders and a command check
1026    /// read the address.
1027    pub fn resolve_ready_check(
1028        &self,
1029        url: Option<&str>,
1030    ) -> Option<std::result::Result<readiness::ReadyCheck, String>> {
1031        let inputs = self.ready_inputs.as_deref()?;
1032        let mut full_env = self.env.clone();
1033        if let Some(url) = url {
1034            full_env.insert(ready_signal::URL_ENV.into(), url.into());
1035        }
1036        let env: BTreeMap<String, String> = full_env
1037            .iter()
1038            .filter_map(|(name, value)| {
1039                Some((name.to_str()?.to_owned(), value.to_str()?.to_owned()))
1040            })
1041            .collect();
1042        let scope = readiness::Scope {
1043            workspace_root: &inputs.workspace_root,
1044            project_root: &inputs.project_root,
1045            project_name: &inputs.project_name,
1046            options: &inputs.options,
1047            env: &env,
1048        };
1049        let mut check = readiness::resolve(&self.id, &inputs.config, &scope);
1050        // As in Nx 24, a command check runs as the task's commands do, and
1051        // reads the options they run with.
1052        if let Ok(Some(readiness::ReadyCheck {
1053            probe: readiness::Probe::Command { env: probe_env, .. },
1054            ..
1055        })) = &mut check
1056        {
1057            *probe_env = full_env;
1058            probe_env.insert("NX_TASK_OPTIONS".into(), inputs.options_json.clone().into());
1059        }
1060        check.map_err(|error| format!("{error:#}")).transpose()
1061    }
1062}
1063
1064/// The run-commands options Nx reads, which a task's arguments may set too.
1065const OPTION_FLAGS: &[&str] = &[
1066    "command",
1067    "commands",
1068    "color",
1069    "parallel",
1070    "readyWhen",
1071    "cwd",
1072    "args",
1073    "envFile",
1074    "__unparsed__",
1075    "env",
1076    "usePty",
1077    "streamOutput",
1078    "verbose",
1079    "forwardAllArgs",
1080    "tty",
1081];
1082
1083/// The target's options with the task's arguments that name a run-commands
1084/// option applied. As in Nx, `--cwd=dir`, `--no-parallel` or
1085/// `--env.NAME=value` set the option rather than reaching the command.
1086fn run_commands_overrides(
1087    options: &serde_json::Map<String, Value>,
1088    args: &[String],
1089    project: &qk_config::Project,
1090) -> Result<serde_json::Map<String, Value>> {
1091    const BOOLEAN: &[&str] = &[
1092        "color",
1093        "parallel",
1094        "usePty",
1095        "streamOutput",
1096        "verbose",
1097        "forwardAllArgs",
1098        "tty",
1099    ];
1100    let mut options = options.clone();
1101    let mut ready = Vec::new();
1102    let mut args = args.iter().peekable();
1103    while let Some(arg) = args.next() {
1104        let Some(flag) = arg.strip_prefix("--") else {
1105            continue;
1106        };
1107        let (key, value) = match flag.split_once('=') {
1108            Some((key, value)) => (key, Some(value.to_owned())),
1109            None => (flag, None),
1110        };
1111        let (key, negated) = match key.strip_prefix("no-") {
1112            Some(key) if OPTION_FLAGS.contains(&key) => (key, true),
1113            _ => (key, false),
1114        };
1115        let (base, field) = match key.split_once('.') {
1116            Some((base, field)) => (base, Some(field)),
1117            None => (key, None),
1118        };
1119        if !OPTION_FLAGS.contains(&base) {
1120            continue;
1121        }
1122        let boolean = BOOLEAN.contains(&base);
1123        let value = match value {
1124            Some(value) => value,
1125            None if negated => "false".into(),
1126            None if !boolean && args.peek().is_some_and(|next| !next.starts_with('-')) => {
1127                args.next().unwrap().clone()
1128            }
1129            None => "true".into(),
1130        };
1131        let value = if boolean {
1132            Value::Bool(value != "false")
1133        } else {
1134            // As in Nx, an argument's tokens resolve for the task's project.
1135            Value::String(qk_config::interpolate_override(&value, project)?)
1136        };
1137        match (base, field) {
1138            ("env", Some(name)) => {
1139                let env = options
1140                    .entry("env")
1141                    .or_insert_with(|| Value::Object(Default::default()));
1142                if let Value::Object(env) = env {
1143                    env.insert(name.to_owned(), value);
1144                }
1145            }
1146            ("readyWhen", None) => ready.push(value),
1147            // Nx sets these itself, and a list cannot come from a flag.
1148            ("__unparsed__" | "commands", _) | (_, Some(_)) => {}
1149            (base, None) => {
1150                options.insert(base.to_owned(), value);
1151            }
1152        }
1153    }
1154    if !ready.is_empty() {
1155        let ready = if ready.len() == 1 {
1156            ready.pop().unwrap()
1157        } else {
1158            Value::Array(ready)
1159        };
1160        options.insert("readyWhen".into(), ready);
1161    }
1162    Ok(options)
1163}
1164
1165/// JavaScript truthiness, which decides what Nx reads as set.
1166fn merge_truthy(value: &Value) -> bool {
1167    match value {
1168        Value::Null => false,
1169        Value::Bool(flag) => *flag,
1170        Value::Number(number) => number.as_f64().is_some_and(|number| number != 0.0),
1171        Value::String(text) => !text.is_empty(),
1172        Value::Array(_) | Value::Object(_) => true,
1173    }
1174}
1175
1176fn boolean(value: Option<&Value>, default: bool, name: &str) -> Result<bool> {
1177    value
1178        .map(|value| {
1179            value
1180                .as_bool()
1181                .with_context(|| format!("{name} must be boolean"))
1182        })
1183        .transpose()
1184        .map(|value| value.unwrap_or(default))
1185}
1186
1187fn package_manager(workspace: &Workspace) -> Result<&'static str> {
1188    workspace.package_manager_name()
1189}
1190
1191#[cfg(all(test, windows))]
1192mod dotenv_tests {
1193    #[test]
1194    fn colon_targets_keep_configuration_and_generic_dotenv_files() {
1195        let files = super::dotenv_files("app", "test:unit:watch", Some("ci"), None);
1196        assert!(files.iter().all(|file| !file.contains(':')));
1197        assert!(files.contains(&"app/.env.ci".to_owned()));
1198        assert!(files.contains(&"app/.env".to_owned()));
1199        assert!(files.contains(&".env.ci".to_owned()));
1200        assert!(files.contains(&".env".to_owned()));
1201    }
1202}
1203
1204#[cfg(test)]
1205mod loaded_tests {
1206    use super::*;
1207
1208    /// The dotenv files a task's environment was loaded from are those
1209    /// `prepare` loaded: its `envFile` whatever its name, absolute or not,
1210    /// but not when the task's own dotenv files turn loading off.
1211    #[test]
1212    fn lists_the_dotenv_files_it_loaded() {
1213        let temp = tempfile::tempdir().unwrap();
1214        let root = temp.path();
1215        let absolute = root.join("config/absolute.env");
1216        std::fs::write(root.join("nx.json"), "{}").unwrap();
1217        std::fs::write(
1218            root.join("project.json"),
1219            serde_json::json!({"name": "app", "targets": {
1220                "plain": {"command": "true", "options": {"envFile": "config/variables.txt"}},
1221                "absolute": {"command": "true", "options": {"envFile": absolute}},
1222                "off": {"command": "true", "options": {"envFile": "config/variables.txt"}}
1223            }})
1224            .to_string(),
1225        )
1226        .unwrap();
1227        std::fs::create_dir_all(root.join("config")).unwrap();
1228        std::fs::write(root.join("config/variables.txt"), "A=1\n").unwrap();
1229        std::fs::write(&absolute, "B=1\n").unwrap();
1230        std::fs::write(root.join(".env.off"), "NX_LOAD_DOT_ENV_FILES=false\n").unwrap();
1231        std::fs::write(root.join(".env"), "C=1\n").unwrap();
1232        let workspace = Workspace::load(root).unwrap();
1233        let requests = ["app:plain", "app:absolute", "app:off"]
1234            .map(|request| qk_taskgraph::Request::parse(request).unwrap());
1235        let graph = qk_taskgraph::TaskGraph::build(&workspace, &requests).unwrap();
1236        let loaded = |id: &str| {
1237            prepare(&workspace, &graph.tasks[id], &BTreeMap::new())
1238                .unwrap()
1239                .dotenv
1240        };
1241        let plain = loaded("app:plain");
1242        assert!(plain.contains(&workspace.root.join(".env")), "{plain:?}");
1243        // A candidate that is not there was not loaded.
1244        assert!(
1245            !plain.contains(&workspace.root.join(".env.local")),
1246            "{plain:?}"
1247        );
1248        assert_eq!(
1249            plain.last(),
1250            Some(&workspace.root.join("config/variables.txt"))
1251        );
1252        assert_eq!(loaded("app:absolute").last(), Some(&absolute));
1253        let off = loaded("app:off");
1254        assert!(off.contains(&workspace.root.join(".env.off")), "{off:?}");
1255        assert!(
1256            !off.contains(&workspace.root.join("config/variables.txt")),
1257            "{off:?}"
1258        );
1259    }
1260}