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 for file in variants(identifier, root) {
140 if !files.contains(&file) {
141 files.push(file);
142 }
143 }
144 }
145 }
146 files
147}
148
149fn 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
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 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 "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 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 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 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 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
544const 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
563fn 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 ("__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
658fn 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}