1mod 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#[macro_export]
26macro_rules! status {
27 ($($arg:tt)*) => {{
28 $crate::report::warning(&::std::format!($($arg)*));
29 }};
30}
31
32#[derive(Clone, Debug)]
34pub enum Confinement {
35 Seatbelt(PathBuf),
37 Landlock(i32),
40}
41
42#[derive(Clone, Debug)]
44pub struct Recording {
45 pub program: PathBuf,
46 pub directory: PathBuf,
47}
48
49impl Recording {
50 pub fn command(&self, index: usize, text: &str) -> std::process::Command {
52 let mut command = std::process::Command::new(&self.program);
53 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 pub execution: BTreeMap<OsString, OsString>,
84 pub ready_when: Vec<String>,
87 pub ready: std::sync::Arc<std::sync::atomic::AtomicBool>,
89 pub decorations: Vec<Decoration>,
91 pub interactive: bool,
95 pub sandbox: Option<Confinement>,
97 pub recording: Option<Recording>,
98 pub display: Display,
100}
101
102fn 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 #[cfg(windows)]
141 if identifier.contains(':') {
142 continue;
143 }
144 for file in variants(identifier, root) {
145 if !files.contains(&file) {
146 files.push(file);
147 }
148 }
149 }
150 }
151 files
152}
153
154fn task_environment(
158 workspace: &Workspace,
159 task: &Task,
160 base_env: &BTreeMap<OsString, OsString>,
161) -> Result<BTreeMap<OsString, OsString>> {
162 let mut env = base_env.clone();
163 let root = &workspace.projects[&task.project].root;
164 let files = if loads_dotenv(base_env) {
165 dotenv_files(root, &task.target, task.configuration.as_deref())
166 } else {
167 Vec::new()
168 };
169 for file in files {
170 for (name, value) in read_dotenv(&workspace.root.join(file))? {
171 env.entry(name).or_insert(value);
172 }
173 }
174 let mut set = |name: &str, value: OsString| {
175 env.insert(name.into(), value);
176 };
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
191fn 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
198fn 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 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
217pub 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 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 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 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 let mut execution = BTreeMap::new();
343 execution.insert(
344 "FORCE_COLOR".into(),
345 env.get(std::ffi::OsStr::new("FORCE_COLOR"))
346 .cloned()
347 .unwrap_or_else(|| "true".into()),
348 );
349 if executor == "nx:run-commands" && boolean(options.get("color"), false, "color")? {
350 execution.insert("FORCE_COLOR".into(), "true".into());
351 }
352 for key in options.keys() {
353 let allowed = match executor {
354 "nx:run-commands" => true,
358 "nx:run-script" => matches!(key.as_str(), "script" | "env" | "cwd"),
359 "nx:noop" => true,
360 _ => bail!("unsupported executor {executor:?}"),
361 };
362 if !allowed {
363 bail!("unsupported {executor} option {key:?}");
364 }
365 }
366 let default_cwd = if executor == "nx:run-script" {
367 &workspace.projects[&task.project].root
368 } else {
369 "."
370 };
371 let cwd = options
372 .get("cwd")
373 .map(|value| value.as_str().context("cwd must be a string"))
374 .transpose()?
375 .unwrap_or(default_cwd);
376 let cwd = workspace.root.join(interpolation.text(cwd)?);
377 if !cwd.is_dir() {
378 bail!("working directory does not exist: {}", cwd.display());
379 }
380 let mut paths = Vec::new();
382 for directory in cwd
383 .ancestors()
384 .take_while(|directory| directory.starts_with(&workspace.root))
385 {
386 paths.push(directory.join("node_modules/.bin"));
387 }
388 let path_key = if cfg!(windows) {
389 env.keys()
390 .find(|key| key.to_string_lossy().eq_ignore_ascii_case("PATH"))
391 .cloned()
392 .unwrap_or_else(|| "PATH".into())
393 } else {
394 "PATH".into()
395 };
396 if let Some(path) = env.get(&path_key) {
397 paths.extend(std::env::split_paths(path));
398 }
399 env.insert(
400 path_key,
401 std::env::join_paths(paths).context("cannot construct task PATH")?,
402 );
403 let parallel = boolean(options.get("parallel"), true, "parallel")?;
404 let mut decorations = Vec::new();
405 let ready_when: Vec<String> = match options.get("readyWhen") {
406 None | Some(Value::Null) => Vec::new(),
407 Some(Value::String(text)) => vec![text.clone()],
408 Some(value) => serde_json::from_value(value.clone())
409 .context("readyWhen must be a string or an array of strings")?,
410 };
411 if !ready_when.is_empty() && options.contains_key("commands") && !parallel {
412 bail!("readyWhen can only be used when parallel is true");
413 }
414 let mut commands = Vec::new();
415 match executor {
416 "nx:noop" => {}
417 "nx:run-script" => {
418 let script = options
419 .get("script")
420 .and_then(Value::as_str)
421 .context("run-script requires options.script")?;
422 let package = workspace
423 .packages
424 .get(&task.project)
425 .context("run-script requires a package.json")?;
426 if !package.scripts.contains_key(script) {
427 bail!("package.json has no script {script:?}");
428 }
429 let manager = package_manager(workspace)?;
430 let args = interpolation.arguments()?;
431 let script = interpolate::quote(script)?;
432 let mut command = match manager {
435 "yarn" => format!("yarn {script}"),
436 manager => format!("{manager} run {script}"),
437 };
438 if !args.is_empty() {
439 if matches!(manager, "npm" | "bun") {
440 command.push_str(" --");
441 }
442 command.push(' ');
443 command.push_str(&args);
444 }
445 commands.push(command);
446 }
447 "nx:run-commands" => {
448 if options.contains_key("command") && options.contains_key("commands") {
449 bail!("specify command or commands, not both");
450 }
451 let forward = boolean(options.get("forwardAllArgs"), true, "forwardAllArgs")?;
452 if let Some(command) = options.get("command") {
453 let command = match command {
455 Value::String(command) => command.clone(),
456 Value::Array(words) => words
457 .iter()
458 .map(|word| word.as_str().context("command words must be strings"))
459 .collect::<Result<Vec<_>>>()?
460 .join(" "),
461 _ => bail!("command must be a string or an array of strings"),
462 };
463 commands.push(interpolation.command(&command, forward)?);
464 } else if let Some(values) = options.get("commands") {
465 for value in values.as_array().context("commands must be an array")? {
466 let (command, forward) = if let Some(command) = value.as_str() {
467 (command, forward)
468 } else {
469 let value = value
470 .as_object()
471 .context("commands entries must be strings or objects")?;
472 for key in value.keys() {
473 if !matches!(
474 key.as_str(),
475 "command"
476 | "forwardAllArgs"
477 | "prefix"
478 | "prefixColor"
479 | "color"
480 | "bgColor"
481 | "description"
482 ) {
483 bail!("unsupported command entry field {key:?}");
484 }
485 }
486 let field =
487 |name: &str, background: Option<bool>| -> Result<Option<String>> {
488 let Some(value) = value.get(name) else {
489 return Ok(None);
490 };
491 let text = value.as_str().with_context(|| {
492 format!("command entry {name} must be a string")
493 })?;
494 if let Some(background) = background
495 && !Decoration::valid(text, background)
496 {
497 bail!("command entry {name} {text:?} is not a colour");
498 }
499 Ok(Some(text.to_owned()))
500 };
501 let decoration = Decoration {
502 prefix: field("prefix", None)?,
503 prefix_color: field("prefixColor", Some(false))?,
504 color: field("color", Some(false))?,
505 bg_color: field("bgColor", Some(true))?,
506 };
507 if !decoration.is_empty() && !parallel {
508 bail!(
509 "prefix, prefixColor, color and bgColor can only be set when parallel is true"
510 );
511 }
512 decorations.resize(commands.len(), Decoration::default());
513 decorations.push(decoration);
514 (
515 value
516 .get("command")
517 .and_then(Value::as_str)
518 .context("command entry requires a command string")?,
519 boolean(value.get("forwardAllArgs"), forward, "forwardAllArgs")?,
520 )
521 };
522 commands.push(interpolation.command(command, forward)?);
523 }
524 } else {
525 bail!("run-commands requires command or commands");
526 }
527 if commands.iter().any(|command| command.trim().is_empty()) {
529 bail!("run-commands requires nonempty commands");
530 }
531 }
532 _ => bail!("unsupported executor {executor:?}"),
533 }
534 Ok(PreparedTask {
535 id: task.id.clone(),
536 commands,
537 parallel,
538 cwd,
539 env,
540 execution,
541 ready_when,
542 ready: Default::default(),
543 decorations,
544 interactive: false,
545 sandbox: None,
546 recording: None,
547 display: Display::default(),
548 })
549}
550
551const OPTION_FLAGS: &[&str] = &[
553 "command",
554 "commands",
555 "color",
556 "parallel",
557 "readyWhen",
558 "cwd",
559 "args",
560 "envFile",
561 "__unparsed__",
562 "env",
563 "usePty",
564 "streamOutput",
565 "verbose",
566 "forwardAllArgs",
567 "tty",
568];
569
570fn run_commands_overrides(
575 options: &serde_json::Map<String, Value>,
576 args: &[String],
577) -> (serde_json::Map<String, Value>, Vec<String>) {
578 const BOOLEAN: &[&str] = &[
579 "color",
580 "parallel",
581 "usePty",
582 "streamOutput",
583 "verbose",
584 "forwardAllArgs",
585 "tty",
586 ];
587 let mut options = options.clone();
588 let mut rest = Vec::new();
589 let mut ready = Vec::new();
590 let mut args = args.iter().peekable();
591 while let Some(arg) = args.next() {
592 let Some(flag) = arg.strip_prefix("--") else {
593 rest.push(arg.clone());
594 continue;
595 };
596 let (key, value) = match flag.split_once('=') {
597 Some((key, value)) => (key, Some(value.to_owned())),
598 None => (flag, None),
599 };
600 let (key, negated) = match key.strip_prefix("no-") {
601 Some(key) if OPTION_FLAGS.contains(&key) => (key, true),
602 _ => (key, false),
603 };
604 let (base, field) = match key.split_once('.') {
605 Some((base, field)) => (base, Some(field)),
606 None => (key, None),
607 };
608 if !OPTION_FLAGS.contains(&base) {
609 rest.push(arg.clone());
610 continue;
611 }
612 let boolean = BOOLEAN.contains(&base);
613 let value = match value {
614 Some(value) => value,
615 None if negated => "false".into(),
616 None if !boolean && args.peek().is_some_and(|next| !next.starts_with('-')) => {
617 args.next().unwrap().clone()
618 }
619 None => "true".into(),
620 };
621 let value = if boolean {
622 Value::Bool(value != "false")
623 } else {
624 Value::String(value)
625 };
626 match (base, field) {
627 ("env", Some(name)) => {
628 let env = options
629 .entry("env")
630 .or_insert_with(|| Value::Object(Default::default()));
631 if let Value::Object(env) = env {
632 env.insert(name.to_owned(), value);
633 }
634 }
635 ("readyWhen", None) => ready.push(value),
636 ("__unparsed__" | "commands", _) | (_, Some(_)) => {}
638 (base, None) => {
639 options.insert(base.to_owned(), value);
640 }
641 }
642 }
643 if !ready.is_empty() {
644 let ready = if ready.len() == 1 {
645 ready.pop().unwrap()
646 } else {
647 Value::Array(ready)
648 };
649 options.insert("readyWhen".into(), ready);
650 }
651 (options, rest)
652}
653
654fn boolean(value: Option<&Value>, default: bool, name: &str) -> Result<bool> {
655 value
656 .map(|value| {
657 value
658 .as_bool()
659 .with_context(|| format!("{name} must be boolean"))
660 })
661 .transpose()
662 .map(|value| value.unwrap_or(default))
663}
664
665fn package_manager(workspace: &Workspace) -> Result<&'static str> {
669 let known = |name: &str| -> Result<&'static str> {
670 Ok(match name {
671 "npm" => "npm",
672 "pnpm" => "pnpm",
673 "yarn" => "yarn",
674 "bun" => "bun",
675 other => bail!("unknown package manager {other:?}"),
676 })
677 };
678 if let Some(name) = workspace
679 .config
680 .extra
681 .get("cli")
682 .and_then(|cli| cli.get("packageManager"))
683 .and_then(Value::as_str)
684 {
685 return known(name);
686 }
687 let root = &workspace.root;
688 for (file, manager) in [
689 ("bun.lockb", "bun"),
690 ("bun.lock", "bun"),
691 ("yarn.lock", "yarn"),
692 ("pnpm-lock.yaml", "pnpm"),
693 ("package-lock.json", "npm"),
694 ] {
695 if root.join(file).is_file() {
696 return Ok(manager);
697 }
698 }
699 if let Some(declared) = &workspace.package_manager {
700 return known(declared.split('@').next().unwrap_or(declared));
701 }
702 if root.join("pnpm-workspace.yaml").is_file() {
703 return Ok("pnpm");
704 }
705 let agent = std::env::var("npm_config_user_agent").unwrap_or_default();
706 for manager in ["pnpm", "yarn", "bun"] {
707 if agent.starts_with(&format!("{manager}/")) {
708 return Ok(manager);
709 }
710 }
711 Ok("npm")
712}
713
714#[cfg(all(test, windows))]
715mod dotenv_tests {
716 #[test]
717 fn colon_targets_keep_configuration_and_generic_dotenv_files() {
718 let files = super::dotenv_files("app", "test:unit:watch", Some("ci"));
719 assert!(files.iter().all(|file| !file.contains(':')));
720 assert!(files.contains(&"app/.env.ci".to_owned()));
721 assert!(files.contains(&"app/.env".to_owned()));
722 assert!(files.contains(&".env.ci".to_owned()));
723 assert!(files.contains(&".env".to_owned()));
724 }
725}