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 decorations: Vec<Decoration>,
256 pub interactive: bool,
260 pub sandbox: Option<Confinement>,
262 pub recording: Option<Recording>,
263 pub display: Display,
265}
266
267fn dotenv_files(project_root: &str, target: &str, configuration: Option<&str>) -> Vec<String> {
273 let mut identifiers = Vec::new();
274 if let Some(configuration) = configuration {
275 identifiers.push(format!("{target}.{configuration}"));
276 identifiers.push(configuration.to_owned());
277 }
278 identifiers.push(target.to_owned());
279 identifiers.push(String::new());
280 let variants = |identifier: &str, root: &str| -> Vec<String> {
281 let prefix = if root.is_empty() || root == "." {
282 String::new()
283 } else {
284 format!("{root}/")
285 };
286 if identifier.is_empty() {
287 [".env.local", ".local.env", ".env"]
288 .map(|name| format!("{prefix}{name}"))
289 .to_vec()
290 } else {
291 [
292 format!(".env.{identifier}.local"),
293 format!(".env.{identifier}"),
294 format!(".{identifier}.local.env"),
295 format!(".{identifier}.env"),
296 ]
297 .map(|name| format!("{prefix}{name}"))
298 .to_vec()
299 }
300 };
301 let mut files = Vec::new();
302 for root in [project_root, ""] {
303 for identifier in &identifiers {
304 #[cfg(windows)]
306 if identifier.contains(':') {
307 continue;
308 }
309 for file in variants(identifier, root) {
310 if !files.contains(&file) {
311 files.push(file);
312 }
313 }
314 }
315 }
316 files
317}
318
319fn task_environment(
323 workspace: &Workspace,
324 task: &Task,
325 base_env: &BTreeMap<OsString, OsString>,
326) -> Result<(BTreeMap<OsString, OsString>, BTreeMap<OsString, OsString>)> {
327 let mut env = base_env.clone();
328 let root = &workspace.projects[&task.project].root;
329 let files = if loads_dotenv(base_env) {
330 dotenv_files(root, &task.target, task.configuration.as_deref())
331 } else {
332 Vec::new()
333 };
334 for file in files {
335 for (name, value) in read_dotenv(&workspace.root.join(file))? {
336 let name = environment_key(&env, name);
337 env.entry(name).or_insert(value);
338 }
339 }
340 let runtime_env = env.clone();
341 let mut set = |name: &str, value: OsString| {
342 env.insert(name.into(), value);
343 };
344 set("NX_WORKSPACE_ROOT", workspace.root.clone().into_os_string());
345 set("NX_TASK_TARGET_PROJECT", task.project.clone().into());
346 set("NX_TASK_TARGET_TARGET", task.target.clone().into());
347 set("LERNA_PACKAGE_NAME", task.project.clone().into());
348 set("NX_TUI", "false".into());
349 match &task.configuration {
350 Some(configuration) => set("NX_TASK_TARGET_CONFIGURATION", configuration.clone().into()),
351 None => {
352 env.remove(std::ffi::OsStr::new("NX_TASK_TARGET_CONFIGURATION"));
353 }
354 }
355 Ok((env, runtime_env))
356}
357
358fn loads_dotenv(env: &BTreeMap<OsString, OsString>) -> bool {
361 env.get(std::ffi::OsStr::new("NX_LOAD_DOT_ENV_FILES"))
362 .is_none_or(|value| value != "false")
363}
364
365fn read_dotenv(path: &Path) -> Result<Vec<(OsString, OsString)>> {
367 let file = match std::fs::File::open(path) {
368 Ok(file) => file,
369 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
370 Err(error) => return Err(error).with_context(|| format!("cannot read {}", path.display())),
371 };
372 dotenvy::from_read_iter(file)
373 .enumerate()
374 .map(|(index, entry)| {
375 let (name, value) = entry.map_err(|_| {
377 anyhow::anyhow!("invalid dotenv entry {} in {}", index + 1, path.display())
378 })?;
379 Ok((name.into(), value.into()))
380 })
381 .collect()
382}
383
384pub fn environment(workspace: &Path) -> Result<BTreeMap<OsString, OsString>> {
387 let mut values = BTreeMap::new();
388 let process: BTreeMap<OsString, OsString> = std::env::vars_os().collect();
389 let names: &[&str] = if loads_dotenv(&process) {
390 &[".env", ".env.local"]
391 } else {
392 &[]
393 };
394 for name in names {
395 let path = workspace.join(name);
396 let file = match std::fs::File::open(&path) {
397 Ok(file) => file,
398 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
399 Err(error) => {
400 return Err(error).with_context(|| format!("cannot read {}", path.display()));
401 }
402 };
403 for (index, entry) in dotenvy::from_read_iter(file).enumerate() {
404 let (name, value) = entry.map_err(|_| {
406 anyhow::anyhow!("invalid dotenv entry {} in {}", index + 1, path.display())
407 })?;
408 values.insert(name.into(), value.into());
409 }
410 }
411 values.extend(std::env::vars_os());
412 Ok(values)
413}
414
415pub fn prepare(
416 workspace: &Workspace,
417 task: &Task,
418 base_env: &BTreeMap<OsString, OsString>,
419) -> Result<PreparedTask> {
420 let definition = &task.definition;
421 let executor = definition
422 .executor
423 .as_deref()
424 .context("target has no executor or command")?;
425 let (options, args) = if executor == "nx:run-commands" {
426 run_commands_overrides(&definition.options, &task.args)
427 } else {
428 (definition.options.clone(), task.args.clone())
429 };
430 let options = &options;
431 const RUN_COMMANDS: &[&str] = &[
434 "command",
435 "commands",
436 "color",
437 "no-color",
438 "parallel",
439 "no-parallel",
440 "readyWhen",
441 "cwd",
442 "args",
443 "envFile",
444 "__unparsed__",
445 "env",
446 "usePty",
447 "streamOutput",
448 "verbose",
449 "forwardAllArgs",
450 "tty",
451 ];
452 let mut forwarded = BTreeMap::new();
453 if executor == "nx:run-commands" {
454 for (key, value) in options {
455 if RUN_COMMANDS.contains(&key.as_str()) {
456 continue;
457 }
458 let value = match value {
459 Value::String(text) => text.clone(),
460 Value::Number(number) => number.to_string(),
461 Value::Bool(flag) => flag.to_string(),
462 _ => continue,
463 };
464 forwarded.insert(key.clone(), value);
465 }
466 }
467 let extra = match options.get("args") {
468 None | Some(Value::Null) => None,
469 Some(Value::String(text)) => Some(text.clone()),
470 Some(Value::Array(words)) => Some(
471 words
472 .iter()
473 .map(|word| word.as_str().context("args entries must be strings"))
474 .collect::<Result<Vec<_>>>()?
475 .join(" "),
476 ),
477 Some(_) => bail!("args must be a string or an array of strings"),
478 };
479 let interpolation =
480 interpolate::Interpolation::new(workspace, task, args, &forwarded, extra.as_deref())?;
481 let (mut env, mut runtime_env) = task_environment(workspace, task, base_env)?;
482 if let Some(file) = options.get("envFile") {
484 let file = interpolation.text(file.as_str().context("envFile must be a string")?)?;
485 let path = workspace.root.join(&file);
486 if loads_dotenv(base_env) {
487 if !path.is_file() {
488 bail!("envFile {file} does not exist");
489 }
490 for (name, value) in read_dotenv(&path)? {
491 let name = environment_key(&env, name);
492 runtime_env.entry(name.clone()).or_insert(value.clone());
493 env.entry(name).or_insert(value);
494 }
495 }
496 }
497 for values in [definition.extra.get("env"), options.get("env")]
498 .into_iter()
499 .flatten()
500 {
501 for (name, value) in values.as_object().context("env must be an object")? {
502 if name.is_empty() || name.contains(['=', '\0']) {
503 bail!("invalid environment variable name");
504 }
505 let value = value
506 .as_str()
507 .context("environment values must be strings")?;
508 let value: OsString = interpolation.text(value)?.into();
509 let name = environment_key(&env, name.into());
510 runtime_env.insert(name.clone(), value.clone());
511 env.insert(name, value);
512 }
513 }
514 let mut execution = BTreeMap::new();
515 execution.insert(
516 "FORCE_COLOR".into(),
517 env.get(std::ffi::OsStr::new("FORCE_COLOR"))
518 .cloned()
519 .unwrap_or_else(|| "true".into()),
520 );
521 if executor == "nx:run-commands" && boolean(options.get("color"), false, "color")? {
522 execution.insert("FORCE_COLOR".into(), "true".into());
523 }
524 for key in options.keys() {
525 let allowed = match executor {
526 "nx:run-commands" => true,
530 "nx:run-script" => matches!(key.as_str(), "script" | "env" | "cwd"),
531 "nx:noop" => true,
532 _ => bail!("unsupported executor {executor:?}"),
533 };
534 if !allowed {
535 bail!("unsupported {executor} option {key:?}");
536 }
537 }
538 let default_cwd = if executor == "nx:run-script" {
539 &workspace.projects[&task.project].root
540 } else {
541 "."
542 };
543 let cwd = options
544 .get("cwd")
545 .map(|value| value.as_str().context("cwd must be a string"))
546 .transpose()?
547 .unwrap_or(default_cwd);
548 let cwd = workspace.root.join(interpolation.text(cwd)?);
549 let mut resolved = PathBuf::new();
550 for part in cwd.components() {
551 match part {
552 std::path::Component::ParentDir => {
553 resolved.pop();
554 }
555 std::path::Component::CurDir => {}
556 _ => resolved.push(part.as_os_str()),
557 }
558 }
559 if !cwd.is_dir() {
560 bail!("working directory does not exist: {}", cwd.display());
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 decorations,
718 interactive: false,
719 sandbox: None,
720 recording: None,
721 display: Display::default(),
722 })
723}
724
725const OPTION_FLAGS: &[&str] = &[
727 "command",
728 "commands",
729 "color",
730 "parallel",
731 "readyWhen",
732 "cwd",
733 "args",
734 "envFile",
735 "__unparsed__",
736 "env",
737 "usePty",
738 "streamOutput",
739 "verbose",
740 "forwardAllArgs",
741 "tty",
742];
743
744fn run_commands_overrides(
749 options: &serde_json::Map<String, Value>,
750 args: &[String],
751) -> (serde_json::Map<String, Value>, Vec<String>) {
752 const BOOLEAN: &[&str] = &[
753 "color",
754 "parallel",
755 "usePty",
756 "streamOutput",
757 "verbose",
758 "forwardAllArgs",
759 "tty",
760 ];
761 let mut options = options.clone();
762 let mut rest = Vec::new();
763 let mut ready = Vec::new();
764 let mut args = args.iter().peekable();
765 while let Some(arg) = args.next() {
766 let Some(flag) = arg.strip_prefix("--") else {
767 rest.push(arg.clone());
768 continue;
769 };
770 let (key, value) = match flag.split_once('=') {
771 Some((key, value)) => (key, Some(value.to_owned())),
772 None => (flag, None),
773 };
774 let (key, negated) = match key.strip_prefix("no-") {
775 Some(key) if OPTION_FLAGS.contains(&key) => (key, true),
776 _ => (key, false),
777 };
778 let (base, field) = match key.split_once('.') {
779 Some((base, field)) => (base, Some(field)),
780 None => (key, None),
781 };
782 if !OPTION_FLAGS.contains(&base) {
783 rest.push(arg.clone());
784 continue;
785 }
786 let boolean = BOOLEAN.contains(&base);
787 let value = match value {
788 Some(value) => value,
789 None if negated => "false".into(),
790 None if !boolean && args.peek().is_some_and(|next| !next.starts_with('-')) => {
791 args.next().unwrap().clone()
792 }
793 None => "true".into(),
794 };
795 let value = if boolean {
796 Value::Bool(value != "false")
797 } else {
798 Value::String(value)
799 };
800 match (base, field) {
801 ("env", Some(name)) => {
802 let env = options
803 .entry("env")
804 .or_insert_with(|| Value::Object(Default::default()));
805 if let Value::Object(env) = env {
806 env.insert(name.to_owned(), value);
807 }
808 }
809 ("readyWhen", None) => ready.push(value),
810 ("__unparsed__" | "commands", _) | (_, Some(_)) => {}
812 (base, None) => {
813 options.insert(base.to_owned(), value);
814 }
815 }
816 }
817 if !ready.is_empty() {
818 let ready = if ready.len() == 1 {
819 ready.pop().unwrap()
820 } else {
821 Value::Array(ready)
822 };
823 options.insert("readyWhen".into(), ready);
824 }
825 (options, rest)
826}
827
828fn boolean(value: Option<&Value>, default: bool, name: &str) -> Result<bool> {
829 value
830 .map(|value| {
831 value
832 .as_bool()
833 .with_context(|| format!("{name} must be boolean"))
834 })
835 .transpose()
836 .map(|value| value.unwrap_or(default))
837}
838
839fn package_manager(workspace: &Workspace) -> Result<&'static str> {
843 let known = |name: &str| -> Result<&'static str> {
844 Ok(match name {
845 "npm" => "npm",
846 "pnpm" => "pnpm",
847 "yarn" => "yarn",
848 "bun" => "bun",
849 other => bail!("unknown package manager {other:?}"),
850 })
851 };
852 if let Some(name) = workspace
853 .config
854 .extra
855 .get("cli")
856 .and_then(|cli| cli.get("packageManager"))
857 .and_then(Value::as_str)
858 {
859 return known(name);
860 }
861 let root = &workspace.root;
862 for (file, manager) in [
863 ("bun.lockb", "bun"),
864 ("bun.lock", "bun"),
865 ("yarn.lock", "yarn"),
866 ("pnpm-lock.yaml", "pnpm"),
867 ("package-lock.json", "npm"),
868 ] {
869 if root.join(file).is_file() {
870 return Ok(manager);
871 }
872 }
873 if let Some(declared) = &workspace.package_manager {
874 return known(declared.split('@').next().unwrap_or(declared));
875 }
876 if root.join("pnpm-workspace.yaml").is_file() {
877 return Ok("pnpm");
878 }
879 let agent = std::env::var("npm_config_user_agent").unwrap_or_default();
880 for manager in ["pnpm", "yarn", "bun"] {
881 if agent.starts_with(&format!("{manager}/")) {
882 return Ok(manager);
883 }
884 }
885 Ok("npm")
886}
887
888#[cfg(all(test, windows))]
889mod dotenv_tests {
890 #[test]
891 fn colon_targets_keep_configuration_and_generic_dotenv_files() {
892 let files = super::dotenv_files("app", "test:unit:watch", Some("ci"));
893 assert!(files.iter().all(|file| !file.contains(':')));
894 assert!(files.contains(&"app/.env.ci".to_owned()));
895 assert!(files.contains(&"app/.env".to_owned()));
896 assert!(files.contains(&".env.ci".to_owned()));
897 assert!(files.contains(&".env".to_owned()));
898 }
899}