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
149struct Target<'a> {
151 id: String,
152 section: Section,
153 name: &'a str,
154 entry: &'a Entry,
155 patch: Option<&'a Entry>,
156 locked: Option<&'a Locked>,
157}
158
159struct Staged {
161 id: String,
162 section: Section,
163 record: Option<Locked>,
164 content: Content,
165 outcome: Outcome,
166 warnings: Vec<String>,
168}
169
170enum Content {
171 Items(Vec<(String, PathBuf, String)>),
174 Store {
177 staging: PathBuf,
178 dir: PathBuf,
179 hash: String,
180 },
181 Nothing,
183}
184
185pub async fn hold_lock(
191 file: &Path,
192 waiting: &mut (dyn FnMut() + Send),
193) -> Result<FileLock, ChestError> {
194 let cancel = CancellationToken::new();
195 let failed = |err: LockError| ChestError::Failed(format!("{}: {err}", file.display()));
196 let first = tokio::time::timeout(Duration::from_secs(1), FileLock::acquire(file, &cancel));
197 match first.await {
198 Ok(Ok(lock)) => return Ok(lock),
199 Ok(Err(LockError::Held)) | Err(_) => waiting(),
200 Ok(Err(err)) => return Err(failed(err)),
201 }
202 loop {
203 match FileLock::acquire(file, &cancel).await {
204 Err(LockError::Held) => {}
205 locked => return locked.map_err(failed),
206 }
207 }
208}
209
210pub fn refuse_link(path: &Path) -> Result<(), ChestError> {
214 if path.symlink_metadata().is_ok_and(|meta| meta.is_symlink()) {
215 return Err(invalid(format!(
216 "{} is a symbolic link, which chest does not follow: replace it with the file itself",
217 path.display()
218 )));
219 }
220 Ok(())
221}
222
223fn discard(staged: &[Staged]) {
225 for staged in staged {
226 if let Content::Store { staging, .. } = &staged.content {
227 let _ = std::fs::remove_dir_all(staging);
228 }
229 }
230}
231
232fn real_path(path: &Path) -> PathBuf {
235 let mut rest = Vec::new();
236 let mut current = path;
237 loop {
238 if let Ok(real) = current.canonicalize() {
239 return rest.iter().rev().fold(real, |real, part| real.join(part));
240 }
241 match (current.parent(), current.file_name()) {
242 (Some(parent), Some(name)) => {
243 rest.push(name.to_owned());
244 current = parent;
245 }
246 _ => return path.to_path_buf(),
247 }
248 }
249}
250
251#[derive(Clone, Debug)]
253pub struct Project {
254 pub manifest: Manifest,
256 pub lock: Option<Lock>,
258 pub base: PathBuf,
260 pub scope: Scope,
262 pub cache: PathBuf,
265 pub state: PathBuf,
268 path: PathBuf,
269 pub home: PathBuf,
271}
272
273enum AtStartup {
275 Stale,
277 Present,
279 Missing,
281 Replay,
284}
285
286pub fn manifest_label(path: &Path, scope: Scope, home: &Path) -> String {
290 match (scope, path.strip_prefix(home)) {
291 (Scope::Global, Ok(path)) => format!("~/{}", path.display()),
292 (Scope::Global, Err(_)) => path.display().to_string(),
293 (Scope::Project, _) => path
294 .file_name()
295 .map_or_else(String::new, |name| name.to_string_lossy().into_owned()),
296 }
297}
298
299pub fn command(path: &Path, scope: Scope) -> String {
303 let name = path.file_name().unwrap_or_default().to_string_lossy();
304 match (scope, &*name) {
305 (Scope::Global, _) => "chest -g".to_owned(),
306 (Scope::Project, "chest.toml") => "chest".to_owned(),
307 (Scope::Project, name) => format!("chest --manifest {name}"),
308 }
309}
310
311pub fn install_command(path: &Path, scope: Scope) -> String {
314 format!("{} install", command(path, scope))
315}
316
317impl Project {
318 pub fn open(path: &Path, scope: Scope) -> Result<Project, ChestError> {
326 let path = std::path::absolute(path).map_err(io(path))?;
327 refuse_link(&path)?;
328 let text = std::fs::read_to_string(&path).map_err(io(&path))?;
329 let manifest = Manifest::parse(&text, scope)
330 .map_err(|err| invalid(format!("{}: {err}", path.display())))?;
331 let folder = path.parent().unwrap_or(&path);
332 let base = folder.canonicalize().map_err(io(folder))?;
333 let home = home_dir();
334 let mut project = Project {
335 manifest,
336 lock: None,
337 path: base.join(path.file_name().unwrap_or_default()),
338 base,
339 scope,
340 cache: home.join(".chest/cache"),
341 state: home.join(".chest/state"),
342 home: home.canonicalize().unwrap_or(home),
343 };
344 project.lock = project.read_lock()?;
345 Ok(project)
346 }
347
348 pub fn global() -> Result<Project, ChestError> {
350 Project::open(&home_dir().join(".chest/chest.toml"), Scope::Global)
351 }
352
353 pub fn lock_path(&self) -> PathBuf {
355 self.path.with_extension("lock")
356 }
357
358 pub fn root(&self) -> PathBuf {
361 match self.scope {
362 Scope::Project => self.base.clone(),
363 Scope::Global => self.home.clone(),
364 }
365 }
366
367 pub fn store(&self) -> PathBuf {
370 match (&self.manifest.folders.extensions, self.scope) {
371 (Some(store), _) => self.resolve(store),
372 (None, Scope::Project) => self.base.join(".yapi/chest"),
373 (None, Scope::Global) => self.home.join(".chest/store"),
374 }
375 }
376
377 fn resolve(&self, path: &str) -> PathBuf {
380 let path = match path.strip_prefix('~') {
381 Some("") => self.home.clone(),
382 Some(rest) if rest.starts_with('/') => self.home.join(&rest[1..]),
383 _ => PathBuf::from(path),
384 };
385 resolve_lexically(&self.base, &path)
386 }
387
388 pub fn too_wide(&self, folder: &Path, also: &[&Path]) -> bool {
395 let Ok(folder) = folder.canonicalize() else {
396 return false;
397 };
398 let held = [
399 self.home.clone(),
400 crate::config::agent_dir(),
401 PathBuf::from("/"),
402 self.base.clone(),
403 self.store(),
404 ];
405 held.into_iter()
406 .chain(also.iter().map(|path| path.to_path_buf()))
407 .any(|held| real_path(&held).starts_with(&folder))
408 }
409
410 fn refuse_wide(&self) -> Result<(), ChestError> {
413 for (name, entry) in &self.manifest.extensions {
414 let patch = self.manifest.patch.get(name);
415 let patch = patch.and_then(|patch| patch.path.as_deref());
416 let source = entry.path.as_deref();
417 let source = source.filter(|_| entry.git.is_none() && entry.npm.is_none());
418 let (id, path) = match (patch, source) {
419 (Some(path), _) => (format!("patch.{name}"), path),
420 (None, Some(path)) => (format!("extensions.{name}"), path),
421 (None, None) => continue,
422 };
423 let folder = self.resolve(path);
424 if self.too_wide(&folder, &[]) {
425 return Err(invalid(format!(
426 "{id}: {} holds more than the extension, such as the project, the home or the agent folder, so chest does not install it",
427 folder.display()
428 )));
429 }
430 }
431 Ok(())
432 }
433
434 fn folders(&self, section: Section) -> Result<Vec<PathBuf>, ChestError> {
438 let folders = match section {
439 Section::Skills => &self.manifest.folders.skills,
440 _ => &self.manifest.folders.agents,
441 };
442 if folders.is_empty() {
443 let key = section.key();
444 return Err(invalid(format!(
445 "Set `{key}` in [folders] to say where {key} go"
446 )));
447 }
448 let mut real: Vec<PathBuf> = Vec::new();
449 for folder in folders {
450 let path = real_path(&self.resolve(folder));
451 if !real.contains(&path) {
452 real.push(path);
453 }
454 }
455 Ok(real)
456 }
457
458 fn read_lock(&self) -> Result<Option<Lock>, ChestError> {
459 let path = self.lock_path();
460 refuse_link(&path)?;
461 match std::fs::read_to_string(&path) {
462 Ok(text) => Lock::parse(&text).map(Some).map_err(|err| {
463 let label = manifest_label(&path, self.scope, &self.home);
464 invalid(format!("{label}: {err}"))
465 }),
466 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
467 Err(err) => Err(io(&path)(err)),
468 }
469 }
470
471 fn file_name(&self) -> String {
472 self.path
473 .file_name()
474 .map_or_else(String::new, |name| name.to_string_lossy().into_owned())
475 }
476
477 fn label(&self) -> String {
479 manifest_label(&self.path, self.scope, &self.home)
480 }
481
482 fn command(&self) -> String {
485 command(&self.path, self.scope)
486 }
487
488 fn install_command(&self) -> String {
490 install_command(&self.path, self.scope)
491 }
492
493 fn entries(&self) -> impl Iterator<Item = (Section, &String, &Entry)> {
495 self.manifest.entries()
496 }
497
498 fn homes(&self, section: Section) -> Vec<PathBuf> {
501 match section {
502 Section::Extensions => vec![self.store()],
503 _ => self.folders(section).unwrap_or_default(),
504 }
505 }
506
507 pub(crate) fn locked_items(
512 &self,
513 section: Section,
514 locked: &Locked,
515 ) -> Vec<(String, Option<String>)> {
516 if section == Section::Extensions {
517 let stored = locked.git.is_some() || locked.npm.is_some();
518 return stored
519 .then(|| (locked.name.clone(), locked.hash.clone()))
520 .into_iter()
521 .collect();
522 }
523 let path_source = match (&locked.git, &locked.npm) {
524 (None, None) => locked.path.as_deref().map(|path| self.resolve(path)),
525 _ => None,
526 };
527 if let Some(package) = path_source.filter(|source| fetch::is_package(section, source)) {
528 let items = fetch::items(&package, section, None, &locked.name);
529 let items = items.map(|(items, _)| items).unwrap_or_default();
530 return items.into_iter().map(|(name, _)| (name, None)).collect();
531 }
532 if locked.items.is_empty() {
533 return vec![(
534 fetch::placed_name(section, &locked.name),
535 locked.hash.clone(),
536 )];
537 }
538 let items = locked.items.iter();
539 items
540 .map(|item| (item.name.clone(), Some(item.hash.clone())))
541 .collect()
542 }
543
544 pub fn placed(&self, section: Section, locked: &Locked) -> Vec<PathBuf> {
547 let homes = self.homes(section);
548 let items = self.locked_items(section, locked);
549 items
550 .iter()
551 .flat_map(|(name, _)| homes.iter().map(move |home| home.join(name)))
552 .collect()
553 }
554
555 fn pinned(&self, lock: &Lock) -> HashMap<PathBuf, String> {
557 let mut pinned = HashMap::new();
558 for section in [Section::Skills, Section::Agents, Section::Extensions] {
559 let homes = self.homes(section);
560 for locked in lock.section(section) {
561 for (name, hash) in self.locked_items(section, locked) {
562 let Some(hash) = hash else { continue };
563 for home in &homes {
564 pinned.insert(home.join(&name), hash.clone());
565 }
566 }
567 }
568 }
569 pinned
570 }
571
572 fn recorded(&self, section: Section, locked: &Locked, record: &Record) -> bool {
576 let homes = self.homes(section);
577 let items = self.locked_items(section, locked);
578 items.iter().all(|(name, hash)| {
579 let holds = |hash: &String| {
580 homes
581 .iter()
582 .all(|home| record.holds(&home.join(name), hash))
583 };
584 hash.as_ref().is_some_and(holds)
585 })
586 }
587
588 fn record(&self) -> Record {
591 let path = Record::path(&self.state, &self.root(), self.scope);
592 Record::load(&path, &self.root(), &self.file_name())
593 }
594
595 fn holds_locked(
600 &self,
601 section: Section,
602 entry: &Entry,
603 locked: &Locked,
604 record: &Record,
605 ) -> bool {
606 entry.git.is_none() && entry.npm.is_none() || self.recorded(section, locked, record)
607 }
608
609 fn holds_staged(&self, section: Section, content: &Content, record: &Record) -> bool {
613 let items = match content {
614 Content::Items(items) => items,
615 Content::Nothing => return true,
616 Content::Store { .. } => return false,
617 };
618 let homes = self.homes(section);
619 items.iter().all(|(item, _, hash)| {
620 homes.iter().all(|home| {
621 let path = home.join(item);
622 record.holds(&path, hash) && record.held(&path) == Held::Owned
623 })
624 })
625 }
626
627 fn changed(
632 &self,
633 section: Section,
634 locked: &Locked,
635 record: &Record,
636 ) -> Vec<(PathBuf, Problem)> {
637 let placed = self.placed(section, locked).into_iter();
638 let changed = placed.filter(|path| record.held(path) == Held::Modified);
639 changed
640 .filter_map(|path| match path.symlink_metadata() {
641 Ok(_) => Some((path, Problem::Modified)),
642 Err(_) => (section != Section::Extensions).then_some((path, Problem::Missing)),
643 })
644 .collect()
645 }
646
647 pub fn state(&self, section: Section, name: &str, entry: &Entry) -> &'static str {
657 if self.no_source(name, entry) {
658 return "no source";
659 }
660 let patched = self.manifest.patch.contains_key(name);
661 let locked = self.lock.as_ref().and_then(|lock| {
662 lock.section(section)
663 .iter()
664 .find(|locked| locked.name == name)
665 .filter(|locked| fresh(entry, locked))
666 });
667 let record = self.record();
668 let changed = |locked: &Locked, problem: Problem| {
669 let changed = self.changed(section, locked, &record);
670 changed.iter().any(|(_, found)| *found == problem)
671 };
672 match locked {
673 Some(locked) if patched && changed(locked, Problem::Missing) => "missing",
674 Some(locked) if patched && changed(locked, Problem::Modified) => "edited",
675 _ if patched => "patched",
676 None => "stale",
677 Some(locked) if !self.present(section, locked) => "missing",
678 Some(locked) if changed(locked, Problem::Modified) => "edited",
679 Some(locked) if !self.holds_locked(section, entry, locked, &record) => "outdated",
680 Some(_) => "installed",
681 }
682 }
683
684 fn no_source(&self, name: &str, entry: &Entry) -> bool {
687 let path_source = match (&entry.git, &entry.npm) {
688 (None, None) => entry.path.as_deref(),
689 _ => None,
690 };
691 let patched = self.manifest.patch.contains_key(name);
692 !patched && path_source.is_some_and(|path| !self.resolve(path).exists())
693 }
694
695 pub fn present(&self, section: Section, locked: &Locked) -> bool {
700 let placed = self.placed(section, locked);
701 let in_place = section == Section::Extensions && placed.is_empty();
702 let first = self.homes(section).into_iter().next();
703 let there = |path: &PathBuf| match path.symlink_metadata() {
704 Ok(metadata) => !metadata.is_symlink() || path.parent() != first.as_deref(),
705 Err(_) => false,
706 };
707 in_place || !placed.is_empty() && placed.iter().all(there)
708 }
709
710 pub async fn sync(
719 &mut self,
720 mode: Mode,
721 npm: &mut Npm,
722 progress: &mut (dyn FnMut(&str) + Send),
723 ) -> Result<Report, ChestError> {
724 let mut report = Report::default();
725 if !matches!(mode, Mode::Startup { .. }) {
728 self.refuse_wide()?;
729 self.path_notices(&mut report);
730 }
731 let frozen = mode == Mode::Frozen;
732 if frozen {
735 self.lock = self.read_lock()?;
736 if !self.check_frozen(&mut report)? {
737 return Ok(report);
738 }
739 }
740 if let Mode::Startup { offline } = mode
742 && self.startup_without_lock(offline, &mut report)?
743 {
744 return Ok(report);
745 }
746 let lock_path = self.lock_path();
747 let mut waiting = || progress(&waiting_for(&lock_path));
748 let _held = hold_lock(&lock_path, &mut waiting).await?;
749 self.lock = self.read_lock()?;
750 if frozen && !self.check_frozen(&mut report)? {
752 return Ok(report);
753 }
754 let startup = matches!(mode, Mode::Startup { .. });
755 let old = match (&self.lock, startup) {
756 (Some(lock), _) => lock.clone(),
757 (None, false) => Lock::default(),
758 (None, true) => {
759 report.warnings.push(format!(
760 "{} has no lock, so nothing from it was installed: run `{}`",
761 self.label(),
762 self.install_command()
763 ));
764 return Ok(report);
765 }
766 };
767 let record_path = Record::path(&self.state, &self.root(), self.scope);
770 let state = record_path.parent().unwrap_or(&record_path);
771 std::fs::create_dir_all(state).map_err(io(state))?;
772 let mut waiting = || progress(&waiting_for(&record_path));
773 let _record_held = hold_lock(&record_path, &mut waiting).await?;
774 self.prepare_folders()?;
775 let mut record = Record::load(&record_path, &self.root(), &self.file_name());
776 let (staged, _work) = self
778 .prepare(&mode, &old, &record, npm, progress, &mut report)
779 .await?;
780 let committed = self.commit(
781 &staged,
782 &old,
783 frozen,
784 &mut record,
785 &record_path,
786 &mut report,
787 );
788 let occupied = match committed {
789 Ok(occupied) => occupied,
790 Err(err) => {
791 discard(&staged);
792 return Err(err);
793 }
794 };
795 if startup {
796 return Ok(report);
797 }
798 let mut lock = Lock::default();
799 for staged in staged {
800 let Some(record) = staged.record else {
801 continue;
802 };
803 match staged.section {
804 Section::Skills => lock.skills.push(record),
805 Section::Agents => lock.agents.push(record),
806 _ => lock.extensions.push(record),
807 }
808 }
809 let kept = self.held_back(&old, &occupied, &record, &mut report);
810 self.remove_dropped(&old, &kept, &mut record, &record_path, &mut report)?;
811 let text = lock.to_toml()?;
812 if !frozen && std::fs::read_to_string(&lock_path).ok().as_deref() != Some(text.as_str()) {
814 write_atomic(&lock_path, text.as_bytes(), 0o666).map_err(io(&lock_path))?;
815 }
816 if self.scope == Scope::Project {
817 self.write_ignore(&occupied)?;
818 }
819 self.lock = Some(lock);
820 Ok(report)
821 }
822
823 fn prepare_folders(&self) -> Result<(), ChestError> {
829 let root = self.root();
830 let mut homes: Vec<PathBuf> = Vec::new();
831 for section in [Section::Skills, Section::Agents] {
832 let configured = match section {
833 Section::Skills => &self.manifest.folders.skills,
834 _ => &self.manifest.folders.agents,
835 };
836 if !self.manifest.section(section).is_empty() {
837 for folder in configured {
838 let path = self.resolve(folder);
839 place::within(&root, &path.join("item"))?;
841 if path.symlink_metadata().is_err() {
843 std::fs::create_dir_all(&path).map_err(io(&path))?;
844 }
845 }
846 }
847 let mut reals: Vec<PathBuf> = Vec::new();
848 for folder in configured {
849 let real = real_path(&self.resolve(folder));
850 if !reals.contains(&real) {
851 reals.push(real);
852 }
853 }
854 homes.extend(reals);
855 }
856 homes.push(real_path(&self.store()));
857 for (index, first) in homes.iter().enumerate() {
858 for second in &homes[index + 1..] {
859 if first.starts_with(second) || second.starts_with(first) {
860 return Err(invalid(format!(
861 "folders: {} and {} overlap",
862 first.display(),
863 second.display()
864 )));
865 }
866 }
867 }
868 for home in &homes {
869 if place::within(&root, &home.join("item")).is_err() {
871 continue;
872 }
873 for entry in std::fs::read_dir(home).into_iter().flatten().flatten() {
874 let name = entry.file_name().to_string_lossy().into_owned();
875 let stopped = name.ends_with("-staging") || name.ends_with("-old");
876 if name.starts_with('.') && stopped {
877 let path = entry.path();
878 npm::remove_path(&path).map_err(io(&path))?;
879 }
880 }
881 }
882 Ok(())
883 }
884
885 async fn prepare(
889 &self,
890 mode: &Mode,
891 old: &Lock,
892 record: &Record,
893 npm: &mut Npm,
894 progress: &mut (dyn FnMut(&str) + Send),
895 report: &mut Report,
896 ) -> Result<(Vec<Staged>, fetch::Work), ChestError> {
897 if let Mode::Update(names) = mode
898 && let Some(unknown) = names
899 .iter()
900 .find(|name| self.entries().all(|(_, entry, _)| entry != *name))
901 {
902 return Err(invalid(format!("No entry is named {unknown}")));
903 }
904 let mut work = fetch::Work::new(&self.cache)?;
905 let mut staged: Vec<Staged> = Vec::new();
906 let mut stale = Vec::new();
907 for (section, name, entry) in self.entries() {
908 let locked = old
909 .section(section)
910 .iter()
911 .find(|locked| &locked.name == name);
912 let target = Target {
913 id: format!("{}.{name}", section.key()),
914 section,
915 name,
916 entry,
917 patch: self.manifest.patch.get(name),
918 locked,
919 };
920 let fresh = locked.is_some_and(|locked| fresh(entry, locked));
921 let present = locked.is_some_and(|locked| self.present(section, locked));
922 let recorded = locked.is_some_and(|locked| self.recorded(section, locked, record));
923 let local = target.patch.is_some() || entry.git.is_none() && entry.npm.is_none();
924 let named = matches!(mode, Mode::Update(names)
925 if names.is_empty() || names.iter().any(|n| n == name));
926 let matches = |locked: &Locked| self.copy_problems(section, locked).is_empty();
929 let keep = match mode {
930 Mode::Startup { offline } => {
931 match self.at_startup(section, name, entry, locked, record, *offline) {
932 AtStartup::Stale => {
933 stale.push(target.id.clone());
934 Some(Outcome::Skipped)
935 }
936 AtStartup::Present => Some(Outcome::Unchanged),
937 AtStartup::Missing => Some(Outcome::Skipped),
938 AtStartup::Replay => None,
939 }
940 }
941 Mode::Frozen
942 if present
943 && (recorded || locked.is_some_and(matches))
944 && !self.no_source(name, entry) =>
945 {
946 Some(Outcome::Unchanged)
947 }
948 mode if *mode != Mode::Frozen && self.no_source(name, entry) => {
950 let path = entry.path.as_deref().unwrap_or_default();
951 report.warnings.push(format!(
952 "{}: {} does not exist, so chest skipped it: restore it or remove the entry",
953 target.id,
954 self.resolve(path).display()
955 ));
956 Some(Outcome::Skipped)
957 }
958 _ if fresh && present && recorded && !local && !named => {
959 let problems =
960 locked.map_or_else(Vec::new, |locked| self.copy_problems(section, locked));
961 for problem in &problems {
962 report.warnings.push(format!(
963 "{}, so chest left it as it is: delete it and run `{}` to put back the locked copy",
964 describe(problem),
965 self.install_command()
966 ));
967 }
968 Some(match problems.is_empty() {
969 true => Outcome::Unchanged,
970 false => Outcome::Edited,
971 })
972 }
973 _ => None,
974 };
975 if let Some(outcome) = keep {
976 staged.push(Staged {
977 id: target.id,
978 section,
979 record: locked.cloned(),
980 content: Content::Nothing,
981 outcome,
982 warnings: Vec::new(),
983 });
984 continue;
985 }
986 let resolve = !fresh || named;
988 progress(&format!("Installing {}...", target.id));
989 match self.stage(&target, resolve, npm, &mut work).await {
990 Ok(mut next) => {
991 let id = &next.id;
992 let warnings = next.warnings.drain(..);
993 report
994 .warnings
995 .extend(warnings.map(|warning| format!("{id}: {warning}")));
996 let same = next.record.as_ref() == locked && present && target.patch.is_none();
1001 let held = match local {
1002 true => self.holds_staged(section, &next.content, record),
1003 false => recorded && locked.is_some_and(matches),
1004 };
1005 if same && held {
1006 discard(std::slice::from_ref(&next));
1007 next.content = Content::Nothing;
1008 next.outcome = Outcome::Unchanged;
1009 }
1010 staged.push(next);
1011 }
1012 Err(err) => {
1013 discard(&staged);
1014 return Err(match err {
1015 ChestError::Invalid(message) => {
1016 invalid(format!("{}: {message}", target.id))
1017 }
1018 err => ChestError::Failed(format!("{}: {err}", target.id)),
1019 });
1020 }
1021 }
1022 }
1023 if !stale.is_empty() {
1024 report.warnings.push(self.stale_warning(&stale));
1025 }
1026 Ok((staged, work))
1027 }
1028
1029 async fn stage(
1032 &self,
1033 target: &Target<'_>,
1034 resolve: bool,
1035 npm: &mut Npm,
1036 work: &mut fetch::Work,
1037 ) -> Result<Staged, ChestError> {
1038 let Target {
1039 section,
1040 name,
1041 entry,
1042 locked,
1043 ..
1044 } = *target;
1045 let staged = |record, content, outcome, warnings| Staged {
1046 id: target.id.clone(),
1047 section,
1048 record,
1049 content,
1050 outcome,
1051 warnings,
1052 };
1053 let hashed = |items: Vec<(String, PathBuf)>| -> Result<Vec<_>, ChestError> {
1054 let hash = |(name, source): (String, PathBuf)| {
1055 tree_hash(&source).map(|hash| (name, source, hash))
1056 };
1057 items.into_iter().map(hash).collect()
1058 };
1059 let path_source = match (&entry.git, &entry.npm) {
1060 (None, None) => entry.path.as_deref(),
1061 _ => None,
1062 };
1063 if let Some(path) = target
1064 .patch
1065 .and_then(|patch| patch.path.as_deref())
1066 .or(path_source)
1067 {
1068 let record = match target.patch {
1070 Some(_) => locked.cloned(),
1071 None => Some(Locked {
1072 name: name.to_owned(),
1073 path: entry.path.clone(),
1074 ..Locked::default()
1075 }),
1076 };
1077 let source = self.resolve(path);
1078 if !source.exists() {
1079 return Err(invalid(format!("{} does not exist", source.display())));
1080 }
1081 if section == Section::Extensions {
1082 let outcome = match resolve {
1083 true => Outcome::Installed,
1084 false => Outcome::Unchanged,
1085 };
1086 return Ok(staged(record, Content::Nothing, outcome, Vec::new()));
1087 }
1088 let patched_package =
1091 target.patch.is_some() && path_source.is_none() && entry.path.is_none();
1092 let path_package = target.patch.is_none() && fetch::is_package(section, &source);
1093 let (items, warnings) = if patched_package || path_package {
1094 fetch::items(&source, section, None, name)?
1095 } else {
1096 fetch::check_item(section, &source, &source)?;
1097 let item = (fetch::placed_name(section, name), source);
1098 (vec![item], Vec::new())
1099 };
1100 let sections = [Section::Skills, Section::Agents].into_iter();
1102 let mut installs: Vec<PathBuf> = sections.flat_map(|s| self.homes(s)).collect();
1103 installs.push(real_path(&self.store()));
1104 for (_, item) in &items {
1105 let real = real_path(item);
1106 if let Some(home) = installs.iter().find(|home| home.starts_with(&real)) {
1107 return Err(invalid(format!(
1108 "{} holds {}, where chest installs, so it cannot be installed",
1109 item.display(),
1110 home.display()
1111 )));
1112 }
1113 }
1114 let content = Content::Items(hashed(items)?);
1115 return Ok(staged(record, content, Outcome::Installed, warnings));
1116 }
1117 let replay = locked.filter(|_| !resolve);
1118 let mut record = Locked {
1119 name: name.to_owned(),
1120 git: entry.git.clone(),
1121 rev: entry.rev.clone(),
1122 npm: entry.npm.clone(),
1123 path: entry.path.clone(),
1124 ..Locked::default()
1125 };
1126 let package = work.path();
1127 if let Some(repo) = &entry.git {
1128 let commit = match replay {
1129 Some(locked) => locked.commit.clone().unwrap_or_default(),
1130 None => fetch::resolve(&self.cache, repo, entry.rev.as_deref()).await?,
1131 };
1132 fetch::checkout(&self.cache, repo, &commit, &package).await?;
1133 record.commit = Some(commit);
1134 }
1135 let mut warnings = Vec::new();
1136 let content = if section == Section::Extensions {
1137 let dir = self.store().join(name);
1138 place::within(&self.root(), &dir)?;
1139 let staging = npm::stage(&dir)?;
1140 let built = self
1141 .build_extension(entry, replay, &package, &staging, npm, &mut record)
1142 .await;
1143 if let Err(err) = built {
1144 let _ = std::fs::remove_dir_all(&staging);
1145 return Err(err);
1146 }
1147 let hash = record.hash.clone().unwrap_or_default();
1148 Content::Store { staging, dir, hash }
1149 } else {
1150 if let Some(npm_name) = &entry.npm {
1151 let pin = match replay {
1152 Some(locked) => {
1153 let pin = Pinned {
1154 path: String::new(),
1155 name: npm_name.clone(),
1156 version: locked.version.clone().unwrap_or_default(),
1157 tarball: locked.tarball.clone().unwrap_or_default(),
1158 integrity: locked.integrity.clone().unwrap_or_default(),
1159 };
1160 npm::fetch_pinned(npm, &pin, &package).await?;
1161 pin
1162 }
1163 None => {
1164 let pin =
1165 npm::fetch(npm, npm_name, entry.version.as_deref(), &package).await?;
1166 npm.check_pin(&pin)?;
1168 pin
1169 }
1170 };
1171 record.version = Some(pin.version);
1172 record.tarball = Some(pin.tarball);
1173 record.integrity = Some(pin.integrity);
1174 }
1175 let (items, skipped) = fetch::items(&package, section, entry.path.as_deref(), name)?;
1176 warnings = skipped;
1177 let items = hashed(items)?;
1178 if entry.path.is_some() {
1179 record.hash = Some(items[0].2.clone());
1180 } else {
1181 for (item, _, hash) in &items {
1182 record.items.push(lock::Item {
1183 name: item.clone(),
1184 hash: hash.clone(),
1185 });
1186 }
1187 }
1188 Content::Items(items)
1189 };
1190 let Some(locked) = replay else {
1191 return Ok(staged(Some(record), content, Outcome::Installed, warnings));
1192 };
1193 if record.hash != locked.hash || record.items != locked.items {
1194 if let Content::Store { staging, .. } = &content {
1195 let _ = std::fs::remove_dir_all(staging);
1196 }
1197 return Err(invalid(format!(
1198 "what was fetched does not match the lock: run `{} update` to take it",
1199 self.command()
1200 )));
1201 }
1202 Ok(staged(
1203 Some(locked.clone()),
1204 content,
1205 Outcome::Replayed,
1206 warnings,
1207 ))
1208 }
1209
1210 async fn build_extension(
1214 &self,
1215 entry: &Entry,
1216 replay: Option<&Locked>,
1217 package: &Path,
1218 staging: &Path,
1219 npm: &mut Npm,
1220 record: &mut Locked,
1221 ) -> Result<(), ChestError> {
1222 match (&entry.npm, replay) {
1223 (_, Some(locked)) => {
1224 if let Some(npm_name) = &entry.npm {
1227 let root = format!("node_modules/{npm_name}");
1228 let version = locked.version.as_deref().unwrap_or_default();
1229 let pinned = locked.packages.iter().any(|pin| {
1230 pin.path == root && pin.name == *npm_name && pin.version == version
1231 });
1232 if !pinned {
1233 return Err(invalid(format!(
1234 "the lock does not pin {npm_name}@{version} at {root}"
1235 )));
1236 }
1237 }
1238 if entry.git.is_some() {
1239 copy_checkout(package, entry.path.as_deref(), staging)?;
1240 }
1241 let root = staging.join("package.json");
1244 if let Some(npm_name) = &entry.npm {
1245 let version = locked.version.as_deref().unwrap_or_default();
1246 let manifest = serde_json::json!({"dependencies": {npm_name: version}});
1247 let text = yapi_types::json::to_string(&manifest).unwrap_or_default();
1248 std::fs::write(&root, text).map_err(io(&root))?;
1249 }
1250 npm::replay(npm, staging, &locked.packages).await?;
1251 if entry.npm.is_some() {
1252 std::fs::remove_file(&root).map_err(io(&root))?;
1253 }
1254 }
1255 (Some(npm_name), None) => {
1256 let installed =
1257 npm::install(npm, staging, npm_name, entry.version.as_deref()).await?;
1258 npm::check_installed(staging, &installed.tree)?;
1261 for file in ["package.json", ".gitignore"] {
1263 let file = staging.join(file);
1264 std::fs::remove_file(&file).map_err(io(&file))?;
1265 }
1266 record.version = Some(installed.version);
1267 record.packages = installed.tree;
1268 }
1269 (None, None) => {
1270 copy_checkout(package, entry.path.as_deref(), staging)?;
1271 record.packages = npm::install_dependencies(npm, staging).await?;
1272 npm::check_installed(staging, &record.packages)?;
1273 }
1274 }
1275 for pin in &record.packages {
1278 npm.check_pin(pin)?;
1279 }
1280 record.hash = Some(tree_hash(staging)?);
1281 Ok(())
1282 }
1283
1284 fn commit(
1296 &self,
1297 staged: &[Staged],
1298 old: &Lock,
1299 frozen: bool,
1300 record: &mut Record,
1301 record_path: &Path,
1302 report: &mut Report,
1303 ) -> Result<Vec<PathBuf>, ChestError> {
1304 let root = self.root();
1305 let pinned = self.pinned(old);
1306 let mut occupied: HashSet<PathBuf> = HashSet::new();
1307 for staged in staged {
1308 if let (Content::Nothing, Some(locked)) = (&staged.content, &staged.record) {
1309 occupied.extend(self.placed(staged.section, locked));
1310 }
1311 }
1312 let mut placements: Vec<Vec<(PathBuf, Vec<PathBuf>, String)>> = Vec::new();
1314 let mut outcomes = Vec::new();
1315 for staged in staged {
1316 let planned = match &staged.content {
1317 Content::Items(items) => {
1318 let folders = self.folders(staged.section)?;
1319 let plan = |(name, source, hash): &(String, PathBuf, String)| {
1320 let targets = folders.iter().map(|folder| folder.join(name)).collect();
1321 (source.clone(), targets, hash.clone())
1322 };
1323 items.iter().map(plan).collect()
1324 }
1325 Content::Store { staging, dir, hash } => {
1326 vec![(staging.clone(), vec![dir.clone()], hash.clone())]
1327 }
1328 Content::Nothing => Vec::new(),
1329 };
1330 let mut placing = Vec::new();
1331 let mut left = false;
1332 for (source, targets, hash) in planned {
1333 let mut edited = false;
1334 for target in &targets {
1335 let refuse =
1336 |why: &str| invalid(format!("{}: {why} {}", staged.id, target.display()));
1337 place::within(&root, target)
1338 .map_err(|err| invalid(format!("{}: {err}", staged.id)))?;
1339 if !occupied.insert(target.clone()) {
1340 return Err(refuse("another entry also installs"));
1341 }
1342 if target.symlink_metadata().is_err() {
1343 continue;
1344 }
1345 match record.held(target) {
1346 Held::Owned => {}
1347 Held::Modified => {
1348 let edit = format!(
1349 "{}: {} was modified since chest installed it",
1350 staged.id,
1351 target.display()
1352 );
1353 let how = format!(
1354 "delete it and run `{}` to put back the locked copy",
1355 self.install_command()
1356 );
1357 if frozen {
1358 return Err(invalid(format!("{edit}: {how}")));
1359 }
1360 let mut warning = format!("{edit}, so chest left it as it is: {how}");
1361 if staged.section == Section::Extensions && !record.holds(target, &hash)
1363 {
1364 warning.push_str(", and until then yapi does not load it");
1365 }
1366 report.warnings.push(warning);
1367 edited = true;
1368 }
1369 Held::Unrecorded => {
1370 let lock_pins = pinned.get(target);
1371 let adoptable = lock_pins == Some(&hash)
1372 && content_hash(target).as_ref() == lock_pins
1373 && !has_git(target);
1374 if !adoptable {
1375 return Err(refuse(
1376 "refusing to replace what chest did not install at",
1377 ));
1378 }
1379 record.adopt(target, &hash);
1380 }
1381 }
1382 }
1383 if edited {
1385 left = true;
1386 } else {
1387 placing.push((source, targets, hash));
1388 }
1389 }
1390 let outcome = match left {
1391 true => Outcome::Edited,
1392 false => staged.outcome,
1393 };
1394 if left {
1395 discard(std::slice::from_ref(staged));
1396 }
1397 outcomes.push(outcome);
1398 placements.push(placing);
1399 }
1400 record.save(record_path)?;
1401 let entries = staged.iter().zip(placements).zip(outcomes);
1402 for ((staged, planned), outcome) in entries {
1403 for (source, targets, hash) in planned {
1404 let before: Vec<_> = targets.iter().map(|target| record.get(target)).collect();
1405 for target in &targets {
1407 record.expect(target, &hash);
1408 }
1409 record.save(record_path)?;
1410 let placed = match &staged.content {
1411 Content::Store { dir, .. } => npm::swap(&source, dir, Ok::<(), ChestError>(())),
1412 _ => {
1413 let folders = self.folders(staged.section)?;
1414 let name = targets[0].file_name().unwrap_or_default().to_string_lossy();
1415 place::place(&source, &root, &folders, &name).map(drop)
1416 }
1417 };
1418 for (target, before) in targets.iter().zip(before) {
1421 if placed.is_ok() || content_hash(target).as_ref() == Some(&hash) {
1422 record.noted(target, &hash);
1423 } else {
1424 record.restore(target, before);
1425 }
1426 }
1427 record.save(record_path)?;
1428 placed?;
1429 }
1430 report.entries.push((staged.id.clone(), outcome));
1431 }
1432 Ok(occupied.into_iter().collect())
1433 }
1434
1435 fn held_back(
1441 &self,
1442 old: &Lock,
1443 occupied: &[PathBuf],
1444 record: &Record,
1445 report: &mut Report,
1446 ) -> Vec<PathBuf> {
1447 let recorded = record.paths();
1448 let mut kept = occupied.to_vec();
1449 for (section, name, entry) in self.entries() {
1450 if section == Section::Extensions || !self.no_source(name, entry) {
1451 continue;
1452 }
1453 let locked = old
1454 .section(section)
1455 .iter()
1456 .find(|locked| &locked.name == name);
1457 let Some(locked) = locked else { continue };
1458 if self
1460 .placed(section, locked)
1461 .iter()
1462 .any(|path| recorded.contains(path))
1463 {
1464 continue;
1465 }
1466 let homes = self.homes(section);
1467 let held: Vec<&PathBuf> = recorded
1468 .iter()
1469 .filter(|path| {
1470 path.parent()
1471 .is_some_and(|parent| homes.iter().any(|home| home == parent))
1472 })
1473 .filter(|path| !kept.contains(path))
1474 .collect();
1475 if held.is_empty() {
1476 continue;
1477 }
1478 let list: Vec<String> = held.iter().map(|path| path.display().to_string()).collect();
1479 report.warnings.push(format!(
1480 "{}.{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",
1481 section.key(),
1482 list.join(", ")
1483 ));
1484 kept.extend(held.into_iter().cloned());
1485 }
1486 kept
1487 }
1488
1489 fn remove_dropped(
1497 &self,
1498 old: &Lock,
1499 occupied: &[PathBuf],
1500 record: &mut Record,
1501 record_path: &Path,
1502 report: &mut Report,
1503 ) -> Result<(), ChestError> {
1504 let real = |path: &Path| match (path.parent(), path.file_name()) {
1506 (Some(parent), Some(name)) => real_path(parent).join(name),
1507 _ => path.to_path_buf(),
1508 };
1509 let really: HashSet<PathBuf> = occupied.iter().map(|path| real(path)).collect();
1510 let occupied: HashSet<&PathBuf> = occupied.iter().collect();
1511 for path in record.paths() {
1512 if occupied.contains(&path) {
1513 continue;
1514 }
1515 if record.shared(&path) || really.contains(&real(&path)) {
1516 record.release(&path);
1517 record.save(record_path)?;
1518 continue;
1519 }
1520 if path.symlink_metadata().is_ok() {
1521 match record.held(&path) {
1522 Held::Modified => {
1523 report.warnings.push(format!(
1524 "{} was modified since chest installed it, so chest left it as it is: delete it, or undo the edit and run `{}` to remove it",
1525 path.display(),
1526 self.install_command()
1527 ));
1528 continue;
1529 }
1530 _ => {
1531 if let Err(err) = place::unplace(&self.root(), std::slice::from_ref(&path))
1532 {
1533 report.warnings.push(format!(
1534 "chest could not remove {} and forgets it: {err}",
1535 path.display()
1536 ));
1537 }
1538 }
1539 }
1540 }
1541 record.forget(&path);
1542 record.save(record_path)?;
1543 }
1544 for section in [Section::Skills, Section::Agents, Section::Extensions] {
1545 for locked in old.section(section) {
1546 if !self.manifest.section(section).contains_key(&locked.name) {
1547 let id = format!("{}.{}", section.key(), locked.name);
1548 report.entries.push((id, Outcome::Removed));
1549 }
1550 }
1551 }
1552 Ok(())
1553 }
1554
1555 fn write_ignore(&self, occupied: &[PathBuf]) -> Result<(), ChestError> {
1559 let root = self.root();
1560 let relative = |path: &Path| -> Result<String, ChestError> {
1561 let relative = path.strip_prefix(&root).map_err(|_| {
1562 invalid(format!("{} is outside {}", path.display(), root.display()))
1563 })?;
1564 Ok(slashed(relative))
1565 };
1566 let mut entries = Vec::new();
1567 for path in occupied {
1568 entries.push(relative(path)?);
1569 }
1570 let first = |section| self.folders(section).ok()?.into_iter().next();
1571 let copies = [
1572 first(Section::Skills),
1573 first(Section::Agents),
1574 Some(self.store()),
1575 ];
1576 for home in copies.into_iter().flatten() {
1577 if occupied
1578 .iter()
1579 .any(|path| path.parent() == Some(home.as_path()))
1580 {
1581 let home = relative(&home)?;
1582 entries.push(format!("{home}/.*-staging"));
1583 entries.push(format!("{home}/.*-old"));
1584 }
1585 }
1586 place::write_ignore(&root, &self.file_name(), &entries)
1587 }
1588
1589 fn path_notices(&self, report: &mut Report) {
1591 if self.scope != Scope::Project {
1592 return;
1593 }
1594 let entries = self
1595 .entries()
1596 .map(|(section, name, entry)| (format!("{}.{name}", section.key()), entry));
1597 let patches = self
1598 .manifest
1599 .patch
1600 .iter()
1601 .map(|(name, entry)| (format!("patch.{name}"), entry));
1602 for (id, entry) in entries.chain(patches) {
1603 let source = entry
1604 .path
1605 .as_deref()
1606 .filter(|_| entry.git.is_none() && entry.npm.is_none());
1607 let Some(path) = source else { continue };
1608 let resolved = self.resolve(path);
1609 if !resolved.starts_with(&self.base) {
1610 report.notices.push(format!(
1611 "{id}: `{path}` is outside the project, at {}",
1612 resolved.display()
1613 ));
1614 }
1615 }
1616 }
1617
1618 fn check_frozen(&self, report: &mut Report) -> Result<bool, ChestError> {
1625 let (name, install) = (self.label(), self.install_command());
1626 let lock = self
1627 .lock
1628 .as_ref()
1629 .ok_or_else(|| invalid(format!("{name} has no lock: run `{install}`")))?;
1630 let drift = lock.drift(&self.manifest);
1631 if !drift.is_empty() {
1632 return Err(invalid(format!(
1633 "The lock of {name} is out of date for {}: run `{install}`",
1634 drift.join(", ")
1635 )));
1636 }
1637 let problems = self.problems(lock);
1638 let record = self.record();
1639 let (outdated, wrong): (Vec<_>, Vec<_>) = problems
1640 .iter()
1641 .filter(|(_, _, problem)| !matches!(problem, Problem::Missing | Problem::NoSource))
1642 .partition(|(_, path, problem)| {
1643 *problem == Problem::Mismatch && record.held(path) == Held::Owned
1644 });
1645 if !wrong.is_empty() {
1646 let wrong: Vec<String> = wrong.into_iter().map(describe).collect();
1647 return Err(invalid(wrong.join("\n")));
1648 }
1649 let missing = !outdated.is_empty()
1651 || self.entries().any(|(section, entry_name, entry)| {
1652 let locked = lock.section(section).iter();
1653 let mut found = locked.filter(|locked| &locked.name == entry_name);
1654 self.no_source(entry_name, entry)
1655 || found.any(|locked| !self.present(section, locked))
1656 });
1657 if !missing {
1658 for (section, entry_name, _) in self.entries() {
1659 let id = format!("{}.{entry_name}", section.key());
1660 report.entries.push((id, Outcome::Unchanged));
1661 }
1662 }
1663 Ok(missing)
1664 }
1665
1666 fn problems(&self, lock: &Lock) -> Vec<(String, PathBuf, Problem)> {
1672 let record = self.record();
1673 let mut problems = Vec::new();
1674 for section in [Section::Skills, Section::Agents, Section::Extensions] {
1675 for locked in lock.section(section) {
1676 let copies = self.copy_problems(section, locked);
1677 let path_entry = locked.git.is_none() && locked.npm.is_none();
1678 let (mut edited, mut gone) = (Vec::new(), None);
1679 let patched = self.manifest.patch.contains_key(&locked.name);
1680 if path_entry || patched {
1681 let id = format!("{}.{}", section.key(), locked.name);
1682 let source = locked.path.as_deref().filter(|_| path_entry && !patched);
1684 gone = source
1685 .map(|path| self.resolve(path))
1686 .filter(|source| !source.exists())
1687 .map(|source| (id.clone(), source, Problem::NoSource));
1688 let reported = |path: &PathBuf| copies.iter().any(|(_, at, _)| at == path);
1690 edited = self
1691 .changed(section, locked, &record)
1692 .into_iter()
1693 .filter(|(path, _)| !reported(path))
1694 .map(|(path, problem)| (id.clone(), path, problem))
1695 .collect();
1696 }
1697 if gone.is_none() {
1699 problems.extend(copies);
1700 }
1701 problems.extend(gone);
1702 problems.extend(edited);
1703 }
1704 }
1705 problems
1706 }
1707
1708 fn copy_problems(&self, section: Section, locked: &Locked) -> Vec<(String, PathBuf, Problem)> {
1713 let mut problems = Vec::new();
1714 if self.manifest.patch.contains_key(&locked.name) {
1715 return problems;
1716 }
1717 let homes = self.homes(section);
1718 let id = format!("{}.{}", section.key(), locked.name);
1719 for (item, hash) in self.locked_items(section, locked) {
1720 let canonical = homes.first().map(|home| home.join(&item));
1721 for (index, home) in homes.iter().enumerate() {
1722 let path = home.join(&item);
1723 let problem = match path.symlink_metadata() {
1724 Err(_) => Some(Problem::Missing),
1725 Ok(metadata) if metadata.is_symlink() && index == 0 => Some(Problem::Linked),
1726 Ok(metadata) if metadata.is_symlink() => {
1727 let target = path.canonicalize().ok();
1728 let canonical = canonical.as_ref().and_then(|c| c.canonicalize().ok());
1729 (target != canonical).then(|| Problem::Link(homes[0].join(&item)))
1730 }
1731 Ok(_) => hash
1732 .as_ref()
1733 .filter(|hash| tree_hash(&path).ok().as_ref() != Some(*hash))
1734 .map(|_| Problem::Mismatch),
1735 };
1736 if let Some(problem) = problem {
1737 problems.push((id.clone(), path, problem));
1738 }
1739 }
1740 }
1741 problems
1742 }
1743
1744 pub fn installed_root(&self, name: &str) -> Option<PathBuf> {
1748 self.extension_roots(name)
1749 .map(|(root, _)| root)
1750 .filter(|root| root.exists())
1751 }
1752
1753 pub fn extension_roots(&self, name: &str) -> Option<(PathBuf, PathBuf)> {
1758 let entry = self.manifest.extensions.get(name)?;
1759 let patch = self
1760 .manifest
1761 .patch
1762 .get(name)
1763 .and_then(|patch| patch.path.as_deref());
1764 let store = self.store().join(name);
1765 Some(
1766 match (patch, &entry.npm, &entry.git, entry.path.as_deref()) {
1767 (Some(path), ..) | (None, None, None, Some(path)) => {
1768 let root = self.resolve(path);
1769 (root.clone(), root)
1770 }
1771 (None, Some(npm), _, _) => (store.join("node_modules").join(npm), store),
1772 (None, None, Some(_), _) => (store.clone(), store),
1773 (None, None, None, None) => return None,
1774 },
1775 )
1776 }
1777
1778 fn startup_without_lock(
1783 &mut self,
1784 offline: bool,
1785 report: &mut Report,
1786 ) -> Result<bool, ChestError> {
1787 self.lock = self.read_lock()?;
1788 let Some(lock) = &self.lock else {
1789 report.warnings.push(format!(
1790 "{} has no lock, so nothing from it was installed: run `{}`",
1791 self.label(),
1792 self.install_command()
1793 ));
1794 return Ok(true);
1795 };
1796 let record = self.record();
1797 let mut entries = Vec::new();
1798 let mut stale = Vec::new();
1799 let (mut missing, mut loading) = (Vec::new(), Vec::new());
1800 for (section, name, entry) in self.entries() {
1801 let locked = lock
1802 .section(section)
1803 .iter()
1804 .find(|locked| &locked.name == name);
1805 let id = format!("{}.{name}", section.key());
1806 let outcome = match self.at_startup(section, name, entry, locked, &record, offline) {
1807 AtStartup::Stale => {
1808 stale.push(id.clone());
1809 Outcome::Skipped
1810 }
1811 AtStartup::Present => Outcome::Unchanged,
1812 AtStartup::Missing => {
1813 match locked.is_some_and(|locked| self.read_by_yapi(section, locked)) {
1814 true => loading.push(id.clone()),
1815 false => missing.push(id.clone()),
1816 }
1817 Outcome::Skipped
1818 }
1819 AtStartup::Replay => return Ok(false),
1820 };
1821 entries.push((id, outcome));
1822 }
1823 report.entries = entries;
1824 if !stale.is_empty() {
1825 report.warnings.push(self.stale_warning(&stale));
1826 }
1827 let label = self.label();
1828 if !missing.is_empty() {
1829 let skipped = if missing.len() == 1 {
1830 "it was"
1831 } else {
1832 "they were"
1833 };
1834 report.warnings.push(format!(
1835 "{label}: {} could not be put back offline, so {skipped} left out",
1836 missing.join(", ")
1837 ));
1838 }
1839 if !loading.is_empty() {
1840 let copies = match loading.len() {
1841 1 => "its copy in a folder yapi reads on its own keeps",
1842 _ => "their copies in folders yapi reads on its own keep",
1843 };
1844 report.warnings.push(format!(
1845 "{label}: {} could not be put back offline, so {copies} loading",
1846 loading.join(", ")
1847 ));
1848 }
1849 Ok(true)
1850 }
1851
1852 fn read_by_yapi(&self, section: Section, locked: &Locked) -> bool {
1856 if section != Section::Skills {
1857 return false;
1858 }
1859 let own = match self.scope {
1860 Scope::Project => [".agents/skills", ".yapi/skills"].map(|dir| self.base.join(dir)),
1861 Scope::Global => [
1862 self.home.join(".agents/skills"),
1863 crate::config::agent_dir().join("skills"),
1864 ],
1865 };
1866 let own = own.map(|folder| real_path(&folder));
1867 let homes = self.homes(section);
1868 let items = self.locked_items(section, locked);
1869 let read = homes.iter().filter(|home| own.contains(home));
1870 read.flat_map(|home| items.iter().map(move |(item, _)| home.join(item)))
1871 .any(|copy| copy.exists())
1872 }
1873
1874 fn at_startup(
1879 &self,
1880 section: Section,
1881 name: &str,
1882 entry: &Entry,
1883 locked: Option<&Locked>,
1884 record: &Record,
1885 offline: bool,
1886 ) -> AtStartup {
1887 let patched = self.manifest.patch.contains_key(name);
1888 if !patched && !locked.is_some_and(|locked| fresh(entry, locked)) {
1889 return AtStartup::Stale;
1890 }
1891 let pinned = |locked: &Locked| {
1893 self.present(section, locked) && self.holds_locked(section, entry, locked, record)
1894 };
1895 if patched || locked.is_some_and(pinned) {
1896 return AtStartup::Present;
1897 }
1898 if offline {
1899 AtStartup::Missing
1900 } else {
1901 AtStartup::Replay
1902 }
1903 }
1904
1905 fn stale_warning(&self, ids: &[String]) -> String {
1908 let (verb, lock, skipped) = match ids.len() {
1909 1 => ("is", "its", "it was"),
1910 _ => ("are", "their", "they were"),
1911 };
1912 format!(
1913 "{}: {} {verb} not locked, or {lock} lock is stale, so {skipped} skipped: run `{}`",
1914 self.label(),
1915 ids.join(", "),
1916 self.install_command()
1917 )
1918 }
1919
1920 pub fn verify(&self) -> Result<Vec<String>, ChestError> {
1926 let (name, install) = (self.label(), self.install_command());
1927 let lock = self
1928 .lock
1929 .as_ref()
1930 .ok_or_else(|| invalid(format!("{name} has no lock: run `{install}`")))?;
1931 Ok(self.problems(lock).iter().map(describe).collect())
1932 }
1933
1934 pub async fn outdated(&self, npm: &mut Npm) -> Result<Vec<Outdated>, ChestError> {
1938 let Some(lock) = &self.lock else {
1939 return Ok(Vec::new());
1940 };
1941 let mut found = Vec::new();
1942 for (section, name, entry) in self.entries() {
1943 let Some(locked) = lock
1944 .section(section)
1945 .iter()
1946 .find(|locked| &locked.name == name)
1947 else {
1948 continue;
1949 };
1950 let id = format!("{}.{name}", section.key());
1951 let failed = |err: ChestError| ChestError::Failed(format!("{id}: {err}"));
1952 if let Some(repo) = &entry.git {
1953 let wanted = fetch::resolve(&self.cache, repo, entry.rev.as_deref())
1954 .await
1955 .map_err(failed)?;
1956 let locked = locked.commit.clone().unwrap_or_default();
1957 found.push(Outdated {
1958 id,
1959 locked,
1960 wanted,
1961 latest: None,
1962 });
1963 } else if let Some(package) = &entry.npm {
1964 let range = entry.version.as_deref().unwrap_or("latest");
1965 let wanted = npm
1966 .latest(package, range)
1967 .await
1968 .map_err(|err| failed(err.into()))?;
1969 let latest = npm.latest(package, "latest").await.ok();
1970 let locked = locked.version.clone().unwrap_or_default();
1971 found.push(Outdated {
1972 id,
1973 locked,
1974 wanted,
1975 latest,
1976 });
1977 }
1978 }
1979 Ok(found)
1980 }
1981
1982 pub async fn fetch_package(&self, entry: &Entry, npm: &mut Npm) -> Result<Fetched, ChestError> {
1987 if entry.git.is_none() && entry.npm.is_none() {
1988 let path = entry
1989 .path
1990 .as_deref()
1991 .ok_or_else(|| invalid("needs `git`, `npm` or `path`"))?;
1992 return Ok(Fetched {
1993 root: self.resolve(path),
1994 _work: None,
1995 });
1996 }
1997 let mut work = fetch::Work::new(&self.cache)?;
1998 let root = work.path();
1999 if let Some(repo) = &entry.git {
2000 let commit = fetch::resolve(&self.cache, repo, entry.rev.as_deref()).await?;
2001 fetch::checkout(&self.cache, repo, &commit, &root).await?;
2002 } else if let Some(package) = &entry.npm {
2003 npm::fetch(npm, package, entry.version.as_deref(), &root).await?;
2004 }
2005 Ok(Fetched {
2006 root,
2007 _work: Some(work),
2008 })
2009 }
2010}
2011
2012#[derive(Clone, Debug, PartialEq, Eq)]
2014enum Problem {
2015 Missing,
2017 Mismatch,
2019 Modified,
2021 Link(PathBuf),
2023 Linked,
2025 NoSource,
2027}
2028
2029fn waiting_for(file: &Path) -> String {
2031 format!(
2032 "Waiting for another chest command, which holds the lock of {}...",
2033 file.display()
2034 )
2035}
2036
2037fn describe((id, path, problem): &(String, PathBuf, Problem)) -> String {
2038 let what = match problem {
2039 Problem::NoSource => return format!("{id}: its source {} is missing", path.display()),
2040 Problem::Missing => "is missing".to_owned(),
2041 Problem::Mismatch => "does not match the lock".to_owned(),
2042 Problem::Modified => "was modified since chest installed it".to_owned(),
2043 Problem::Link(canonical) => format!("does not link to {}", canonical.display()),
2044 Problem::Linked => "is a link where chest places the copy".to_owned(),
2045 };
2046 format!("{id}: {} {what}", path.display())
2047}
2048
2049fn has_git(path: &Path) -> bool {
2052 fn walk(dir: &Path) -> bool {
2053 let Ok(entries) = std::fs::read_dir(dir) else {
2054 return false;
2055 };
2056 entries.flatten().any(|entry| {
2057 entry.file_name() == ".git"
2058 || entry.file_type().is_ok_and(|kind| kind.is_dir()) && walk(&entry.path())
2059 })
2060 }
2061 let held = match path.symlink_metadata() {
2062 Ok(metadata) if metadata.is_symlink() => path.canonicalize().ok(),
2063 Ok(_) => Some(path.to_path_buf()),
2064 Err(_) => None,
2065 };
2066 held.is_some_and(|held| walk(&held))
2067}
2068
2069fn copy_checkout(package: &Path, subpath: Option<&str>, staging: &Path) -> Result<(), ChestError> {
2074 let source = package.join(subpath.unwrap_or_default());
2075 fetch::inside(package, &source)?;
2076 if !source.is_dir() {
2077 return Err(invalid(format!(
2078 "`path` must name the folder that holds the extension, not a file: {}",
2079 subpath.unwrap_or_default()
2080 )));
2081 }
2082 place::copy(&source, staging)?;
2083 let modules = staging.join("node_modules");
2084 if modules.symlink_metadata().is_ok() {
2085 std::fs::remove_dir_all(&modules).map_err(io(&modules))?;
2086 }
2087 Ok(())
2088}