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 let mut paths = Vec::new();
564 for directory in resolved.ancestors() {
565 paths.push(directory.join("node_modules/.bin"));
566 }
567 let node_path = NodePath::new(&workspace.root, base_env, paths.len());
568 let path_key = path_key(&env);
569 if let Some(path) = env.get(&path_key) {
570 paths.extend(std::env::split_paths(path));
571 }
572 let path = std::env::join_paths(paths).context("cannot construct task PATH")?;
573 env.insert(path_key.clone(), path.clone());
574 runtime_env.insert(path_key, path);
575 let parallel = boolean(options.get("parallel"), true, "parallel")?;
576 let mut decorations = Vec::new();
577 let ready_when: Vec<String> = match options.get("readyWhen") {
578 None | Some(Value::Null) => Vec::new(),
579 Some(Value::String(text)) => vec![text.clone()],
580 Some(value) => serde_json::from_value(value.clone())
581 .context("readyWhen must be a string or an array of strings")?,
582 };
583 if !ready_when.is_empty() && options.contains_key("commands") && !parallel {
584 bail!("readyWhen can only be used when parallel is true");
585 }
586 let mut commands = Vec::new();
587 match executor {
588 "nx:noop" => {}
589 "nx:run-script" => {
590 let script = options
591 .get("script")
592 .and_then(Value::as_str)
593 .context("run-script requires options.script")?;
594 let package = workspace
595 .packages
596 .get(&task.project)
597 .context("run-script requires a package.json")?;
598 if !package.scripts.contains_key(script) {
599 bail!("package.json has no script {script:?}");
600 }
601 let manager = package_manager(workspace)?;
602 let args = interpolation.arguments()?;
603 let script = interpolate::quote(script)?;
604 let mut command = match manager {
607 "yarn" => format!("yarn {script}"),
608 manager => format!("{manager} run {script}"),
609 };
610 if !args.is_empty() {
611 if matches!(manager, "npm" | "bun") {
612 command.push_str(" --");
613 }
614 command.push(' ');
615 command.push_str(&args);
616 }
617 commands.push(command);
618 }
619 "nx:run-commands" => {
620 if options.contains_key("command") && options.contains_key("commands") {
621 bail!("specify command or commands, not both");
622 }
623 let forward = boolean(options.get("forwardAllArgs"), true, "forwardAllArgs")?;
624 if let Some(command) = options.get("command") {
625 let command = match command {
627 Value::String(command) => command.clone(),
628 Value::Array(words) => words
629 .iter()
630 .map(|word| word.as_str().context("command words must be strings"))
631 .collect::<Result<Vec<_>>>()?
632 .join(" "),
633 _ => bail!("command must be a string or an array of strings"),
634 };
635 commands.push(interpolation.command(&command, forward)?);
636 } else if let Some(values) = options.get("commands") {
637 for value in values.as_array().context("commands must be an array")? {
638 let (command, forward) = if let Some(command) = value.as_str() {
639 (command, forward)
640 } else {
641 let value = value
642 .as_object()
643 .context("commands entries must be strings or objects")?;
644 for key in value.keys() {
645 if !matches!(
646 key.as_str(),
647 "command"
648 | "forwardAllArgs"
649 | "prefix"
650 | "prefixColor"
651 | "color"
652 | "bgColor"
653 | "description"
654 ) {
655 bail!("unsupported command entry field {key:?}");
656 }
657 }
658 let field =
659 |name: &str, background: Option<bool>| -> Result<Option<String>> {
660 let Some(value) = value.get(name) else {
661 return Ok(None);
662 };
663 let text = value.as_str().with_context(|| {
664 format!("command entry {name} must be a string")
665 })?;
666 if let Some(background) = background
667 && !Decoration::valid(text, background)
668 {
669 bail!("command entry {name} {text:?} is not a colour");
670 }
671 Ok(Some(text.to_owned()))
672 };
673 let decoration = Decoration {
674 prefix: field("prefix", None)?,
675 prefix_color: field("prefixColor", Some(false))?,
676 color: field("color", Some(false))?,
677 bg_color: field("bgColor", Some(true))?,
678 };
679 if !decoration.is_empty() && !parallel {
680 bail!(
681 "prefix, prefixColor, color and bgColor can only be set when parallel is true"
682 );
683 }
684 decorations.resize(commands.len(), Decoration::default());
685 decorations.push(decoration);
686 (
687 value
688 .get("command")
689 .and_then(Value::as_str)
690 .context("command entry requires a command string")?,
691 boolean(value.get("forwardAllArgs"), forward, "forwardAllArgs")?,
692 )
693 };
694 commands.push(interpolation.command(command, forward)?);
695 }
696 } else {
697 bail!("run-commands requires command or commands");
698 }
699 if commands.iter().any(|command| command.trim().is_empty()) {
701 bail!("run-commands requires nonempty commands");
702 }
703 }
704 _ => bail!("unsupported executor {executor:?}"),
705 }
706 Ok(PreparedTask {
707 id: task.id.clone(),
708 commands,
709 parallel,
710 cwd,
711 env,
712 runtime_env,
713 node_path: Some(node_path),
714 execution,
715 ready_when,
716 ready: Default::default(),
717 processes: Default::default(),
718 decorations,
719 interactive: false,
720 sandbox: None,
721 recording: None,
722 display: Display::default(),
723 })
724}
725
726const OPTION_FLAGS: &[&str] = &[
728 "command",
729 "commands",
730 "color",
731 "parallel",
732 "readyWhen",
733 "cwd",
734 "args",
735 "envFile",
736 "__unparsed__",
737 "env",
738 "usePty",
739 "streamOutput",
740 "verbose",
741 "forwardAllArgs",
742 "tty",
743];
744
745fn run_commands_overrides(
750 options: &serde_json::Map<String, Value>,
751 args: &[String],
752) -> (serde_json::Map<String, Value>, Vec<String>) {
753 const BOOLEAN: &[&str] = &[
754 "color",
755 "parallel",
756 "usePty",
757 "streamOutput",
758 "verbose",
759 "forwardAllArgs",
760 "tty",
761 ];
762 let mut options = options.clone();
763 let mut rest = Vec::new();
764 let mut ready = Vec::new();
765 let mut args = args.iter().peekable();
766 while let Some(arg) = args.next() {
767 let Some(flag) = arg.strip_prefix("--") else {
768 rest.push(arg.clone());
769 continue;
770 };
771 let (key, value) = match flag.split_once('=') {
772 Some((key, value)) => (key, Some(value.to_owned())),
773 None => (flag, None),
774 };
775 let (key, negated) = match key.strip_prefix("no-") {
776 Some(key) if OPTION_FLAGS.contains(&key) => (key, true),
777 _ => (key, false),
778 };
779 let (base, field) = match key.split_once('.') {
780 Some((base, field)) => (base, Some(field)),
781 None => (key, None),
782 };
783 if !OPTION_FLAGS.contains(&base) {
784 rest.push(arg.clone());
785 continue;
786 }
787 let boolean = BOOLEAN.contains(&base);
788 let value = match value {
789 Some(value) => value,
790 None if negated => "false".into(),
791 None if !boolean && args.peek().is_some_and(|next| !next.starts_with('-')) => {
792 args.next().unwrap().clone()
793 }
794 None => "true".into(),
795 };
796 let value = if boolean {
797 Value::Bool(value != "false")
798 } else {
799 Value::String(value)
800 };
801 match (base, field) {
802 ("env", Some(name)) => {
803 let env = options
804 .entry("env")
805 .or_insert_with(|| Value::Object(Default::default()));
806 if let Value::Object(env) = env {
807 env.insert(name.to_owned(), value);
808 }
809 }
810 ("readyWhen", None) => ready.push(value),
811 ("__unparsed__" | "commands", _) | (_, Some(_)) => {}
813 (base, None) => {
814 options.insert(base.to_owned(), value);
815 }
816 }
817 }
818 if !ready.is_empty() {
819 let ready = if ready.len() == 1 {
820 ready.pop().unwrap()
821 } else {
822 Value::Array(ready)
823 };
824 options.insert("readyWhen".into(), ready);
825 }
826 (options, rest)
827}
828
829fn boolean(value: Option<&Value>, default: bool, name: &str) -> Result<bool> {
830 value
831 .map(|value| {
832 value
833 .as_bool()
834 .with_context(|| format!("{name} must be boolean"))
835 })
836 .transpose()
837 .map(|value| value.unwrap_or(default))
838}
839
840fn package_manager(workspace: &Workspace) -> Result<&'static str> {
844 let known = |name: &str| -> Result<&'static str> {
845 Ok(match name {
846 "npm" => "npm",
847 "pnpm" => "pnpm",
848 "yarn" => "yarn",
849 "bun" => "bun",
850 other => bail!("unknown package manager {other:?}"),
851 })
852 };
853 if let Some(name) = workspace
854 .config
855 .extra
856 .get("cli")
857 .and_then(|cli| cli.get("packageManager"))
858 .and_then(Value::as_str)
859 {
860 return known(name);
861 }
862 let root = &workspace.root;
863 for (file, manager) in [
864 ("bun.lockb", "bun"),
865 ("bun.lock", "bun"),
866 ("yarn.lock", "yarn"),
867 ("pnpm-lock.yaml", "pnpm"),
868 ("package-lock.json", "npm"),
869 ] {
870 if root.join(file).is_file() {
871 return Ok(manager);
872 }
873 }
874 if let Some(declared) = &workspace.package_manager {
875 return known(declared.split('@').next().unwrap_or(declared));
876 }
877 if root.join("pnpm-workspace.yaml").is_file() {
878 return Ok("pnpm");
879 }
880 let agent = std::env::var("npm_config_user_agent").unwrap_or_default();
881 for manager in ["pnpm", "yarn", "bun"] {
882 if agent.starts_with(&format!("{manager}/")) {
883 return Ok(manager);
884 }
885 }
886 Ok("npm")
887}
888
889#[cfg(all(test, windows))]
890mod dotenv_tests {
891 #[test]
892 fn colon_targets_keep_configuration_and_generic_dotenv_files() {
893 let files = super::dotenv_files("app", "test:unit:watch", Some("ci"));
894 assert!(files.iter().all(|file| !file.contains(':')));
895 assert!(files.contains(&"app/.env.ci".to_owned()));
896 assert!(files.contains(&"app/.env".to_owned()));
897 assert!(files.contains(&".env.ci".to_owned()));
898 assert!(files.contains(&".env".to_owned()));
899 }
900}