1mod fetch;
7pub mod hash;
8pub mod lock;
9pub mod manifest;
10pub mod place;
11mod record;
12pub mod startup;
13pub mod wasm;
14
15use std::collections::{HashMap, HashSet};
16use std::path::{Path, PathBuf};
17use std::time::Duration;
18
19use tokio_util::sync::CancellationToken;
20use yapi_ai::auth::lock::{FileLock, LockError, write_atomic};
21
22use crate::packages::npm::{self, Npm, NpmError, Pinned};
23use crate::settings::Scope;
24use crate::tools::path::{home_dir, resolve_lexically};
25use hash::tree_hash;
26use lock::{Lock, Locked, fresh};
27use manifest::{Entry, Manifest, Section};
28use record::{Held, Record, content_hash};
29
30#[derive(Debug, thiserror::Error)]
32pub enum ChestError {
33 #[error("{0}")]
35 Invalid(String),
36 #[error("{0}")]
38 Failed(String),
39 #[error(transparent)]
41 Npm(#[from] NpmError),
42 #[error("{path}: {source}")]
44 Io {
45 path: PathBuf,
47 source: std::io::Error,
49 },
50}
51
52fn invalid(message: impl Into<String>) -> ChestError {
53 ChestError::Invalid(message.into())
54}
55
56fn slashed(path: &Path) -> String {
59 let text = path.to_string_lossy();
60 text.replace(std::path::MAIN_SEPARATOR, "/")
61}
62
63fn io(path: &Path) -> impl FnOnce(std::io::Error) -> ChestError + '_ {
64 move |source| ChestError::Io {
65 path: path.to_path_buf(),
66 source,
67 }
68}
69
70#[derive(Clone, Debug, PartialEq, Eq)]
72pub enum Mode {
73 Resolve,
80 Update(Vec<String>),
83 Frozen,
89 Startup {
93 offline: bool,
95 },
96}
97
98#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100pub enum Outcome {
101 Installed,
103 Replayed,
105 Unchanged,
107 Edited,
109 Skipped,
111 Removed,
113}
114
115#[derive(Clone, Debug, Default, PartialEq, Eq)]
117pub struct Report {
118 pub entries: Vec<(String, Outcome)>,
121 pub warnings: Vec<String>,
123 pub notices: Vec<String>,
125}
126
127#[derive(Clone, Debug, PartialEq, Eq)]
129pub struct Outdated {
130 pub id: String,
132 pub locked: String,
134 pub wanted: String,
137 pub latest: Option<String>,
139}
140
141pub struct Fetched {
143 pub root: PathBuf,
146 _work: Option<fetch::Work>,
147}
148
149impl Fetched {
150 pub fn items(&self, section: Section) -> Result<Vec<(String, PathBuf)>, ChestError> {
155 if !fetch::is_package(section, &self.root) {
156 return Ok(Vec::new());
157 }
158 Ok(fetch::items(&self.root, section, None, "")?.0)
159 }
160}
161
162struct Target<'a> {
164 id: String,
165 section: Section,
166 name: &'a str,
167 entry: &'a Entry,
168 patch: Option<&'a Entry>,
169 locked: Option<&'a Locked>,
170}
171
172struct Staged {
174 id: String,
175 section: Section,
176 record: Option<Locked>,
177 content: Content,
178 outcome: Outcome,
179 warnings: Vec<String>,
181}
182
183enum Content {
184 Items(Vec<(String, PathBuf, String)>),
187 Store {
190 staging: PathBuf,
191 dir: PathBuf,
192 hash: String,
193 },
194 Nothing,
196}
197
198pub async fn hold_lock(
204 file: &Path,
205 waiting: &mut (dyn FnMut() + Send),
206) -> Result<FileLock, ChestError> {
207 let cancel = CancellationToken::new();
208 let failed = |err: LockError| ChestError::Failed(format!("{}: {err}", file.display()));
209 let first = tokio::time::timeout(Duration::from_secs(1), FileLock::acquire(file, &cancel));
210 match first.await {
211 Ok(Ok(lock)) => return Ok(lock),
212 Ok(Err(LockError::Held)) | Err(_) => waiting(),
213 Ok(Err(err)) => return Err(failed(err)),
214 }
215 loop {
216 match FileLock::acquire(file, &cancel).await {
217 Err(LockError::Held) => {}
218 locked => return locked.map_err(failed),
219 }
220 }
221}
222
223pub fn refuse_link(path: &Path) -> Result<(), ChestError> {
227 if path.symlink_metadata().is_ok_and(|meta| meta.is_symlink()) {
228 return Err(invalid(format!(
229 "{} is a symbolic link, which chest does not follow: replace it with the file itself",
230 path.display()
231 )));
232 }
233 Ok(())
234}
235
236fn discard(staged: &[Staged]) {
238 for staged in staged {
239 if let Content::Store { staging, .. } = &staged.content {
240 let _ = std::fs::remove_dir_all(staging);
241 }
242 }
243}
244
245fn real_path(path: &Path) -> PathBuf {
248 let mut rest = Vec::new();
249 let mut current = path;
250 loop {
251 if let Ok(real) = current.canonicalize() {
252 return rest.iter().rev().fold(real, |real, part| real.join(part));
253 }
254 match (current.parent(), current.file_name()) {
255 (Some(parent), Some(name)) => {
256 rest.push(name.to_owned());
257 current = parent;
258 }
259 _ => return path.to_path_buf(),
260 }
261 }
262}
263
264#[derive(Clone, Debug)]
266pub struct Project {
267 pub manifest: Manifest,
269 pub lock: Option<Lock>,
271 pub base: PathBuf,
273 pub scope: Scope,
275 pub cache: PathBuf,
278 pub state: PathBuf,
281 path: PathBuf,
282 pub home: PathBuf,
284}
285
286enum AtStartup {
288 Stale,
290 Present,
292 Missing,
294 Replay,
297}
298
299pub fn manifest_label(path: &Path, scope: Scope, home: &Path) -> String {
303 match (scope, path.strip_prefix(home)) {
304 (Scope::Global, Ok(path)) => format!("~/{}", path.display()),
305 (Scope::Global, Err(_)) => path.display().to_string(),
306 (Scope::Project, _) => path
307 .file_name()
308 .map_or_else(String::new, |name| name.to_string_lossy().into_owned()),
309 }
310}
311
312pub fn command(path: &Path, scope: Scope) -> String {
316 let name = path.file_name().unwrap_or_default().to_string_lossy();
317 match (scope, &*name) {
318 (Scope::Global, _) => "chest -g".to_owned(),
319 (Scope::Project, "chest.toml") => "chest".to_owned(),
320 (Scope::Project, name) => format!("chest --manifest {name}"),
321 }
322}
323
324pub fn install_command(path: &Path, scope: Scope) -> String {
327 format!("{} install", command(path, scope))
328}
329
330impl Project {
331 pub fn open(path: &Path, scope: Scope) -> Result<Project, ChestError> {
339 let path = std::path::absolute(path).map_err(io(path))?;
340 refuse_link(&path)?;
341 let text = std::fs::read_to_string(&path).map_err(io(&path))?;
342 let manifest = Manifest::parse(&text, scope)
343 .map_err(|err| invalid(format!("{}: {err}", path.display())))?;
344 let folder = path.parent().unwrap_or(&path);
345 let base = folder.canonicalize().map_err(io(folder))?;
346 let home = home_dir();
347 let mut project = Project {
348 manifest,
349 lock: None,
350 path: base.join(path.file_name().unwrap_or_default()),
351 base,
352 scope,
353 cache: home.join(".chest/cache"),
354 state: home.join(".chest/state"),
355 home: home.canonicalize().unwrap_or(home),
356 };
357 project.lock = project.read_lock()?;
358 Ok(project)
359 }
360
361 pub fn global() -> Result<Project, ChestError> {
363 Project::open(&home_dir().join(".chest/chest.toml"), Scope::Global)
364 }
365
366 pub fn lock_path(&self) -> PathBuf {
368 self.path.with_extension("lock")
369 }
370
371 pub fn root(&self) -> PathBuf {
374 match self.scope {
375 Scope::Project => self.base.clone(),
376 Scope::Global => self.home.clone(),
377 }
378 }
379
380 pub fn store(&self) -> PathBuf {
383 match (&self.manifest.folders.extensions, self.scope) {
384 (Some(store), _) => self.resolve(store),
385 (None, Scope::Project) => self.base.join(".yapi/chest"),
386 (None, Scope::Global) => self.home.join(".chest/store"),
387 }
388 }
389
390 fn resolve(&self, path: &str) -> PathBuf {
393 let path = match path.strip_prefix('~') {
394 Some("") => self.home.clone(),
395 Some(rest) if rest.starts_with('/') => self.home.join(&rest[1..]),
396 _ => PathBuf::from(path),
397 };
398 resolve_lexically(&self.base, &path)
399 }
400
401 pub fn too_wide(&self, folder: &Path, also: &[&Path]) -> bool {
408 let Ok(folder) = folder.canonicalize() else {
409 return false;
410 };
411 let held = [
412 self.home.clone(),
413 crate::config::agent_dir(),
414 PathBuf::from("/"),
415 self.base.clone(),
416 self.store(),
417 ];
418 held.into_iter()
419 .chain(also.iter().map(|path| path.to_path_buf()))
420 .any(|held| real_path(&held).starts_with(&folder))
421 }
422
423 fn refuse_wide(&self) -> Result<(), ChestError> {
426 for (name, entry) in &self.manifest.extensions {
427 let patch = self.manifest.patch.get(name);
428 let patch = patch.and_then(|patch| patch.path.as_deref());
429 let source = entry.path.as_deref();
430 let source = source.filter(|_| entry.git.is_none() && entry.npm.is_none());
431 let (id, path) = match (patch, source) {
432 (Some(path), _) => (format!("patch.{name}"), path),
433 (None, Some(path)) => (format!("extensions.{name}"), path),
434 (None, None) => continue,
435 };
436 let folder = self.resolve(path);
437 if self.too_wide(&folder, &[]) {
438 return Err(invalid(format!(
439 "{id}: {} holds more than the extension, such as the project, the home or the agent folder, so chest does not install it",
440 folder.display()
441 )));
442 }
443 }
444 Ok(())
445 }
446
447 fn folders(&self, section: Section) -> Result<Vec<PathBuf>, ChestError> {
451 let folders = match section {
452 Section::Skills => &self.manifest.folders.skills,
453 _ => &self.manifest.folders.agents,
454 };
455 if folders.is_empty() {
456 let key = section.key();
457 return Err(invalid(format!(
458 "Set `{key}` in [folders] to say where {key} go"
459 )));
460 }
461 let mut real: Vec<PathBuf> = Vec::new();
462 for folder in folders {
463 let path = real_path(&self.resolve(folder));
464 if !real.contains(&path) {
465 real.push(path);
466 }
467 }
468 Ok(real)
469 }
470
471 fn read_lock(&self) -> Result<Option<Lock>, ChestError> {
472 let path = self.lock_path();
473 refuse_link(&path)?;
474 match std::fs::read_to_string(&path) {
475 Ok(text) => Lock::parse(&text).map(Some).map_err(|err| {
476 let label = manifest_label(&path, self.scope, &self.home);
477 invalid(format!("{label}: {err}"))
478 }),
479 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
480 Err(err) => Err(io(&path)(err)),
481 }
482 }
483
484 fn file_name(&self) -> String {
485 self.path
486 .file_name()
487 .map_or_else(String::new, |name| name.to_string_lossy().into_owned())
488 }
489
490 fn label(&self) -> String {
492 manifest_label(&self.path, self.scope, &self.home)
493 }
494
495 fn command(&self) -> String {
498 command(&self.path, self.scope)
499 }
500
501 fn install_command(&self) -> String {
503 install_command(&self.path, self.scope)
504 }
505
506 fn entries(&self) -> impl Iterator<Item = (Section, &String, &Entry)> {
508 self.manifest.entries()
509 }
510
511 fn homes(&self, section: Section) -> Vec<PathBuf> {
514 match section {
515 Section::Extensions => vec![self.store()],
516 _ => self.folders(section).unwrap_or_default(),
517 }
518 }
519
520 pub(crate) fn locked_items(
525 &self,
526 section: Section,
527 locked: &Locked,
528 ) -> Vec<(String, Option<String>)> {
529 if section == Section::Extensions {
530 let stored = locked.git.is_some() || locked.npm.is_some();
531 return stored
532 .then(|| (locked.name.clone(), locked.hash.clone()))
533 .into_iter()
534 .collect();
535 }
536 let path_source = match (&locked.git, &locked.npm) {
537 (None, None) => locked.path.as_deref().map(|path| self.resolve(path)),
538 _ => None,
539 };
540 if let Some(package) = path_source.filter(|source| fetch::is_package(section, source)) {
541 let items = fetch::items(&package, section, None, &locked.name);
542 let items = items.map(|(items, _)| items).unwrap_or_default();
543 return items.into_iter().map(|(name, _)| (name, None)).collect();
544 }
545 if locked.items.is_empty() {
546 return vec![(
547 fetch::placed_name(section, &locked.name),
548 locked.hash.clone(),
549 )];
550 }
551 let items = locked.items.iter();
552 items
553 .map(|item| (item.name.clone(), Some(item.hash.clone())))
554 .collect()
555 }
556
557 pub fn placed(&self, section: Section, locked: &Locked) -> Vec<PathBuf> {
560 let homes = self.homes(section);
561 let items = self.locked_items(section, locked);
562 items
563 .iter()
564 .flat_map(|(name, _)| homes.iter().map(move |home| home.join(name)))
565 .collect()
566 }
567
568 fn pinned(&self, lock: &Lock) -> HashMap<PathBuf, String> {
570 let mut pinned = HashMap::new();
571 for section in [Section::Skills, Section::Agents, Section::Extensions] {
572 let homes = self.homes(section);
573 for locked in lock.section(section) {
574 for (name, hash) in self.locked_items(section, locked) {
575 let Some(hash) = hash else { continue };
576 for home in &homes {
577 pinned.insert(home.join(&name), hash.clone());
578 }
579 }
580 }
581 }
582 pinned
583 }
584
585 fn recorded(&self, section: Section, locked: &Locked, record: &Record) -> bool {
589 let homes = self.homes(section);
590 let items = self.locked_items(section, locked);
591 items.iter().all(|(name, hash)| {
592 let holds = |hash: &String| {
593 homes
594 .iter()
595 .all(|home| record.holds(&home.join(name), hash))
596 };
597 hash.as_ref().is_some_and(holds)
598 })
599 }
600
601 fn record(&self) -> Record {
604 let path = Record::path(&self.state, &self.root(), self.scope);
605 Record::load(&path, &self.root(), &self.file_name())
606 }
607
608 fn holds_locked(
613 &self,
614 section: Section,
615 entry: &Entry,
616 locked: &Locked,
617 record: &Record,
618 ) -> bool {
619 entry.git.is_none() && entry.npm.is_none() || self.recorded(section, locked, record)
620 }
621
622 fn holds_staged(&self, section: Section, content: &Content, record: &Record) -> bool {
626 let items = match content {
627 Content::Items(items) => items,
628 Content::Nothing => return true,
629 Content::Store { .. } => return false,
630 };
631 let homes = self.homes(section);
632 items.iter().all(|(item, _, hash)| {
633 homes.iter().all(|home| {
634 let path = home.join(item);
635 record.holds(&path, hash) && record.held(&path) == Held::Owned
636 })
637 })
638 }
639
640 fn changed(
645 &self,
646 section: Section,
647 locked: &Locked,
648 record: &Record,
649 ) -> Vec<(PathBuf, Problem)> {
650 let placed = self.placed(section, locked).into_iter();
651 let changed = placed.filter(|path| record.held(path) == Held::Modified);
652 changed
653 .filter_map(|path| match path.symlink_metadata() {
654 Ok(_) => Some((path, Problem::Modified)),
655 Err(_) => (section != Section::Extensions).then_some((path, Problem::Missing)),
656 })
657 .collect()
658 }
659
660 pub fn state(&self, section: Section, name: &str, entry: &Entry) -> &'static str {
670 if self.no_source(name, entry) {
671 return "no source";
672 }
673 let patched = self.manifest.patch.contains_key(name);
674 let locked = self.lock.as_ref().and_then(|lock| {
675 lock.section(section)
676 .iter()
677 .find(|locked| locked.name == name)
678 .filter(|locked| fresh(entry, locked))
679 });
680 let record = self.record();
681 let changed = |locked: &Locked, problem: Problem| {
682 let changed = self.changed(section, locked, &record);
683 changed.iter().any(|(_, found)| *found == problem)
684 };
685 match locked {
686 Some(locked) if patched && changed(locked, Problem::Missing) => "missing",
687 Some(locked) if patched && changed(locked, Problem::Modified) => "edited",
688 _ if patched => "patched",
689 None => "stale",
690 Some(locked) if !self.present(section, locked) => "missing",
691 Some(locked) if changed(locked, Problem::Modified) => "edited",
692 Some(locked) if !self.holds_locked(section, entry, locked, &record) => "outdated",
693 Some(_) => "installed",
694 }
695 }
696
697 fn no_source(&self, name: &str, entry: &Entry) -> bool {
700 let path_source = match (&entry.git, &entry.npm) {
701 (None, None) => entry.path.as_deref(),
702 _ => None,
703 };
704 let patched = self.manifest.patch.contains_key(name);
705 !patched && path_source.is_some_and(|path| !self.resolve(path).exists())
706 }
707
708 pub fn present(&self, section: Section, locked: &Locked) -> bool {
713 let placed = self.placed(section, locked);
714 let in_place = section == Section::Extensions && placed.is_empty();
715 let first = self.homes(section).into_iter().next();
716 let there = |path: &PathBuf| match path.symlink_metadata() {
717 Ok(metadata) => !metadata.is_symlink() || path.parent() != first.as_deref(),
718 Err(_) => false,
719 };
720 in_place || !placed.is_empty() && placed.iter().all(there)
721 }
722
723 pub async fn sync(
732 &mut self,
733 mode: Mode,
734 npm: &mut Npm,
735 progress: &mut (dyn FnMut(&str) + Send),
736 ) -> Result<Report, ChestError> {
737 let mut report = Report::default();
738 if !matches!(mode, Mode::Startup { .. }) {
741 self.refuse_wide()?;
742 self.path_notices(&mut report);
743 }
744 let frozen = mode == Mode::Frozen;
745 if frozen {
748 self.lock = self.read_lock()?;
749 if !self.check_frozen(&mut report)? {
750 return Ok(report);
751 }
752 }
753 if let Mode::Startup { offline } = mode
755 && self.startup_without_lock(offline, &mut report)?
756 {
757 return Ok(report);
758 }
759 let lock_path = self.lock_path();
760 let mut waiting = || progress(&waiting_for(&lock_path));
761 let _held = hold_lock(&lock_path, &mut waiting).await?;
762 self.lock = self.read_lock()?;
763 if frozen && !self.check_frozen(&mut report)? {
765 return Ok(report);
766 }
767 let startup = matches!(mode, Mode::Startup { .. });
768 let old = match (&self.lock, startup) {
769 (Some(lock), _) => lock.clone(),
770 (None, false) => Lock::default(),
771 (None, true) => {
772 report.warnings.push(format!(
773 "{} has no lock, so nothing from it was installed: run `{}`",
774 self.label(),
775 self.install_command()
776 ));
777 return Ok(report);
778 }
779 };
780 let record_path = Record::path(&self.state, &self.root(), self.scope);
783 let state = record_path.parent().unwrap_or(&record_path);
784 std::fs::create_dir_all(state).map_err(io(state))?;
785 let mut waiting = || progress(&waiting_for(&record_path));
786 let _record_held = hold_lock(&record_path, &mut waiting).await?;
787 self.prepare_folders()?;
788 let mut record = Record::load(&record_path, &self.root(), &self.file_name());
789 let (staged, _work) = self
791 .prepare(&mode, &old, &record, npm, progress, &mut report)
792 .await?;
793 let committed = self.commit(
794 &staged,
795 &old,
796 frozen,
797 &mut record,
798 &record_path,
799 &mut report,
800 );
801 let occupied = match committed {
802 Ok(occupied) => occupied,
803 Err(err) => {
804 discard(&staged);
805 return Err(err);
806 }
807 };
808 if startup {
809 return Ok(report);
810 }
811 let mut lock = Lock::default();
812 for staged in staged {
813 let Some(record) = staged.record else {
814 continue;
815 };
816 match staged.section {
817 Section::Skills => lock.skills.push(record),
818 Section::Agents => lock.agents.push(record),
819 _ => lock.extensions.push(record),
820 }
821 }
822 let kept = self.held_back(&old, &occupied, &record, &mut report);
823 self.remove_dropped(&old, &kept, &mut record, &record_path, &mut report)?;
824 let text = lock.to_toml()?;
825 if !frozen && std::fs::read_to_string(&lock_path).ok().as_deref() != Some(text.as_str()) {
827 write_atomic(&lock_path, text.as_bytes(), 0o666).map_err(io(&lock_path))?;
828 }
829 if self.scope == Scope::Project {
830 self.write_ignore(&occupied)?;
831 }
832 self.lock = Some(lock);
833 Ok(report)
834 }
835
836 fn prepare_folders(&self) -> Result<(), ChestError> {
842 let root = self.root();
843 let mut homes: Vec<PathBuf> = Vec::new();
844 for section in [Section::Skills, Section::Agents] {
845 let configured = match section {
846 Section::Skills => &self.manifest.folders.skills,
847 _ => &self.manifest.folders.agents,
848 };
849 if !self.manifest.section(section).is_empty() {
850 for folder in configured {
851 let path = self.resolve(folder);
852 place::within(&root, &path.join("item"))?;
854 if path.symlink_metadata().is_err() {
856 std::fs::create_dir_all(&path).map_err(io(&path))?;
857 }
858 }
859 }
860 let mut reals: Vec<PathBuf> = Vec::new();
861 for folder in configured {
862 let real = real_path(&self.resolve(folder));
863 if !reals.contains(&real) {
864 reals.push(real);
865 }
866 }
867 homes.extend(reals);
868 }
869 homes.push(real_path(&self.store()));
870 for (index, first) in homes.iter().enumerate() {
871 for second in &homes[index + 1..] {
872 if first.starts_with(second) || second.starts_with(first) {
873 return Err(invalid(format!(
874 "folders: {} and {} overlap",
875 first.display(),
876 second.display()
877 )));
878 }
879 }
880 }
881 for home in &homes {
882 if place::within(&root, &home.join("item")).is_err() {
884 continue;
885 }
886 for entry in std::fs::read_dir(home).into_iter().flatten().flatten() {
887 let name = entry.file_name().to_string_lossy().into_owned();
888 let stopped = name.ends_with("-staging") || name.ends_with("-old");
889 if name.starts_with('.') && stopped {
890 let path = entry.path();
891 npm::remove_path(&path).map_err(io(&path))?;
892 }
893 }
894 }
895 Ok(())
896 }
897
898 async fn prepare(
902 &self,
903 mode: &Mode,
904 old: &Lock,
905 record: &Record,
906 npm: &mut Npm,
907 progress: &mut (dyn FnMut(&str) + Send),
908 report: &mut Report,
909 ) -> Result<(Vec<Staged>, fetch::Work), ChestError> {
910 if let Mode::Update(names) = mode
911 && let Some(unknown) = names
912 .iter()
913 .find(|name| self.entries().all(|(_, entry, _)| entry != *name))
914 {
915 return Err(invalid(format!("No entry is named {unknown}")));
916 }
917 let mut work = fetch::Work::new(&self.cache)?;
918 let mut staged: Vec<Staged> = Vec::new();
919 let mut stale = Vec::new();
920 for (section, name, entry) in self.entries() {
921 let locked = old
922 .section(section)
923 .iter()
924 .find(|locked| &locked.name == name);
925 let target = Target {
926 id: format!("{}.{name}", section.key()),
927 section,
928 name,
929 entry,
930 patch: self.manifest.patch.get(name),
931 locked,
932 };
933 let fresh = locked.is_some_and(|locked| fresh(entry, locked));
934 let present = locked.is_some_and(|locked| self.present(section, locked));
935 let recorded = locked.is_some_and(|locked| self.recorded(section, locked, record));
936 let local = target.patch.is_some() || entry.git.is_none() && entry.npm.is_none();
937 let named = matches!(mode, Mode::Update(names)
938 if names.is_empty() || names.iter().any(|n| n == name));
939 let matches = |locked: &Locked| self.copy_problems(section, locked).is_empty();
942 let keep = match mode {
943 Mode::Startup { offline } => {
944 match self.at_startup(section, name, entry, locked, record, *offline) {
945 AtStartup::Stale => {
946 stale.push(target.id.clone());
947 Some(Outcome::Skipped)
948 }
949 AtStartup::Present => Some(Outcome::Unchanged),
950 AtStartup::Missing => Some(Outcome::Skipped),
951 AtStartup::Replay => None,
952 }
953 }
954 Mode::Frozen
955 if present
956 && (recorded || locked.is_some_and(matches))
957 && !self.no_source(name, entry) =>
958 {
959 Some(Outcome::Unchanged)
960 }
961 mode if *mode != Mode::Frozen && self.no_source(name, entry) => {
963 let path = entry.path.as_deref().unwrap_or_default();
964 report.warnings.push(format!(
965 "{}: {} does not exist, so chest skipped it: restore it or remove the entry",
966 target.id,
967 self.resolve(path).display()
968 ));
969 Some(Outcome::Skipped)
970 }
971 _ if fresh && present && recorded && !local && !named => {
972 let problems =
973 locked.map_or_else(Vec::new, |locked| self.copy_problems(section, locked));
974 for problem in &problems {
975 report.warnings.push(format!(
976 "{}, so chest left it as it is: delete it and run `{}` to put back the locked copy",
977 describe(problem),
978 self.install_command()
979 ));
980 }
981 Some(match problems.is_empty() {
982 true => Outcome::Unchanged,
983 false => Outcome::Edited,
984 })
985 }
986 _ => None,
987 };
988 if let Some(outcome) = keep {
989 staged.push(Staged {
990 id: target.id,
991 section,
992 record: locked.cloned(),
993 content: Content::Nothing,
994 outcome,
995 warnings: Vec::new(),
996 });
997 continue;
998 }
999 let resolve = !fresh || named;
1001 progress(&format!("Installing {}...", target.id));
1002 match self.stage(&target, resolve, npm, &mut work).await {
1003 Ok(mut next) => {
1004 let id = &next.id;
1005 let warnings = next.warnings.drain(..);
1006 report
1007 .warnings
1008 .extend(warnings.map(|warning| format!("{id}: {warning}")));
1009 let same = next.record.as_ref() == locked && present && target.patch.is_none();
1014 let held = match local {
1015 true => self.holds_staged(section, &next.content, record),
1016 false => recorded && locked.is_some_and(matches),
1017 };
1018 if same && held {
1019 discard(std::slice::from_ref(&next));
1020 next.content = Content::Nothing;
1021 next.outcome = Outcome::Unchanged;
1022 }
1023 staged.push(next);
1024 }
1025 Err(err) => {
1026 discard(&staged);
1027 return Err(match err {
1028 ChestError::Invalid(message) => {
1029 invalid(format!("{}: {message}", target.id))
1030 }
1031 err => ChestError::Failed(format!("{}: {err}", target.id)),
1032 });
1033 }
1034 }
1035 }
1036 if !stale.is_empty() {
1037 report.warnings.push(self.stale_warning(&stale));
1038 }
1039 Ok((staged, work))
1040 }
1041
1042 async fn stage(
1045 &self,
1046 target: &Target<'_>,
1047 resolve: bool,
1048 npm: &mut Npm,
1049 work: &mut fetch::Work,
1050 ) -> Result<Staged, ChestError> {
1051 let Target {
1052 section,
1053 name,
1054 entry,
1055 locked,
1056 ..
1057 } = *target;
1058 let staged = |record, content, outcome, warnings| Staged {
1059 id: target.id.clone(),
1060 section,
1061 record,
1062 content,
1063 outcome,
1064 warnings,
1065 };
1066 let hashed = |items: Vec<(String, PathBuf)>| -> Result<Vec<_>, ChestError> {
1067 let hash = |(name, source): (String, PathBuf)| {
1068 tree_hash(&source).map(|hash| (name, source, hash))
1069 };
1070 items.into_iter().map(hash).collect()
1071 };
1072 let path_source = match (&entry.git, &entry.npm) {
1073 (None, None) => entry.path.as_deref(),
1074 _ => None,
1075 };
1076 if let Some(path) = target
1077 .patch
1078 .and_then(|patch| patch.path.as_deref())
1079 .or(path_source)
1080 {
1081 let record = match target.patch {
1083 Some(_) => locked.cloned(),
1084 None => Some(Locked {
1085 name: name.to_owned(),
1086 path: entry.path.clone(),
1087 ..Locked::default()
1088 }),
1089 };
1090 let source = self.resolve(path);
1091 if !source.exists() {
1092 return Err(invalid(format!("{} does not exist", source.display())));
1093 }
1094 if section == Section::Extensions {
1095 let outcome = match resolve {
1096 true => Outcome::Installed,
1097 false => Outcome::Unchanged,
1098 };
1099 return Ok(staged(record, Content::Nothing, outcome, Vec::new()));
1100 }
1101 let patched_package =
1104 target.patch.is_some() && path_source.is_none() && entry.path.is_none();
1105 let path_package = target.patch.is_none() && fetch::is_package(section, &source);
1106 let (items, warnings) = if patched_package || path_package {
1107 fetch::items(&source, section, None, name)?
1108 } else {
1109 fetch::check_item(section, &source, &source)?;
1110 let item = (fetch::placed_name(section, name), source);
1111 (vec![item], Vec::new())
1112 };
1113 let sections = [Section::Skills, Section::Agents].into_iter();
1115 let mut installs: Vec<PathBuf> = sections.flat_map(|s| self.homes(s)).collect();
1116 installs.push(real_path(&self.store()));
1117 for (_, item) in &items {
1118 let real = real_path(item);
1119 if let Some(home) = installs.iter().find(|home| home.starts_with(&real)) {
1120 return Err(invalid(format!(
1121 "{} holds {}, where chest installs, so it cannot be installed",
1122 item.display(),
1123 home.display()
1124 )));
1125 }
1126 }
1127 let content = Content::Items(hashed(items)?);
1128 return Ok(staged(record, content, Outcome::Installed, warnings));
1129 }
1130 let replay = locked.filter(|_| !resolve);
1131 let mut record = Locked {
1132 name: name.to_owned(),
1133 git: entry.git.clone(),
1134 rev: entry.rev.clone(),
1135 npm: entry.npm.clone(),
1136 path: entry.path.clone(),
1137 ..Locked::default()
1138 };
1139 let package = work.path();
1140 if let Some(repo) = &entry.git {
1141 let commit = match replay {
1142 Some(locked) => locked.commit.clone().unwrap_or_default(),
1143 None => fetch::resolve(&self.cache, repo, entry.rev.as_deref()).await?,
1144 };
1145 fetch::checkout(&self.cache, repo, &commit, &package).await?;
1146 record.commit = Some(commit);
1147 }
1148 let mut warnings = Vec::new();
1149 let content = if section == Section::Extensions {
1150 let dir = self.store().join(name);
1151 place::within(&self.root(), &dir)?;
1152 let staging = npm::stage(&dir)?;
1153 let built = self
1154 .build_extension(entry, replay, &package, &staging, npm, &mut record)
1155 .await;
1156 if let Err(err) = built {
1157 let _ = std::fs::remove_dir_all(&staging);
1158 return Err(err);
1159 }
1160 let hash = record.hash.clone().unwrap_or_default();
1161 Content::Store { staging, dir, hash }
1162 } else {
1163 if let Some(npm_name) = &entry.npm {
1164 let pin = match replay {
1165 Some(locked) => {
1166 let pin = Pinned {
1167 path: String::new(),
1168 name: npm_name.clone(),
1169 version: locked.version.clone().unwrap_or_default(),
1170 tarball: locked.tarball.clone().unwrap_or_default(),
1171 integrity: locked.integrity.clone().unwrap_or_default(),
1172 };
1173 npm::fetch_pinned(npm, &pin, &package).await?;
1174 pin
1175 }
1176 None => {
1177 let pin =
1178 npm::fetch(npm, npm_name, entry.version.as_deref(), &package).await?;
1179 npm.check_pin(&pin)?;
1181 pin
1182 }
1183 };
1184 record.version = Some(pin.version);
1185 record.tarball = Some(pin.tarball);
1186 record.integrity = Some(pin.integrity);
1187 }
1188 let (items, skipped) = fetch::items(&package, section, entry.path.as_deref(), name)?;
1189 warnings = skipped;
1190 let items = hashed(items)?;
1191 if entry.path.is_some() {
1192 record.hash = Some(items[0].2.clone());
1193 } else {
1194 for (item, _, hash) in &items {
1195 record.items.push(lock::Item {
1196 name: item.clone(),
1197 hash: hash.clone(),
1198 });
1199 }
1200 }
1201 Content::Items(items)
1202 };
1203 let Some(locked) = replay else {
1204 return Ok(staged(Some(record), content, Outcome::Installed, warnings));
1205 };
1206 if record.hash != locked.hash || record.items != locked.items {
1207 if let Content::Store { staging, .. } = &content {
1208 let _ = std::fs::remove_dir_all(staging);
1209 }
1210 return Err(invalid(format!(
1211 "what was fetched does not match the lock: run `{} update` to take it",
1212 self.command()
1213 )));
1214 }
1215 Ok(staged(
1216 Some(locked.clone()),
1217 content,
1218 Outcome::Replayed,
1219 warnings,
1220 ))
1221 }
1222
1223 async fn build_extension(
1227 &self,
1228 entry: &Entry,
1229 replay: Option<&Locked>,
1230 package: &Path,
1231 staging: &Path,
1232 npm: &mut Npm,
1233 record: &mut Locked,
1234 ) -> Result<(), ChestError> {
1235 match (&entry.npm, replay) {
1236 (_, Some(locked)) => {
1237 if let Some(npm_name) = &entry.npm {
1240 let root = format!("node_modules/{npm_name}");
1241 let version = locked.version.as_deref().unwrap_or_default();
1242 let pinned = locked.packages.iter().any(|pin| {
1243 pin.path == root && pin.name == *npm_name && pin.version == version
1244 });
1245 if !pinned {
1246 return Err(invalid(format!(
1247 "the lock does not pin {npm_name}@{version} at {root}"
1248 )));
1249 }
1250 }
1251 if entry.git.is_some() {
1252 copy_checkout(package, entry.path.as_deref(), staging)?;
1253 }
1254 let root = staging.join("package.json");
1257 if let Some(npm_name) = &entry.npm {
1258 let version = locked.version.as_deref().unwrap_or_default();
1259 let manifest = serde_json::json!({"dependencies": {npm_name: version}});
1260 let text = yapi_types::json::to_string(&manifest).unwrap_or_default();
1261 std::fs::write(&root, text).map_err(io(&root))?;
1262 }
1263 npm::replay(npm, staging, &locked.packages).await?;
1264 if entry.npm.is_some() {
1265 std::fs::remove_file(&root).map_err(io(&root))?;
1266 }
1267 }
1268 (Some(npm_name), None) => {
1269 let installed =
1270 npm::install(npm, staging, npm_name, entry.version.as_deref()).await?;
1271 npm::check_installed(staging, &installed.tree)?;
1274 for file in ["package.json", ".gitignore"] {
1276 let file = staging.join(file);
1277 std::fs::remove_file(&file).map_err(io(&file))?;
1278 }
1279 record.version = Some(installed.version);
1280 record.packages = installed.tree;
1281 }
1282 (None, None) => {
1283 copy_checkout(package, entry.path.as_deref(), staging)?;
1284 record.packages = npm::install_dependencies(npm, staging).await?;
1285 npm::check_installed(staging, &record.packages)?;
1286 }
1287 }
1288 for pin in &record.packages {
1291 npm.check_pin(pin)?;
1292 }
1293 record.hash = Some(tree_hash(staging)?);
1294 Ok(())
1295 }
1296
1297 fn commit(
1309 &self,
1310 staged: &[Staged],
1311 old: &Lock,
1312 frozen: bool,
1313 record: &mut Record,
1314 record_path: &Path,
1315 report: &mut Report,
1316 ) -> Result<Vec<PathBuf>, ChestError> {
1317 let root = self.root();
1318 let pinned = self.pinned(old);
1319 let mut occupied: HashSet<PathBuf> = HashSet::new();
1320 for staged in staged {
1321 if let (Content::Nothing, Some(locked)) = (&staged.content, &staged.record) {
1322 occupied.extend(self.placed(staged.section, locked));
1323 }
1324 }
1325 let mut placements: Vec<Vec<(PathBuf, Vec<PathBuf>, String)>> = Vec::new();
1327 let mut outcomes = Vec::new();
1328 for staged in staged {
1329 let planned = match &staged.content {
1330 Content::Items(items) => {
1331 let folders = self.folders(staged.section)?;
1332 let plan = |(name, source, hash): &(String, PathBuf, String)| {
1333 let targets = folders.iter().map(|folder| folder.join(name)).collect();
1334 (source.clone(), targets, hash.clone())
1335 };
1336 items.iter().map(plan).collect()
1337 }
1338 Content::Store { staging, dir, hash } => {
1339 vec![(staging.clone(), vec![dir.clone()], hash.clone())]
1340 }
1341 Content::Nothing => Vec::new(),
1342 };
1343 let mut placing = Vec::new();
1344 let mut left = false;
1345 for (source, targets, hash) in planned {
1346 let mut edited = false;
1347 for target in &targets {
1348 let refuse =
1349 |why: &str| invalid(format!("{}: {why} {}", staged.id, target.display()));
1350 place::within(&root, target)
1351 .map_err(|err| invalid(format!("{}: {err}", staged.id)))?;
1352 if !occupied.insert(target.clone()) {
1353 return Err(refuse("another entry also installs"));
1354 }
1355 if target.symlink_metadata().is_err() {
1356 continue;
1357 }
1358 match record.held(target) {
1359 Held::Owned => {}
1360 Held::Modified => {
1361 let edit = format!(
1362 "{}: {} was modified since chest installed it",
1363 staged.id,
1364 target.display()
1365 );
1366 let how = format!(
1367 "delete it and run `{}` to put back the locked copy",
1368 self.install_command()
1369 );
1370 if frozen {
1371 return Err(invalid(format!("{edit}: {how}")));
1372 }
1373 let mut warning = format!("{edit}, so chest left it as it is: {how}");
1374 if staged.section == Section::Extensions && !record.holds(target, &hash)
1376 {
1377 warning.push_str(", and until then yapi does not load it");
1378 }
1379 report.warnings.push(warning);
1380 edited = true;
1381 }
1382 Held::Unrecorded => {
1383 let lock_pins = pinned.get(target);
1384 let adoptable = lock_pins == Some(&hash)
1385 && content_hash(target).as_ref() == lock_pins
1386 && !has_git(target);
1387 if !adoptable {
1388 return Err(refuse(
1389 "refusing to replace what chest did not install at",
1390 ));
1391 }
1392 record.adopt(target, &hash);
1393 }
1394 }
1395 }
1396 if edited {
1398 left = true;
1399 } else {
1400 placing.push((source, targets, hash));
1401 }
1402 }
1403 let outcome = match left {
1404 true => Outcome::Edited,
1405 false => staged.outcome,
1406 };
1407 if left {
1408 discard(std::slice::from_ref(staged));
1409 }
1410 outcomes.push(outcome);
1411 placements.push(placing);
1412 }
1413 record.save(record_path)?;
1414 let entries = staged.iter().zip(placements).zip(outcomes);
1415 for ((staged, planned), outcome) in entries {
1416 for (source, targets, hash) in planned {
1417 let before: Vec<_> = targets.iter().map(|target| record.get(target)).collect();
1418 for target in &targets {
1420 record.expect(target, &hash);
1421 }
1422 record.save(record_path)?;
1423 let placed = match &staged.content {
1424 Content::Store { dir, .. } => npm::swap(&source, dir, Ok::<(), ChestError>(())),
1425 _ => {
1426 let folders = self.folders(staged.section)?;
1427 let name = targets[0].file_name().unwrap_or_default().to_string_lossy();
1428 place::place(&source, &root, &folders, &name).map(drop)
1429 }
1430 };
1431 for (target, before) in targets.iter().zip(before) {
1434 if placed.is_ok() || content_hash(target).as_ref() == Some(&hash) {
1435 record.noted(target, &hash);
1436 } else {
1437 record.restore(target, before);
1438 }
1439 }
1440 record.save(record_path)?;
1441 placed?;
1442 }
1443 report.entries.push((staged.id.clone(), outcome));
1444 }
1445 Ok(occupied.into_iter().collect())
1446 }
1447
1448 fn held_back(
1454 &self,
1455 old: &Lock,
1456 occupied: &[PathBuf],
1457 record: &Record,
1458 report: &mut Report,
1459 ) -> Vec<PathBuf> {
1460 let recorded = record.paths();
1461 let mut kept = occupied.to_vec();
1462 for (section, name, entry) in self.entries() {
1463 if section == Section::Extensions || !self.no_source(name, entry) {
1464 continue;
1465 }
1466 let locked = old
1467 .section(section)
1468 .iter()
1469 .find(|locked| &locked.name == name);
1470 let Some(locked) = locked else { continue };
1471 if self
1473 .placed(section, locked)
1474 .iter()
1475 .any(|path| recorded.contains(path))
1476 {
1477 continue;
1478 }
1479 let homes = self.homes(section);
1480 let held: Vec<&PathBuf> = recorded
1481 .iter()
1482 .filter(|path| {
1483 path.parent()
1484 .is_some_and(|parent| homes.iter().any(|home| home == parent))
1485 })
1486 .filter(|path| !kept.contains(path))
1487 .collect();
1488 if held.is_empty() {
1489 continue;
1490 }
1491 let list: Vec<String> = held.iter().map(|path| path.display().to_string()).collect();
1492 report.warnings.push(format!(
1493 "{}.{name}'s folder is missing and held several items, so chest keeps {}, which it would remove, as it can't tell that entry's copies apart: restore the folder or remove the entry",
1494 section.key(),
1495 list.join(", ")
1496 ));
1497 kept.extend(held.into_iter().cloned());
1498 }
1499 kept
1500 }
1501
1502 fn remove_dropped(
1510 &self,
1511 old: &Lock,
1512 occupied: &[PathBuf],
1513 record: &mut Record,
1514 record_path: &Path,
1515 report: &mut Report,
1516 ) -> Result<(), ChestError> {
1517 let real = |path: &Path| match (path.parent(), path.file_name()) {
1519 (Some(parent), Some(name)) => real_path(parent).join(name),
1520 _ => path.to_path_buf(),
1521 };
1522 let really: HashSet<PathBuf> = occupied.iter().map(|path| real(path)).collect();
1523 let occupied: HashSet<&PathBuf> = occupied.iter().collect();
1524 for path in record.paths() {
1525 if occupied.contains(&path) {
1526 continue;
1527 }
1528 if record.shared(&path) || really.contains(&real(&path)) {
1529 record.release(&path);
1530 record.save(record_path)?;
1531 continue;
1532 }
1533 if path.symlink_metadata().is_ok() {
1534 match record.held(&path) {
1535 Held::Modified => {
1536 report.warnings.push(format!(
1537 "{} was modified since chest installed it, so chest left it as it is: delete it, or undo the edit and run `{}` to remove it",
1538 path.display(),
1539 self.install_command()
1540 ));
1541 continue;
1542 }
1543 _ => {
1544 if let Err(err) = place::unplace(&self.root(), std::slice::from_ref(&path))
1545 {
1546 report.warnings.push(format!(
1547 "chest could not remove {} and forgets it: {err}",
1548 path.display()
1549 ));
1550 }
1551 }
1552 }
1553 }
1554 record.forget(&path);
1555 record.save(record_path)?;
1556 }
1557 for section in [Section::Skills, Section::Agents, Section::Extensions] {
1558 for locked in old.section(section) {
1559 if !self.manifest.section(section).contains_key(&locked.name) {
1560 let id = format!("{}.{}", section.key(), locked.name);
1561 report.entries.push((id, Outcome::Removed));
1562 }
1563 }
1564 }
1565 Ok(())
1566 }
1567
1568 fn write_ignore(&self, occupied: &[PathBuf]) -> Result<(), ChestError> {
1572 let root = self.root();
1573 let relative = |path: &Path| -> Result<String, ChestError> {
1574 let relative = path.strip_prefix(&root).map_err(|_| {
1575 invalid(format!("{} is outside {}", path.display(), root.display()))
1576 })?;
1577 Ok(slashed(relative))
1578 };
1579 let mut entries = Vec::new();
1580 for path in occupied {
1581 entries.push(relative(path)?);
1582 }
1583 let first = |section| self.folders(section).ok()?.into_iter().next();
1584 let copies = [
1585 first(Section::Skills),
1586 first(Section::Agents),
1587 Some(self.store()),
1588 ];
1589 for home in copies.into_iter().flatten() {
1590 if occupied
1591 .iter()
1592 .any(|path| path.parent() == Some(home.as_path()))
1593 {
1594 let home = relative(&home)?;
1595 entries.push(format!("{home}/.*-staging"));
1596 entries.push(format!("{home}/.*-old"));
1597 }
1598 }
1599 place::write_ignore(&root, &self.file_name(), &entries)
1600 }
1601
1602 fn path_notices(&self, report: &mut Report) {
1604 if self.scope != Scope::Project {
1605 return;
1606 }
1607 let entries = self
1608 .entries()
1609 .map(|(section, name, entry)| (format!("{}.{name}", section.key()), entry));
1610 let patches = self
1611 .manifest
1612 .patch
1613 .iter()
1614 .map(|(name, entry)| (format!("patch.{name}"), entry));
1615 for (id, entry) in entries.chain(patches) {
1616 let source = entry
1617 .path
1618 .as_deref()
1619 .filter(|_| entry.git.is_none() && entry.npm.is_none());
1620 let Some(path) = source else { continue };
1621 let resolved = self.resolve(path);
1622 if !resolved.starts_with(&self.base) {
1623 report.notices.push(format!(
1624 "{id}: `{path}` is outside the project, at {}",
1625 resolved.display()
1626 ));
1627 }
1628 }
1629 }
1630
1631 fn check_frozen(&self, report: &mut Report) -> Result<bool, ChestError> {
1638 let (name, install) = (self.label(), self.install_command());
1639 let lock = self
1640 .lock
1641 .as_ref()
1642 .ok_or_else(|| invalid(format!("{name} has no lock: run `{install}`")))?;
1643 let drift = lock.drift(&self.manifest);
1644 if !drift.is_empty() {
1645 return Err(invalid(format!(
1646 "The lock of {name} is out of date for {}: run `{install}`",
1647 drift.join(", ")
1648 )));
1649 }
1650 let problems = self.problems(lock);
1651 let record = self.record();
1652 let (outdated, wrong): (Vec<_>, Vec<_>) = problems
1653 .iter()
1654 .filter(|(_, _, problem)| !matches!(problem, Problem::Missing | Problem::NoSource))
1655 .partition(|(_, path, problem)| {
1656 *problem == Problem::Mismatch && record.held(path) == Held::Owned
1657 });
1658 if !wrong.is_empty() {
1659 let wrong: Vec<String> = wrong.into_iter().map(describe).collect();
1660 return Err(invalid(wrong.join("\n")));
1661 }
1662 let missing = !outdated.is_empty()
1664 || self.entries().any(|(section, entry_name, entry)| {
1665 let locked = lock.section(section).iter();
1666 let mut found = locked.filter(|locked| &locked.name == entry_name);
1667 self.no_source(entry_name, entry)
1668 || found.any(|locked| !self.present(section, locked))
1669 });
1670 if !missing {
1671 for (section, entry_name, _) in self.entries() {
1672 let id = format!("{}.{entry_name}", section.key());
1673 report.entries.push((id, Outcome::Unchanged));
1674 }
1675 }
1676 Ok(missing)
1677 }
1678
1679 fn problems(&self, lock: &Lock) -> Vec<(String, PathBuf, Problem)> {
1685 let record = self.record();
1686 let mut problems = Vec::new();
1687 for section in [Section::Skills, Section::Agents, Section::Extensions] {
1688 for locked in lock.section(section) {
1689 let copies = self.copy_problems(section, locked);
1690 let path_entry = locked.git.is_none() && locked.npm.is_none();
1691 let (mut edited, mut gone) = (Vec::new(), None);
1692 let patched = self.manifest.patch.contains_key(&locked.name);
1693 if path_entry || patched {
1694 let id = format!("{}.{}", section.key(), locked.name);
1695 let source = locked.path.as_deref().filter(|_| path_entry && !patched);
1697 gone = source
1698 .map(|path| self.resolve(path))
1699 .filter(|source| !source.exists())
1700 .map(|source| (id.clone(), source, Problem::NoSource));
1701 let reported = |path: &PathBuf| copies.iter().any(|(_, at, _)| at == path);
1703 edited = self
1704 .changed(section, locked, &record)
1705 .into_iter()
1706 .filter(|(path, _)| !reported(path))
1707 .map(|(path, problem)| (id.clone(), path, problem))
1708 .collect();
1709 }
1710 if gone.is_none() {
1712 problems.extend(copies);
1713 }
1714 problems.extend(gone);
1715 problems.extend(edited);
1716 }
1717 }
1718 problems
1719 }
1720
1721 fn copy_problems(&self, section: Section, locked: &Locked) -> Vec<(String, PathBuf, Problem)> {
1726 let mut problems = Vec::new();
1727 if self.manifest.patch.contains_key(&locked.name) {
1728 return problems;
1729 }
1730 let homes = self.homes(section);
1731 let id = format!("{}.{}", section.key(), locked.name);
1732 for (item, hash) in self.locked_items(section, locked) {
1733 let canonical = homes.first().map(|home| home.join(&item));
1734 for (index, home) in homes.iter().enumerate() {
1735 let path = home.join(&item);
1736 let problem = match path.symlink_metadata() {
1737 Err(_) => Some(Problem::Missing),
1738 Ok(metadata) if metadata.is_symlink() && index == 0 => Some(Problem::Linked),
1739 Ok(metadata) if metadata.is_symlink() => {
1740 let target = path.canonicalize().ok();
1741 let canonical = canonical.as_ref().and_then(|c| c.canonicalize().ok());
1742 (target != canonical).then(|| Problem::Link(homes[0].join(&item)))
1743 }
1744 Ok(_) => hash
1745 .as_ref()
1746 .filter(|hash| tree_hash(&path).ok().as_ref() != Some(*hash))
1747 .map(|_| Problem::Mismatch),
1748 };
1749 if let Some(problem) = problem {
1750 problems.push((id.clone(), path, problem));
1751 }
1752 }
1753 }
1754 problems
1755 }
1756
1757 pub fn installed_root(&self, name: &str) -> Option<PathBuf> {
1761 self.extension_roots(name)
1762 .map(|(root, _)| root)
1763 .filter(|root| root.exists())
1764 }
1765
1766 pub fn extension_roots(&self, name: &str) -> Option<(PathBuf, PathBuf)> {
1771 let entry = self.manifest.extensions.get(name)?;
1772 let patch = self
1773 .manifest
1774 .patch
1775 .get(name)
1776 .and_then(|patch| patch.path.as_deref());
1777 let store = self.store().join(name);
1778 Some(
1779 match (patch, &entry.npm, &entry.git, entry.path.as_deref()) {
1780 (Some(path), ..) | (None, None, None, Some(path)) => {
1781 let root = self.resolve(path);
1782 (root.clone(), root)
1783 }
1784 (None, Some(npm), _, _) => (store.join("node_modules").join(npm), store),
1785 (None, None, Some(_), _) => (store.clone(), store),
1786 (None, None, None, None) => return None,
1787 },
1788 )
1789 }
1790
1791 fn startup_without_lock(
1796 &mut self,
1797 offline: bool,
1798 report: &mut Report,
1799 ) -> Result<bool, ChestError> {
1800 self.lock = self.read_lock()?;
1801 let Some(lock) = &self.lock else {
1802 report.warnings.push(format!(
1803 "{} has no lock, so nothing from it was installed: run `{}`",
1804 self.label(),
1805 self.install_command()
1806 ));
1807 return Ok(true);
1808 };
1809 let record = self.record();
1810 let mut entries = Vec::new();
1811 let mut stale = Vec::new();
1812 let (mut missing, mut loading) = (Vec::new(), Vec::new());
1813 for (section, name, entry) in self.entries() {
1814 let locked = lock
1815 .section(section)
1816 .iter()
1817 .find(|locked| &locked.name == name);
1818 let id = format!("{}.{name}", section.key());
1819 let outcome = match self.at_startup(section, name, entry, locked, &record, offline) {
1820 AtStartup::Stale => {
1821 stale.push(id.clone());
1822 Outcome::Skipped
1823 }
1824 AtStartup::Present => Outcome::Unchanged,
1825 AtStartup::Missing => {
1826 match locked.is_some_and(|locked| self.read_by_yapi(section, locked)) {
1827 true => loading.push(id.clone()),
1828 false => missing.push(id.clone()),
1829 }
1830 Outcome::Skipped
1831 }
1832 AtStartup::Replay => return Ok(false),
1833 };
1834 entries.push((id, outcome));
1835 }
1836 report.entries = entries;
1837 if !stale.is_empty() {
1838 report.warnings.push(self.stale_warning(&stale));
1839 }
1840 let label = self.label();
1841 if !missing.is_empty() {
1842 let skipped = if missing.len() == 1 {
1843 "it was"
1844 } else {
1845 "they were"
1846 };
1847 report.warnings.push(format!(
1848 "{label}: {} could not be put back offline, so {skipped} left out",
1849 missing.join(", ")
1850 ));
1851 }
1852 if !loading.is_empty() {
1853 let copies = match loading.len() {
1854 1 => "its copy in a folder yapi reads on its own keeps",
1855 _ => "their copies in folders yapi reads on its own keep",
1856 };
1857 report.warnings.push(format!(
1858 "{label}: {} could not be put back offline, so {copies} loading",
1859 loading.join(", ")
1860 ));
1861 }
1862 Ok(true)
1863 }
1864
1865 fn read_by_yapi(&self, section: Section, locked: &Locked) -> bool {
1869 if section != Section::Skills {
1870 return false;
1871 }
1872 let own = match self.scope {
1873 Scope::Project => [".agents/skills", ".yapi/skills"].map(|dir| self.base.join(dir)),
1874 Scope::Global => [
1875 self.home.join(".agents/skills"),
1876 crate::config::agent_dir().join("skills"),
1877 ],
1878 };
1879 let own = own.map(|folder| real_path(&folder));
1880 let homes = self.homes(section);
1881 let items = self.locked_items(section, locked);
1882 let read = homes.iter().filter(|home| own.contains(home));
1883 read.flat_map(|home| items.iter().map(move |(item, _)| home.join(item)))
1884 .any(|copy| copy.exists())
1885 }
1886
1887 fn at_startup(
1892 &self,
1893 section: Section,
1894 name: &str,
1895 entry: &Entry,
1896 locked: Option<&Locked>,
1897 record: &Record,
1898 offline: bool,
1899 ) -> AtStartup {
1900 let patched = self.manifest.patch.contains_key(name);
1901 if !patched && !locked.is_some_and(|locked| fresh(entry, locked)) {
1902 return AtStartup::Stale;
1903 }
1904 let pinned = |locked: &Locked| {
1906 self.present(section, locked) && self.holds_locked(section, entry, locked, record)
1907 };
1908 if patched || locked.is_some_and(pinned) {
1909 return AtStartup::Present;
1910 }
1911 if offline {
1912 AtStartup::Missing
1913 } else {
1914 AtStartup::Replay
1915 }
1916 }
1917
1918 fn stale_warning(&self, ids: &[String]) -> String {
1921 let (verb, lock, skipped) = match ids.len() {
1922 1 => ("is", "its", "it was"),
1923 _ => ("are", "their", "they were"),
1924 };
1925 format!(
1926 "{}: {} {verb} not locked, or {lock} lock is stale, so {skipped} skipped: run `{}`",
1927 self.label(),
1928 ids.join(", "),
1929 self.install_command()
1930 )
1931 }
1932
1933 pub fn verify(&self) -> Result<Vec<String>, ChestError> {
1939 let (name, install) = (self.label(), self.install_command());
1940 let lock = self
1941 .lock
1942 .as_ref()
1943 .ok_or_else(|| invalid(format!("{name} has no lock: run `{install}`")))?;
1944 Ok(self.problems(lock).iter().map(describe).collect())
1945 }
1946
1947 pub async fn outdated(&self, npm: &mut Npm) -> Result<Vec<Outdated>, ChestError> {
1951 let Some(lock) = &self.lock else {
1952 return Ok(Vec::new());
1953 };
1954 let mut found = Vec::new();
1955 for (section, name, entry) in self.entries() {
1956 let Some(locked) = lock
1957 .section(section)
1958 .iter()
1959 .find(|locked| &locked.name == name)
1960 else {
1961 continue;
1962 };
1963 let id = format!("{}.{name}", section.key());
1964 let failed = |err: ChestError| ChestError::Failed(format!("{id}: {err}"));
1965 if let Some(repo) = &entry.git {
1966 let wanted = fetch::resolve(&self.cache, repo, entry.rev.as_deref())
1967 .await
1968 .map_err(failed)?;
1969 let locked = locked.commit.clone().unwrap_or_default();
1970 found.push(Outdated {
1971 id,
1972 locked,
1973 wanted,
1974 latest: None,
1975 });
1976 } else if let Some(package) = &entry.npm {
1977 let range = entry.version.as_deref().unwrap_or("latest");
1978 let wanted = npm
1979 .latest(package, range)
1980 .await
1981 .map_err(|err| failed(err.into()))?;
1982 let latest = npm.latest(package, "latest").await.ok();
1983 let locked = locked.version.clone().unwrap_or_default();
1984 found.push(Outdated {
1985 id,
1986 locked,
1987 wanted,
1988 latest,
1989 });
1990 }
1991 }
1992 Ok(found)
1993 }
1994
1995 pub async fn fetch_package(&self, entry: &Entry, npm: &mut Npm) -> Result<Fetched, ChestError> {
2000 if entry.git.is_none() && entry.npm.is_none() {
2001 let path = entry
2002 .path
2003 .as_deref()
2004 .ok_or_else(|| invalid("needs `git`, `npm` or `path`"))?;
2005 return Ok(Fetched {
2006 root: self.resolve(path),
2007 _work: None,
2008 });
2009 }
2010 let mut work = fetch::Work::new(&self.cache)?;
2011 let root = work.path();
2012 if let Some(repo) = &entry.git {
2013 let commit = fetch::resolve(&self.cache, repo, entry.rev.as_deref()).await?;
2014 fetch::checkout(&self.cache, repo, &commit, &root).await?;
2015 } else if let Some(package) = &entry.npm {
2016 npm::fetch(npm, package, entry.version.as_deref(), &root).await?;
2017 }
2018 Ok(Fetched {
2019 root,
2020 _work: Some(work),
2021 })
2022 }
2023}
2024
2025#[derive(Clone, Debug, PartialEq, Eq)]
2027enum Problem {
2028 Missing,
2030 Mismatch,
2032 Modified,
2034 Link(PathBuf),
2036 Linked,
2038 NoSource,
2040}
2041
2042fn waiting_for(file: &Path) -> String {
2044 format!(
2045 "Waiting for another chest command, which holds the lock of {}...",
2046 file.display()
2047 )
2048}
2049
2050fn describe((id, path, problem): &(String, PathBuf, Problem)) -> String {
2051 let what = match problem {
2052 Problem::NoSource => return format!("{id}: its source {} is missing", path.display()),
2053 Problem::Missing => "is missing".to_owned(),
2054 Problem::Mismatch => "does not match the lock".to_owned(),
2055 Problem::Modified => "was modified since chest installed it".to_owned(),
2056 Problem::Link(canonical) => format!("does not link to {}", canonical.display()),
2057 Problem::Linked => "is a link where chest places the copy".to_owned(),
2058 };
2059 format!("{id}: {} {what}", path.display())
2060}
2061
2062fn has_git(path: &Path) -> bool {
2065 fn walk(dir: &Path) -> bool {
2066 let Ok(entries) = std::fs::read_dir(dir) else {
2067 return false;
2068 };
2069 entries.flatten().any(|entry| {
2070 entry.file_name() == ".git"
2071 || entry.file_type().is_ok_and(|kind| kind.is_dir()) && walk(&entry.path())
2072 })
2073 }
2074 let held = match path.symlink_metadata() {
2075 Ok(metadata) if metadata.is_symlink() => path.canonicalize().ok(),
2076 Ok(_) => Some(path.to_path_buf()),
2077 Err(_) => None,
2078 };
2079 held.is_some_and(|held| walk(&held))
2080}
2081
2082fn copy_checkout(package: &Path, subpath: Option<&str>, staging: &Path) -> Result<(), ChestError> {
2087 let source = package.join(subpath.unwrap_or_default());
2088 fetch::inside(package, &source)?;
2089 if !source.is_dir() {
2090 return Err(invalid(format!(
2091 "`path` must name the folder that holds the extension, not a file: {}",
2092 subpath.unwrap_or_default()
2093 )));
2094 }
2095 place::copy(&source, staging)?;
2096 let modules = staging.join("node_modules");
2097 if modules.symlink_metadata().is_ok() {
2098 std::fs::remove_dir_all(&modules).map_err(io(&modules))?;
2099 }
2100 Ok(())
2101}