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#[derive(Debug)]
24struct NodeRuntime {
25 root: PathBuf,
26 env: BTreeMap<OsString, OsString>,
27 paths: Vec<PathBuf>,
28 directory: std::sync::OnceLock<Option<PathBuf>>,
29}
30
31#[derive(Clone, Debug)]
33pub struct NodePath {
34 runtime: std::sync::Arc<NodeRuntime>,
35 local_entries: usize,
36}
37
38impl NodePath {
39 fn new(root: &Path, env: &BTreeMap<OsString, OsString>, local_entries: usize) -> Self {
40 type Key = (PathBuf, BTreeMap<OsString, OsString>);
41 static RUNTIMES: std::sync::OnceLock<
42 std::sync::Mutex<BTreeMap<Key, std::sync::Arc<NodeRuntime>>>,
43 > = std::sync::OnceLock::new();
44 let mut runtimes = RUNTIMES.get_or_init(Default::default).lock().unwrap();
45 let runtime = runtimes
46 .entry((root.to_owned(), env.clone()))
47 .or_insert_with(|| {
48 let paths = env
49 .get(&path_key(env))
50 .map(|path| std::env::split_paths(path).collect())
51 .unwrap_or_default();
52 std::sync::Arc::new(NodeRuntime {
53 root: root.to_owned(),
54 env: env.clone(),
55 paths,
56 directory: Default::default(),
57 })
58 })
59 .clone();
60 Self {
61 runtime,
62 local_entries,
63 }
64 }
65
66 fn path(&self, env: &BTreeMap<OsString, OsString>) -> Result<Option<(OsString, OsString)>> {
67 let Some(directory) = self.runtime.directory.get_or_init(|| {
70 node_directory(&self.runtime.root, &self.runtime.paths, &self.runtime.env)
71 }) else {
72 return Ok(None);
73 };
74 let key = path_key(env);
75 let mut paths: Vec<_> = env
76 .get(&key)
77 .map(|path| std::env::split_paths(path).collect())
78 .unwrap_or_default();
79 paths.insert(self.local_entries.min(paths.len()), directory.clone());
80 Ok(Some((
81 key,
82 std::env::join_paths(paths).context("cannot construct task PATH")?,
83 )))
84 }
85}
86
87fn path_key(env: &BTreeMap<OsString, OsString>) -> OsString {
88 if cfg!(windows) {
89 env.keys()
90 .find(|key| key.to_string_lossy().eq_ignore_ascii_case("PATH"))
91 .cloned()
92 .unwrap_or_else(|| "PATH".into())
93 } else {
94 "PATH".into()
95 }
96}
97
98fn environment_key(env: &BTreeMap<OsString, OsString>, name: OsString) -> OsString {
99 if cfg!(windows) && name.to_string_lossy().eq_ignore_ascii_case("PATH") {
100 path_key(env)
101 } else {
102 name
103 }
104}
105
106fn node_directory(
107 cwd: &Path,
108 paths: &[PathBuf],
109 env: &BTreeMap<OsString, OsString>,
110) -> Option<PathBuf> {
111 let name = if cfg!(windows) { "node.exe" } else { "node" };
112 for directory in paths {
113 let candidate = cwd.join(directory).join(name);
114 let Ok(metadata) = candidate.metadata() else {
115 continue;
116 };
117 if !metadata.is_file() {
118 continue;
119 }
120 #[cfg(unix)]
121 {
122 use std::os::unix::fs::PermissionsExt;
123 if metadata.permissions().mode() & 0o111 == 0 {
124 continue;
125 }
126 }
127 let actual = candidate.canonicalize().ok()?;
129 if actual.file_name()? == name {
130 use std::io::Read;
131 let mut header = [0; 2];
132 let script = std::fs::File::open(&actual)
133 .and_then(|mut file| file.read_exact(&mut header))
134 .is_ok()
135 && header == *b"#!";
136 if !script {
137 return actual.parent().map(Path::to_owned);
138 }
139 }
140 return probe_node(&candidate, cwd, env);
141 }
142 None
143}
144
145fn probe_node(candidate: &Path, cwd: &Path, env: &BTreeMap<OsString, OsString>) -> Option<PathBuf> {
146 use std::io::{Read, Seek};
147 use std::process::{Command, Stdio};
148 use std::time::{Duration, Instant};
149 let mut output = tempfile::tempfile().ok()?;
150 let mut child = Command::new(candidate)
151 .args(["-p", "process.execPath"])
152 .current_dir(cwd)
153 .env_clear()
154 .envs(env)
155 .stdout(Stdio::from(output.try_clone().ok()?))
156 .stderr(Stdio::null())
157 .spawn()
158 .ok()?;
159 let started = Instant::now();
160 loop {
161 match child.try_wait() {
162 Ok(Some(status)) => {
163 if !status.success() {
164 return None;
165 }
166 let mut bytes = Vec::new();
167 output.rewind().ok()?;
168 output.take(4096).read_to_end(&mut bytes).ok()?;
169 let path = PathBuf::from(std::str::from_utf8(&bytes).ok()?.trim());
170 return (path.is_absolute() && path.is_file())
171 .then(|| path.parent().unwrap().to_owned());
172 }
173 Ok(None) if started.elapsed() < Duration::from_secs(2) => {
174 std::thread::sleep(Duration::from_millis(5))
175 }
176 _ => {
177 let _ = child.kill();
178 let _ = child.wait();
179 return None;
180 }
181 }
182 }
183}
184
185#[macro_export]
188macro_rules! status {
189 ($($arg:tt)*) => {{
190 $crate::report::warning(&::std::format!($($arg)*));
191 }};
192}
193
194#[derive(Clone, Debug)]
196pub enum Confinement {
197 Seatbelt(PathBuf),
199 Landlock(i32),
202}
203
204#[derive(Clone, Debug)]
206pub struct Recording {
207 pub program: PathBuf,
208 pub directory: PathBuf,
209}
210
211impl Recording {
212 pub fn command(&self, index: usize, text: &str) -> std::process::Command {
214 let mut command = std::process::Command::new(&self.program);
215 command
217 .args([
218 "-ff",
219 "-ttt",
220 "-yy",
221 "-qq",
222 "-s",
223 "4096",
224 "-e",
225 "trace=%file,%process,getdents64,fchdir,close,dup,dup2,dup3,getcwd",
226 "-e",
227 "raw=execve,execveat",
228 "-o",
229 ])
230 .arg(self.directory.join(format!("command-{index}")))
231 .args(["--", "/bin/sh", "-c", text]);
232 command
233 }
234}
235
236#[derive(Clone, Debug)]
237pub struct PreparedTask {
238 pub id: String,
239 pub commands: Vec<String>,
240 pub parallel: bool,
241 pub cwd: PathBuf,
242 pub env: BTreeMap<OsString, OsString>,
243 pub runtime_env: BTreeMap<OsString, OsString>,
245 pub node_path: Option<NodePath>,
246 pub execution: BTreeMap<OsString, OsString>,
249 pub ready_when: Vec<String>,
252 pub ready: std::sync::Arc<std::sync::atomic::AtomicBool>,
254 pub processes: std::sync::Arc<std::sync::Mutex<std::collections::BTreeSet<u32>>>,
257 pub decorations: Vec<Decoration>,
259 pub interactive: bool,
263 pub sandbox: Option<Confinement>,
265 pub recording: Option<Recording>,
266 pub display: Display,
268}
269
270fn dotenv_files(project_root: &str, target: &str, configuration: Option<&str>) -> Vec<String> {
276 let mut identifiers = Vec::new();
277 if let Some(configuration) = configuration {
278 identifiers.push(format!("{target}.{configuration}"));
279 identifiers.push(configuration.to_owned());
280 }
281 identifiers.push(target.to_owned());
282 identifiers.push(String::new());
283 let variants = |identifier: &str, root: &str| -> Vec<String> {
284 let prefix = if root.is_empty() || root == "." {
285 String::new()
286 } else {
287 format!("{root}/")
288 };
289 if identifier.is_empty() {
290 [".env.local", ".local.env", ".env"]
291 .map(|name| format!("{prefix}{name}"))
292 .to_vec()
293 } else {
294 [
295 format!(".env.{identifier}.local"),
296 format!(".env.{identifier}"),
297 format!(".{identifier}.local.env"),
298 format!(".{identifier}.env"),
299 ]
300 .map(|name| format!("{prefix}{name}"))
301 .to_vec()
302 }
303 };
304 let mut files = Vec::new();
305 for root in [project_root, ""] {
306 for identifier in &identifiers {
307 #[cfg(windows)]
309 if identifier.contains(':') {
310 continue;
311 }
312 for file in variants(identifier, root) {
313 if !files.contains(&file) {
314 files.push(file);
315 }
316 }
317 }
318 }
319 files
320}
321
322fn task_environment(
326 workspace: &Workspace,
327 task: &Task,
328 base_env: &BTreeMap<OsString, OsString>,
329) -> Result<(BTreeMap<OsString, OsString>, BTreeMap<OsString, OsString>)> {
330 let mut env = base_env.clone();
331 let root = &workspace.projects[&task.project].root;
332 let files = if loads_dotenv(base_env) {
333 dotenv_files(root, &task.target, task.configuration.as_deref())
334 } else {
335 Vec::new()
336 };
337 for file in files {
338 for (name, value) in read_dotenv(&workspace.root.join(file))? {
339 let name = environment_key(&env, name);
340 env.entry(name).or_insert(value);
341 }
342 }
343 let runtime_env = env.clone();
344 let mut set = |name: &str, value: OsString| {
345 env.insert(name.into(), value);
346 };
347 set("NX_WORKSPACE_ROOT", workspace.root.clone().into_os_string());
348 set("NX_TASK_TARGET_PROJECT", task.project.clone().into());
349 set("NX_TASK_TARGET_TARGET", task.target.clone().into());
350 set("LERNA_PACKAGE_NAME", task.project.clone().into());
351 set("NX_TUI", "false".into());
352 match &task.configuration {
353 Some(configuration) => set("NX_TASK_TARGET_CONFIGURATION", configuration.clone().into()),
354 None => {
355 env.remove(std::ffi::OsStr::new("NX_TASK_TARGET_CONFIGURATION"));
356 }
357 }
358 Ok((env, runtime_env))
359}
360
361fn loads_dotenv(env: &BTreeMap<OsString, OsString>) -> bool {
364 env.get(std::ffi::OsStr::new("NX_LOAD_DOT_ENV_FILES"))
365 .is_none_or(|value| value != "false")
366}
367
368fn read_dotenv(path: &Path) -> Result<Vec<(OsString, OsString)>> {
370 let file = match std::fs::File::open(path) {
371 Ok(file) => file,
372 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
373 Err(error) => return Err(error).with_context(|| format!("cannot read {}", path.display())),
374 };
375 dotenvy::from_read_iter(file)
376 .enumerate()
377 .map(|(index, entry)| {
378 let (name, value) = entry.map_err(|_| {
380 anyhow::anyhow!("invalid dotenv entry {} in {}", index + 1, path.display())
381 })?;
382 Ok((name.into(), value.into()))
383 })
384 .collect()
385}
386
387pub fn environment(workspace: &Path) -> Result<BTreeMap<OsString, OsString>> {
390 let mut values = BTreeMap::new();
391 let process: BTreeMap<OsString, OsString> = std::env::vars_os().collect();
392 let names: &[&str] = if loads_dotenv(&process) {
393 &[".env", ".env.local"]
394 } else {
395 &[]
396 };
397 for name in names {
398 let path = workspace.join(name);
399 let file = match std::fs::File::open(&path) {
400 Ok(file) => file,
401 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
402 Err(error) => {
403 return Err(error).with_context(|| format!("cannot read {}", path.display()));
404 }
405 };
406 for (index, entry) in dotenvy::from_read_iter(file).enumerate() {
407 let (name, value) = entry.map_err(|_| {
409 anyhow::anyhow!("invalid dotenv entry {} in {}", index + 1, path.display())
410 })?;
411 values.insert(name.into(), value.into());
412 }
413 }
414 values.extend(std::env::vars_os());
415 Ok(values)
416}
417
418pub fn prepare(
419 workspace: &Workspace,
420 task: &Task,
421 base_env: &BTreeMap<OsString, OsString>,
422) -> Result<PreparedTask> {
423 let definition = &task.definition;
424 let executor = definition
425 .executor
426 .as_deref()
427 .context("target has no executor or command")?;
428 let (options, args) = if executor == "nx:run-commands" {
429 run_commands_overrides(&definition.options, &task.args)
430 } else {
431 (definition.options.clone(), task.args.clone())
432 };
433 let options = &options;
434 const RUN_COMMANDS: &[&str] = &[
437 "command",
438 "commands",
439 "color",
440 "no-color",
441 "parallel",
442 "no-parallel",
443 "readyWhen",
444 "cwd",
445 "args",
446 "envFile",
447 "__unparsed__",
448 "env",
449 "usePty",
450 "streamOutput",
451 "verbose",
452 "forwardAllArgs",
453 "tty",
454 ];
455 let mut forwarded = BTreeMap::new();
456 if executor == "nx:run-commands" {
457 for (key, value) in options {
458 if RUN_COMMANDS.contains(&key.as_str()) {
459 continue;
460 }
461 let value = match value {
462 Value::String(text) => text.clone(),
463 Value::Number(number) => number.to_string(),
464 Value::Bool(flag) => flag.to_string(),
465 _ => continue,
466 };
467 forwarded.insert(key.clone(), value);
468 }
469 }
470 let extra = match options.get("args") {
471 None | Some(Value::Null) => None,
472 Some(Value::String(text)) => Some(text.clone()),
473 Some(Value::Array(words)) => Some(
474 words
475 .iter()
476 .map(|word| word.as_str().context("args entries must be strings"))
477 .collect::<Result<Vec<_>>>()?
478 .join(" "),
479 ),
480 Some(_) => bail!("args must be a string or an array of strings"),
481 };
482 let interpolation =
483 interpolate::Interpolation::new(workspace, task, args, &forwarded, extra.as_deref())?;
484 let (mut env, mut runtime_env) = task_environment(workspace, task, base_env)?;
485 if let Some(file) = options.get("envFile") {
487 let file = interpolation.text(file.as_str().context("envFile must be a string")?)?;
488 let path = workspace.root.join(&file);
489 if loads_dotenv(base_env) {
490 if !path.is_file() {
491 bail!("envFile {file} does not exist");
492 }
493 for (name, value) in read_dotenv(&path)? {
494 let name = environment_key(&env, name);
495 runtime_env.entry(name.clone()).or_insert(value.clone());
496 env.entry(name).or_insert(value);
497 }
498 }
499 }
500 for values in [definition.extra.get("env"), options.get("env")]
501 .into_iter()
502 .flatten()
503 {
504 for (name, value) in values.as_object().context("env must be an object")? {
505 if name.is_empty() || name.contains(['=', '\0']) {
506 bail!("invalid environment variable name");
507 }
508 let value = value
509 .as_str()
510 .context("environment values must be strings")?;
511 let value: OsString = interpolation.text(value)?.into();
512 let name = environment_key(&env, name.into());
513 runtime_env.insert(name.clone(), value.clone());
514 env.insert(name, value);
515 }
516 }
517 let mut execution = BTreeMap::new();
518 execution.insert(
519 "FORCE_COLOR".into(),
520 env.get(std::ffi::OsStr::new("FORCE_COLOR"))
521 .cloned()
522 .unwrap_or_else(|| "true".into()),
523 );
524 if executor == "nx:run-commands" && boolean(options.get("color"), false, "color")? {
525 execution.insert("FORCE_COLOR".into(), "true".into());
526 }
527 for key in options.keys() {
528 let allowed = match executor {
529 "nx:run-commands" => true,
533 "nx:run-script" => matches!(key.as_str(), "script" | "env" | "cwd"),
534 "nx:noop" => true,
535 _ => bail!("unsupported executor {executor:?}"),
536 };
537 if !allowed {
538 bail!("unsupported {executor} option {key:?}");
539 }
540 }
541 let default_cwd = if executor == "nx:run-script" {
542 &workspace.projects[&task.project].root
543 } else {
544 "."
545 };
546 let cwd = options
547 .get("cwd")
548 .map(|value| value.as_str().context("cwd must be a string"))
549 .transpose()?
550 .unwrap_or(default_cwd);
551 let cwd = workspace.root.join(interpolation.text(cwd)?);
552 let mut resolved = PathBuf::new();
553 for part in cwd.components() {
554 match part {
555 std::path::Component::ParentDir => {
556 resolved.pop();
557 }
558 std::path::Component::CurDir => {}
559 _ => resolved.push(part.as_os_str()),
560 }
561 }
562 if !cwd.is_dir() {
563 bail!("working directory does not exist: {}", cwd.display());
564 }
565 let mut paths = Vec::new();
567 for directory in resolved.ancestors() {
568 paths.push(directory.join("node_modules/.bin"));
569 }
570 let node_path = NodePath::new(&workspace.root, base_env, paths.len());
571 let path_key = path_key(&env);
572 if let Some(path) = env.get(&path_key) {
573 paths.extend(std::env::split_paths(path));
574 }
575 let path = std::env::join_paths(paths).context("cannot construct task PATH")?;
576 env.insert(path_key.clone(), path.clone());
577 runtime_env.insert(path_key, path);
578 let parallel = boolean(options.get("parallel"), true, "parallel")?;
579 let mut decorations = Vec::new();
580 let ready_when: Vec<String> = match options.get("readyWhen") {
581 None | Some(Value::Null) => Vec::new(),
582 Some(Value::String(text)) => vec![text.clone()],
583 Some(value) => serde_json::from_value(value.clone())
584 .context("readyWhen must be a string or an array of strings")?,
585 };
586 if !ready_when.is_empty() && options.contains_key("commands") && !parallel {
587 bail!("readyWhen can only be used when parallel is true");
588 }
589 let mut commands = Vec::new();
590 match executor {
591 "nx:noop" => {}
592 "nx:run-script" => {
593 let script = options
594 .get("script")
595 .and_then(Value::as_str)
596 .context("run-script requires options.script")?;
597 let package = workspace
598 .packages
599 .get(&task.project)
600 .context("run-script requires a package.json")?;
601 if !package.scripts.contains_key(script) {
602 bail!("package.json has no script {script:?}");
603 }
604 let manager = package_manager(workspace)?;
605 let args = interpolation.arguments()?;
606 let script = interpolate::quote(script)?;
607 let mut command = match manager {
610 "yarn" => format!("yarn {script}"),
611 manager => format!("{manager} run {script}"),
612 };
613 if !args.is_empty() {
614 if matches!(manager, "npm" | "bun") {
615 command.push_str(" --");
616 }
617 command.push(' ');
618 command.push_str(&args);
619 }
620 commands.push(command);
621 }
622 "nx:run-commands" => {
623 if options.contains_key("command") && options.contains_key("commands") {
624 bail!("specify command or commands, not both");
625 }
626 let forward = boolean(options.get("forwardAllArgs"), true, "forwardAllArgs")?;
627 if let Some(command) = options.get("command") {
628 let command = match command {
630 Value::String(command) => command.clone(),
631 Value::Array(words) => words
632 .iter()
633 .map(|word| word.as_str().context("command words must be strings"))
634 .collect::<Result<Vec<_>>>()?
635 .join(" "),
636 _ => bail!("command must be a string or an array of strings"),
637 };
638 commands.push(interpolation.command(&command, forward)?);
639 } else if let Some(values) = options.get("commands") {
640 for value in values.as_array().context("commands must be an array")? {
641 let (command, forward) = if let Some(command) = value.as_str() {
642 (command, forward)
643 } else {
644 let value = value
645 .as_object()
646 .context("commands entries must be strings or objects")?;
647 for key in value.keys() {
648 if !matches!(
649 key.as_str(),
650 "command"
651 | "forwardAllArgs"
652 | "prefix"
653 | "prefixColor"
654 | "color"
655 | "bgColor"
656 | "description"
657 ) {
658 bail!("unsupported command entry field {key:?}");
659 }
660 }
661 let field =
662 |name: &str, background: Option<bool>| -> Result<Option<String>> {
663 let Some(value) = value.get(name) else {
664 return Ok(None);
665 };
666 let text = value.as_str().with_context(|| {
667 format!("command entry {name} must be a string")
668 })?;
669 if let Some(background) = background
670 && !Decoration::valid(text, background)
671 {
672 bail!("command entry {name} {text:?} is not a colour");
673 }
674 Ok(Some(text.to_owned()))
675 };
676 let decoration = Decoration {
677 prefix: field("prefix", None)?,
678 prefix_color: field("prefixColor", Some(false))?,
679 color: field("color", Some(false))?,
680 bg_color: field("bgColor", Some(true))?,
681 };
682 if !decoration.is_empty() && !parallel {
683 bail!(
684 "prefix, prefixColor, color and bgColor can only be set when parallel is true"
685 );
686 }
687 decorations.resize(commands.len(), Decoration::default());
688 decorations.push(decoration);
689 (
690 value
691 .get("command")
692 .and_then(Value::as_str)
693 .context("command entry requires a command string")?,
694 boolean(value.get("forwardAllArgs"), forward, "forwardAllArgs")?,
695 )
696 };
697 commands.push(interpolation.command(command, forward)?);
698 }
699 } else {
700 bail!("run-commands requires command or commands");
701 }
702 if commands.iter().any(|command| command.trim().is_empty()) {
704 bail!("run-commands requires nonempty commands");
705 }
706 }
707 _ => bail!("unsupported executor {executor:?}"),
708 }
709 Ok(PreparedTask {
710 id: task.id.clone(),
711 commands,
712 parallel,
713 cwd,
714 env,
715 runtime_env,
716 node_path: Some(node_path),
717 execution,
718 ready_when,
719 ready: Default::default(),
720 processes: Default::default(),
721 decorations,
722 interactive: false,
723 sandbox: None,
724 recording: None,
725 display: Display::default(),
726 })
727}
728
729const OPTION_FLAGS: &[&str] = &[
731 "command",
732 "commands",
733 "color",
734 "parallel",
735 "readyWhen",
736 "cwd",
737 "args",
738 "envFile",
739 "__unparsed__",
740 "env",
741 "usePty",
742 "streamOutput",
743 "verbose",
744 "forwardAllArgs",
745 "tty",
746];
747
748fn run_commands_overrides(
753 options: &serde_json::Map<String, Value>,
754 args: &[String],
755) -> (serde_json::Map<String, Value>, Vec<String>) {
756 const BOOLEAN: &[&str] = &[
757 "color",
758 "parallel",
759 "usePty",
760 "streamOutput",
761 "verbose",
762 "forwardAllArgs",
763 "tty",
764 ];
765 let mut options = options.clone();
766 let mut rest = Vec::new();
767 let mut ready = Vec::new();
768 let mut args = args.iter().peekable();
769 while let Some(arg) = args.next() {
770 let Some(flag) = arg.strip_prefix("--") else {
771 rest.push(arg.clone());
772 continue;
773 };
774 let (key, value) = match flag.split_once('=') {
775 Some((key, value)) => (key, Some(value.to_owned())),
776 None => (flag, None),
777 };
778 let (key, negated) = match key.strip_prefix("no-") {
779 Some(key) if OPTION_FLAGS.contains(&key) => (key, true),
780 _ => (key, false),
781 };
782 let (base, field) = match key.split_once('.') {
783 Some((base, field)) => (base, Some(field)),
784 None => (key, None),
785 };
786 if !OPTION_FLAGS.contains(&base) {
787 rest.push(arg.clone());
788 continue;
789 }
790 let boolean = BOOLEAN.contains(&base);
791 let value = match value {
792 Some(value) => value,
793 None if negated => "false".into(),
794 None if !boolean && args.peek().is_some_and(|next| !next.starts_with('-')) => {
795 args.next().unwrap().clone()
796 }
797 None => "true".into(),
798 };
799 let value = if boolean {
800 Value::Bool(value != "false")
801 } else {
802 Value::String(value)
803 };
804 match (base, field) {
805 ("env", Some(name)) => {
806 let env = options
807 .entry("env")
808 .or_insert_with(|| Value::Object(Default::default()));
809 if let Value::Object(env) = env {
810 env.insert(name.to_owned(), value);
811 }
812 }
813 ("readyWhen", None) => ready.push(value),
814 ("__unparsed__" | "commands", _) | (_, Some(_)) => {}
816 (base, None) => {
817 options.insert(base.to_owned(), value);
818 }
819 }
820 }
821 if !ready.is_empty() {
822 let ready = if ready.len() == 1 {
823 ready.pop().unwrap()
824 } else {
825 Value::Array(ready)
826 };
827 options.insert("readyWhen".into(), ready);
828 }
829 (options, rest)
830}
831
832fn boolean(value: Option<&Value>, default: bool, name: &str) -> Result<bool> {
833 value
834 .map(|value| {
835 value
836 .as_bool()
837 .with_context(|| format!("{name} must be boolean"))
838 })
839 .transpose()
840 .map(|value| value.unwrap_or(default))
841}
842
843fn package_manager(workspace: &Workspace) -> Result<&'static str> {
847 let known = |name: &str| -> Result<&'static str> {
848 Ok(match name {
849 "npm" => "npm",
850 "pnpm" => "pnpm",
851 "yarn" => "yarn",
852 "bun" => "bun",
853 other => bail!("unknown package manager {other:?}"),
854 })
855 };
856 if let Some(name) = workspace
857 .config
858 .extra
859 .get("cli")
860 .and_then(|cli| cli.get("packageManager"))
861 .and_then(Value::as_str)
862 {
863 return known(name);
864 }
865 let root = &workspace.root;
866 for (file, manager) in [
867 ("bun.lockb", "bun"),
868 ("bun.lock", "bun"),
869 ("yarn.lock", "yarn"),
870 ("pnpm-lock.yaml", "pnpm"),
871 ("package-lock.json", "npm"),
872 ] {
873 if root.join(file).is_file() {
874 return Ok(manager);
875 }
876 }
877 if let Some(declared) = &workspace.package_manager {
878 return known(declared.split('@').next().unwrap_or(declared));
879 }
880 if root.join("pnpm-workspace.yaml").is_file() {
881 return Ok("pnpm");
882 }
883 let agent = std::env::var("npm_config_user_agent").unwrap_or_default();
884 for manager in ["pnpm", "yarn", "bun"] {
885 if agent.starts_with(&format!("{manager}/")) {
886 return Ok(manager);
887 }
888 }
889 Ok("npm")
890}
891
892#[cfg(all(test, windows))]
893mod dotenv_tests {
894 #[test]
895 fn colon_targets_keep_configuration_and_generic_dotenv_files() {
896 let files = super::dotenv_files("app", "test:unit:watch", Some("ci"));
897 assert!(files.iter().all(|file| !file.contains(':')));
898 assert!(files.contains(&"app/.env.ci".to_owned()));
899 assert!(files.contains(&"app/.env".to_owned()));
900 assert!(files.contains(&".env.ci".to_owned()));
901 assert!(files.contains(&".env".to_owned()));
902 }
903}