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 io(path: &Path) -> impl FnOnce(std::io::Error) -> ChestError + '_ {
57 move |source| ChestError::Io {
58 path: path.to_path_buf(),
59 source,
60 }
61}
62
63#[derive(Clone, Debug, PartialEq, Eq)]
65pub enum Mode {
66 Resolve,
71 Update(Vec<String>),
74 Frozen,
77 Startup {
81 offline: bool,
83 },
84}
85
86#[derive(Clone, Copy, Debug, PartialEq, Eq)]
88pub enum Outcome {
89 Installed,
91 Replayed,
93 Unchanged,
95 Skipped,
97 Removed,
99}
100
101#[derive(Clone, Debug, Default, PartialEq, Eq)]
103pub struct Report {
104 pub entries: Vec<(String, Outcome)>,
107 pub warnings: Vec<String>,
109 pub notices: Vec<String>,
111}
112
113#[derive(Clone, Debug, PartialEq, Eq)]
115pub struct Outdated {
116 pub id: String,
118 pub locked: String,
120 pub wanted: String,
123 pub latest: Option<String>,
125}
126
127pub struct Fetched {
129 pub root: PathBuf,
132 _work: Option<fetch::Work>,
133}
134
135struct Target<'a> {
137 id: String,
138 section: Section,
139 name: &'a str,
140 entry: &'a Entry,
141 patch: Option<&'a Entry>,
142 locked: Option<&'a Locked>,
143}
144
145struct Staged {
147 id: String,
148 section: Section,
149 record: Option<Locked>,
150 content: Content,
151 outcome: Outcome,
152 warnings: Vec<String>,
154}
155
156enum Content {
157 Items(Vec<(String, PathBuf, String)>),
160 Store {
163 staging: PathBuf,
164 dir: PathBuf,
165 hash: String,
166 },
167 Nothing,
169}
170
171pub async fn hold_lock(
177 file: &Path,
178 waiting: &mut (dyn FnMut() + Send),
179) -> Result<FileLock, ChestError> {
180 let cancel = CancellationToken::new();
181 let failed = |err: LockError| ChestError::Failed(format!("{}: {err}", file.display()));
182 let first = tokio::time::timeout(Duration::from_secs(1), FileLock::acquire(file, &cancel));
183 match first.await {
184 Ok(Ok(lock)) => return Ok(lock),
185 Ok(Err(LockError::Held)) | Err(_) => waiting(),
186 Ok(Err(err)) => return Err(failed(err)),
187 }
188 loop {
189 match FileLock::acquire(file, &cancel).await {
190 Err(LockError::Held) => {}
191 locked => return locked.map_err(failed),
192 }
193 }
194}
195
196pub fn refuse_link(path: &Path) -> Result<(), ChestError> {
200 if path.symlink_metadata().is_ok_and(|meta| meta.is_symlink()) {
201 return Err(invalid(format!(
202 "{} is a symbolic link, which chest does not follow: replace it with the file itself",
203 path.display()
204 )));
205 }
206 Ok(())
207}
208
209fn discard(staged: &[Staged]) {
211 for staged in staged {
212 if let Content::Store { staging, .. } = &staged.content {
213 let _ = std::fs::remove_dir_all(staging);
214 }
215 }
216}
217
218fn real_path(path: &Path) -> PathBuf {
221 let mut rest = Vec::new();
222 let mut current = path;
223 loop {
224 if let Ok(real) = current.canonicalize() {
225 return rest.iter().rev().fold(real, |real, part| real.join(part));
226 }
227 match (current.parent(), current.file_name()) {
228 (Some(parent), Some(name)) => {
229 rest.push(name.to_owned());
230 current = parent;
231 }
232 _ => return path.to_path_buf(),
233 }
234 }
235}
236
237#[derive(Clone, Debug)]
239pub struct Project {
240 pub manifest: Manifest,
242 pub lock: Option<Lock>,
244 pub base: PathBuf,
246 pub scope: Scope,
248 pub cache: PathBuf,
251 pub state: PathBuf,
254 path: PathBuf,
255 pub home: PathBuf,
257}
258
259enum AtStartup {
261 Stale,
263 Present,
265 Missing,
267 Replay,
269}
270
271impl Project {
272 pub fn open(path: &Path, scope: Scope) -> Result<Project, ChestError> {
280 let path = std::path::absolute(path).map_err(io(path))?;
281 refuse_link(&path)?;
282 let text = std::fs::read_to_string(&path).map_err(io(&path))?;
283 let manifest = Manifest::parse(&text, scope)
284 .map_err(|err| invalid(format!("{}: {err}", path.display())))?;
285 let folder = path.parent().unwrap_or(&path);
286 let base = folder.canonicalize().map_err(io(folder))?;
287 let home = home_dir();
288 let mut project = Project {
289 manifest,
290 lock: None,
291 path: base.join(path.file_name().unwrap_or_default()),
292 base,
293 scope,
294 cache: home.join(".chest/cache"),
295 state: home.join(".chest/state"),
296 home: home.canonicalize().unwrap_or(home),
297 };
298 project.lock = project.read_lock()?;
299 Ok(project)
300 }
301
302 pub fn global() -> Result<Project, ChestError> {
304 Project::open(&home_dir().join(".chest/chest.toml"), Scope::Global)
305 }
306
307 pub fn lock_path(&self) -> PathBuf {
309 self.path.with_extension("lock")
310 }
311
312 pub fn root(&self) -> PathBuf {
315 match self.scope {
316 Scope::Project => self.base.clone(),
317 Scope::Global => self.home.clone(),
318 }
319 }
320
321 pub fn store(&self) -> PathBuf {
324 match (&self.manifest.folders.extensions, self.scope) {
325 (Some(store), _) => self.resolve(store),
326 (None, Scope::Project) => self.base.join(".yapi/chest"),
327 (None, Scope::Global) => self.home.join(".chest/store"),
328 }
329 }
330
331 fn resolve(&self, path: &str) -> PathBuf {
334 let path = match path.strip_prefix('~') {
335 Some("") => self.home.clone(),
336 Some(rest) if rest.starts_with('/') => self.home.join(&rest[1..]),
337 _ => PathBuf::from(path),
338 };
339 resolve_lexically(&self.base, &path)
340 }
341
342 pub fn too_wide(&self, folder: &Path, also: &[&Path]) -> bool {
349 let Ok(folder) = folder.canonicalize() else {
350 return false;
351 };
352 let held = [
353 self.home.clone(),
354 crate::config::agent_dir(),
355 PathBuf::from("/"),
356 self.base.clone(),
357 self.store(),
358 ];
359 held.into_iter()
360 .chain(also.iter().map(|path| path.to_path_buf()))
361 .any(|held| real_path(&held).starts_with(&folder))
362 }
363
364 fn refuse_wide(&self) -> Result<(), ChestError> {
367 for (name, entry) in &self.manifest.extensions {
368 let patch = self.manifest.patch.get(name);
369 let patch = patch.and_then(|patch| patch.path.as_deref());
370 let source = entry.path.as_deref();
371 let source = source.filter(|_| entry.git.is_none() && entry.npm.is_none());
372 let (id, path) = match (patch, source) {
373 (Some(path), _) => (format!("patch.{name}"), path),
374 (None, Some(path)) => (format!("extensions.{name}"), path),
375 (None, None) => continue,
376 };
377 let folder = self.resolve(path);
378 if self.too_wide(&folder, &[]) {
379 return Err(invalid(format!(
380 "{id}: {} holds more than the extension, such as the project, the home or the agent folder, so chest does not install it",
381 folder.display()
382 )));
383 }
384 }
385 Ok(())
386 }
387
388 fn folders(&self, section: Section) -> Result<Vec<PathBuf>, ChestError> {
392 let folders = match section {
393 Section::Skills => &self.manifest.folders.skills,
394 _ => &self.manifest.folders.agents,
395 };
396 if folders.is_empty() {
397 let key = section.key();
398 return Err(invalid(format!(
399 "Set `{key}` in [folders] to say where {key} go"
400 )));
401 }
402 let mut real: Vec<PathBuf> = Vec::new();
403 for folder in folders {
404 let path = real_path(&self.resolve(folder));
405 if !real.contains(&path) {
406 real.push(path);
407 }
408 }
409 Ok(real)
410 }
411
412 fn read_lock(&self) -> Result<Option<Lock>, ChestError> {
413 let path = self.lock_path();
414 refuse_link(&path)?;
415 match std::fs::read_to_string(&path) {
416 Ok(text) => Lock::parse(&text)
417 .map(Some)
418 .map_err(|err| invalid(format!("{}: {err}", path.display()))),
419 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
420 Err(err) => Err(io(&path)(err)),
421 }
422 }
423
424 fn file_name(&self) -> String {
425 self.path
426 .file_name()
427 .map_or_else(String::new, |name| name.to_string_lossy().into_owned())
428 }
429
430 fn entries(&self) -> impl Iterator<Item = (Section, &String, &Entry)> {
432 self.manifest.entries()
433 }
434
435 fn homes(&self, section: Section) -> Vec<PathBuf> {
438 match section {
439 Section::Extensions => vec![self.store()],
440 _ => self.folders(section).unwrap_or_default(),
441 }
442 }
443
444 pub fn placed(&self, section: Section, locked: &Locked) -> Vec<PathBuf> {
447 let homes = self.homes(section);
448 let items = locked_items(section, locked);
449 items
450 .iter()
451 .flat_map(|(name, _)| homes.iter().map(move |home| home.join(name)))
452 .collect()
453 }
454
455 fn pinned(&self, lock: &Lock) -> HashMap<PathBuf, String> {
457 let mut pinned = HashMap::new();
458 for section in [Section::Skills, Section::Agents, Section::Extensions] {
459 let homes = self.homes(section);
460 for locked in lock.section(section) {
461 for (name, hash) in locked_items(section, locked) {
462 let Some(hash) = hash else { continue };
463 for home in &homes {
464 pinned.insert(home.join(&name), hash.clone());
465 }
466 }
467 }
468 }
469 pinned
470 }
471
472 fn recorded(&self, section: Section, locked: &Locked, record: &Record) -> bool {
476 let homes = self.homes(section);
477 let items = locked_items(section, locked);
478 items.iter().all(|(name, hash)| {
479 let holds = |hash: &String| {
480 homes
481 .iter()
482 .all(|home| record.holds(&home.join(name), hash))
483 };
484 hash.as_ref().is_some_and(holds)
485 })
486 }
487
488 pub fn present(&self, section: Section, locked: &Locked) -> bool {
493 let placed = self.placed(section, locked);
494 let in_place = section == Section::Extensions && placed.is_empty();
495 let first = self.homes(section).into_iter().next();
496 let there = |path: &PathBuf| match path.symlink_metadata() {
497 Ok(metadata) => !metadata.is_symlink() || path.parent() != first.as_deref(),
498 Err(_) => false,
499 };
500 in_place || !placed.is_empty() && placed.iter().all(there)
501 }
502
503 pub async fn sync(
512 &mut self,
513 mode: Mode,
514 npm: &mut Npm,
515 progress: &mut (dyn FnMut(&str) + Send),
516 ) -> Result<Report, ChestError> {
517 let mut report = Report::default();
518 if !matches!(mode, Mode::Startup { .. }) {
520 self.refuse_wide()?;
521 }
522 self.path_notices(&mut report);
523 let frozen = mode == Mode::Frozen;
524 if frozen {
527 self.lock = self.read_lock()?;
528 if !self.check_frozen(&mut report)? {
529 return Ok(report);
530 }
531 }
532 if let Mode::Startup { offline } = mode
534 && self.startup_without_lock(offline, &mut report)?
535 {
536 return Ok(report);
537 }
538 let lock_path = self.lock_path();
539 let mut waiting = || progress(&waiting_for(&lock_path));
540 let _held = hold_lock(&lock_path, &mut waiting).await?;
541 self.lock = self.read_lock()?;
542 if frozen && !self.check_frozen(&mut report)? {
544 return Ok(report);
545 }
546 let startup = matches!(mode, Mode::Startup { .. });
547 let old = match (&self.lock, startup) {
548 (Some(lock), _) => lock.clone(),
549 (None, false) => Lock::default(),
550 (None, true) => {
551 report.warnings.push(format!(
552 "{} has no lock, so nothing from it was installed: run `chest install`",
553 self.file_name()
554 ));
555 return Ok(report);
556 }
557 };
558 let record_path = Record::path(&self.state, &self.root(), self.scope);
561 let state = record_path.parent().unwrap_or(&record_path);
562 std::fs::create_dir_all(state).map_err(io(state))?;
563 let mut waiting = || progress(&waiting_for(&record_path));
564 let _record_held = hold_lock(&record_path, &mut waiting).await?;
565 self.prepare_folders()?;
566 let mut record = Record::load(&record_path, &self.root(), &self.file_name());
567 let (staged, _work) = self
569 .prepare(&mode, &old, &record, npm, progress, &mut report)
570 .await?;
571 let committed = self.commit(&staged, &old, &mut record, &record_path, &mut report);
572 let occupied = match committed {
573 Ok(occupied) => occupied,
574 Err(err) => {
575 discard(&staged);
576 return Err(err);
577 }
578 };
579 if startup {
580 return Ok(report);
581 }
582 let mut lock = Lock::default();
583 for staged in staged {
584 let Some(record) = staged.record else {
585 continue;
586 };
587 match staged.section {
588 Section::Skills => lock.skills.push(record),
589 Section::Agents => lock.agents.push(record),
590 _ => lock.extensions.push(record),
591 }
592 }
593 self.remove_dropped(&old, &occupied, &mut record, &record_path, &mut report)?;
594 let text = lock.to_toml()?;
595 if !frozen && std::fs::read_to_string(&lock_path).ok().as_deref() != Some(text.as_str()) {
597 write_atomic(&lock_path, text.as_bytes(), 0o666).map_err(io(&lock_path))?;
598 }
599 if self.scope == Scope::Project {
600 self.write_ignore(&occupied)?;
601 }
602 self.lock = Some(lock);
603 Ok(report)
604 }
605
606 fn prepare_folders(&self) -> Result<(), ChestError> {
612 let root = self.root();
613 let mut homes: Vec<PathBuf> = Vec::new();
614 for section in [Section::Skills, Section::Agents] {
615 let configured = match section {
616 Section::Skills => &self.manifest.folders.skills,
617 _ => &self.manifest.folders.agents,
618 };
619 if !self.manifest.section(section).is_empty() {
620 for folder in configured {
621 let path = self.resolve(folder);
622 place::within(&root, &path.join("item"))?;
624 if path.symlink_metadata().is_err() {
626 std::fs::create_dir_all(&path).map_err(io(&path))?;
627 }
628 }
629 }
630 let mut reals: Vec<PathBuf> = Vec::new();
631 for folder in configured {
632 let real = real_path(&self.resolve(folder));
633 if !reals.contains(&real) {
634 reals.push(real);
635 }
636 }
637 homes.extend(reals);
638 }
639 homes.push(real_path(&self.store()));
640 for (index, first) in homes.iter().enumerate() {
641 for second in &homes[index + 1..] {
642 if first.starts_with(second) || second.starts_with(first) {
643 return Err(invalid(format!(
644 "folders: {} and {} overlap",
645 first.display(),
646 second.display()
647 )));
648 }
649 }
650 }
651 for home in &homes {
652 if place::within(&root, &home.join("item")).is_err() {
654 continue;
655 }
656 for entry in std::fs::read_dir(home).into_iter().flatten().flatten() {
657 let name = entry.file_name().to_string_lossy().into_owned();
658 let stopped = name.ends_with("-staging") || name.ends_with("-old");
659 if name.starts_with('.') && stopped {
660 let path = entry.path();
661 npm::remove_path(&path).map_err(io(&path))?;
662 }
663 }
664 }
665 Ok(())
666 }
667
668 async fn prepare(
672 &self,
673 mode: &Mode,
674 old: &Lock,
675 record: &Record,
676 npm: &mut Npm,
677 progress: &mut (dyn FnMut(&str) + Send),
678 report: &mut Report,
679 ) -> Result<(Vec<Staged>, fetch::Work), ChestError> {
680 if let Mode::Update(names) = mode
681 && let Some(unknown) = names
682 .iter()
683 .find(|name| self.entries().all(|(_, entry, _)| entry != *name))
684 {
685 return Err(invalid(format!("No entry is named {unknown}")));
686 }
687 let mut work = fetch::Work::new(&self.cache)?;
688 let mut staged: Vec<Staged> = Vec::new();
689 let mut stale = false;
690 for (section, name, entry) in self.entries() {
691 let locked = old
692 .section(section)
693 .iter()
694 .find(|locked| &locked.name == name);
695 let target = Target {
696 id: format!("{}.{name}", section.key()),
697 section,
698 name,
699 entry,
700 patch: self.manifest.patch.get(name),
701 locked,
702 };
703 let fresh = locked.is_some_and(|locked| fresh(entry, locked));
704 let present = locked.is_some_and(|locked| self.present(section, locked));
705 let recorded = locked.is_some_and(|locked| self.recorded(section, locked, record));
706 let local = target.patch.is_some() || entry.git.is_none() && entry.npm.is_none();
707 let named = matches!(mode, Mode::Update(names)
708 if names.is_empty() || names.iter().any(|n| n == name));
709 let keep = match mode {
710 Mode::Startup { offline } => {
711 match self.at_startup(section, name, entry, locked, *offline) {
712 AtStartup::Stale => {
713 stale = true;
714 Some(Outcome::Skipped)
715 }
716 AtStartup::Present => Some(Outcome::Unchanged),
717 AtStartup::Missing => Some(Outcome::Skipped),
718 AtStartup::Replay => None,
719 }
720 }
721 Mode::Frozen if present => Some(Outcome::Unchanged),
722 _ if fresh && present && recorded && !local && !named => Some(Outcome::Unchanged),
723 _ => None,
724 };
725 if let Some(outcome) = keep {
726 staged.push(Staged {
727 id: target.id,
728 section,
729 record: locked.cloned(),
730 content: Content::Nothing,
731 outcome,
732 warnings: Vec::new(),
733 });
734 continue;
735 }
736 let resolve = !fresh || named;
738 progress(&format!("Installing {}...", target.id));
739 match self.stage(&target, resolve, npm, &mut work).await {
740 Ok(mut next) => {
741 let id = &next.id;
742 let warnings = next.warnings.drain(..);
743 report
744 .warnings
745 .extend(warnings.map(|warning| format!("{id}: {warning}")));
746 staged.push(next);
747 }
748 Err(err) => {
749 discard(&staged);
750 return Err(match err {
751 ChestError::Invalid(message) => {
752 invalid(format!("{}: {message}", target.id))
753 }
754 err => ChestError::Failed(format!("{}: {err}", target.id)),
755 });
756 }
757 }
758 }
759 if stale {
760 report.warnings.push(self.stale_warning());
761 }
762 Ok((staged, work))
763 }
764
765 async fn stage(
768 &self,
769 target: &Target<'_>,
770 resolve: bool,
771 npm: &mut Npm,
772 work: &mut fetch::Work,
773 ) -> Result<Staged, ChestError> {
774 let Target {
775 section,
776 name,
777 entry,
778 locked,
779 ..
780 } = *target;
781 let staged = |record, content, outcome, warnings| Staged {
782 id: target.id.clone(),
783 section,
784 record,
785 content,
786 outcome,
787 warnings,
788 };
789 let hashed = |items: Vec<(String, PathBuf)>| -> Result<Vec<_>, ChestError> {
790 let hash = |(name, source): (String, PathBuf)| {
791 tree_hash(&source).map(|hash| (name, source, hash))
792 };
793 items.into_iter().map(hash).collect()
794 };
795 let path_source = match (&entry.git, &entry.npm) {
796 (None, None) => entry.path.as_deref(),
797 _ => None,
798 };
799 if let Some(path) = target
800 .patch
801 .and_then(|patch| patch.path.as_deref())
802 .or(path_source)
803 {
804 let record = match target.patch {
806 Some(_) => locked.cloned(),
807 None => Some(Locked {
808 name: name.to_owned(),
809 path: entry.path.clone(),
810 ..Locked::default()
811 }),
812 };
813 if section == Section::Extensions {
814 let outcome = match resolve {
815 true => Outcome::Installed,
816 false => Outcome::Unchanged,
817 };
818 return Ok(staged(record, Content::Nothing, outcome, Vec::new()));
819 }
820 let source = self.resolve(path);
821 let patched_package =
824 target.patch.is_some() && path_source.is_none() && entry.path.is_none();
825 let (items, warnings) = if patched_package {
826 fetch::items(&source, section, None, name)?
827 } else {
828 fetch::check_item(section, &source)?;
829 let item = (fetch::placed_name(section, name), source);
830 (vec![item], Vec::new())
831 };
832 let sections = [Section::Skills, Section::Agents].into_iter();
834 let mut installs: Vec<PathBuf> = sections.flat_map(|s| self.homes(s)).collect();
835 installs.push(real_path(&self.store()));
836 for (_, item) in &items {
837 let real = real_path(item);
838 if let Some(home) = installs.iter().find(|home| home.starts_with(&real)) {
839 return Err(invalid(format!(
840 "{} holds {}, where chest installs, so it cannot be installed",
841 item.display(),
842 home.display()
843 )));
844 }
845 }
846 let content = Content::Items(hashed(items)?);
847 return Ok(staged(record, content, Outcome::Installed, warnings));
848 }
849 let replay = locked.filter(|_| !resolve);
850 let mut record = Locked {
851 name: name.to_owned(),
852 git: entry.git.clone(),
853 rev: entry.rev.clone(),
854 npm: entry.npm.clone(),
855 path: entry.path.clone(),
856 ..Locked::default()
857 };
858 let package = work.path();
859 if let Some(repo) = &entry.git {
860 let commit = match replay {
861 Some(locked) => locked.commit.clone().unwrap_or_default(),
862 None => fetch::resolve(&self.cache, repo, entry.rev.as_deref()).await?,
863 };
864 fetch::checkout(&self.cache, repo, &commit, &package).await?;
865 record.commit = Some(commit);
866 }
867 let mut warnings = Vec::new();
868 let content = if section == Section::Extensions {
869 let dir = self.store().join(name);
870 place::within(&self.root(), &dir)?;
871 let staging = npm::stage(&dir)?;
872 let built = self
873 .build_extension(entry, replay, &package, &staging, npm, &mut record)
874 .await;
875 if let Err(err) = built {
876 let _ = std::fs::remove_dir_all(&staging);
877 return Err(err);
878 }
879 let hash = record.hash.clone().unwrap_or_default();
880 Content::Store { staging, dir, hash }
881 } else {
882 if let Some(npm_name) = &entry.npm {
883 let pin = match replay {
884 Some(locked) => {
885 let pin = Pinned {
886 path: String::new(),
887 name: npm_name.clone(),
888 version: locked.version.clone().unwrap_or_default(),
889 tarball: locked.tarball.clone().unwrap_or_default(),
890 integrity: locked.integrity.clone().unwrap_or_default(),
891 };
892 npm::fetch_pinned(npm, &pin, &package).await?;
893 pin
894 }
895 None => {
896 let pin =
897 npm::fetch(npm, npm_name, entry.version.as_deref(), &package).await?;
898 npm.check_pin(&pin)?;
900 pin
901 }
902 };
903 record.version = Some(pin.version);
904 record.tarball = Some(pin.tarball);
905 record.integrity = Some(pin.integrity);
906 }
907 let (items, skipped) = fetch::items(&package, section, entry.path.as_deref(), name)?;
908 warnings = skipped;
909 let items = hashed(items)?;
910 if entry.path.is_some() {
911 record.hash = Some(items[0].2.clone());
912 } else {
913 for (item, _, hash) in &items {
914 record.items.push(lock::Item {
915 name: item.clone(),
916 hash: hash.clone(),
917 });
918 }
919 }
920 Content::Items(items)
921 };
922 let Some(locked) = replay else {
923 return Ok(staged(Some(record), content, Outcome::Installed, warnings));
924 };
925 if record.hash != locked.hash || record.items != locked.items {
926 if let Content::Store { staging, .. } = &content {
927 let _ = std::fs::remove_dir_all(staging);
928 }
929 return Err(invalid(
930 "what was fetched does not match the lock: run `chest update` to take it",
931 ));
932 }
933 Ok(staged(
934 Some(locked.clone()),
935 content,
936 Outcome::Replayed,
937 warnings,
938 ))
939 }
940
941 async fn build_extension(
945 &self,
946 entry: &Entry,
947 replay: Option<&Locked>,
948 package: &Path,
949 staging: &Path,
950 npm: &mut Npm,
951 record: &mut Locked,
952 ) -> Result<(), ChestError> {
953 match (&entry.npm, replay) {
954 (_, Some(locked)) => {
955 if let Some(npm_name) = &entry.npm {
958 let root = format!("node_modules/{npm_name}");
959 let version = locked.version.as_deref().unwrap_or_default();
960 let pinned = locked.packages.iter().any(|pin| {
961 pin.path == root && pin.name == *npm_name && pin.version == version
962 });
963 if !pinned {
964 return Err(invalid(format!(
965 "the lock does not pin {npm_name}@{version} at {root}"
966 )));
967 }
968 }
969 if entry.git.is_some() {
970 copy_checkout(package, entry.path.as_deref(), staging)?;
971 }
972 let root = staging.join("package.json");
975 if let Some(npm_name) = &entry.npm {
976 let version = locked.version.as_deref().unwrap_or_default();
977 let manifest = serde_json::json!({"dependencies": {npm_name: version}});
978 let text = yapi_types::json::to_string(&manifest).unwrap_or_default();
979 std::fs::write(&root, text).map_err(io(&root))?;
980 }
981 npm::replay(npm, staging, &locked.packages).await?;
982 if entry.npm.is_some() {
983 std::fs::remove_file(&root).map_err(io(&root))?;
984 }
985 }
986 (Some(npm_name), None) => {
987 let installed =
988 npm::install(npm, staging, npm_name, entry.version.as_deref()).await?;
989 npm::check_installed(staging, &installed.tree)?;
992 for file in ["package.json", ".gitignore"] {
994 let file = staging.join(file);
995 std::fs::remove_file(&file).map_err(io(&file))?;
996 }
997 record.version = Some(installed.version);
998 record.packages = installed.tree;
999 }
1000 (None, None) => {
1001 copy_checkout(package, entry.path.as_deref(), staging)?;
1002 record.packages = npm::install_dependencies(npm, staging).await?;
1003 npm::check_installed(staging, &record.packages)?;
1004 }
1005 }
1006 for pin in &record.packages {
1009 npm.check_pin(pin)?;
1010 }
1011 record.hash = Some(tree_hash(staging)?);
1012 Ok(())
1013 }
1014
1015 fn commit(
1024 &self,
1025 staged: &[Staged],
1026 old: &Lock,
1027 record: &mut Record,
1028 record_path: &Path,
1029 report: &mut Report,
1030 ) -> Result<Vec<PathBuf>, ChestError> {
1031 let root = self.root();
1032 let pinned = self.pinned(old);
1033 let mut occupied: HashSet<PathBuf> = HashSet::new();
1034 for staged in staged {
1035 if let (Content::Nothing, Some(locked)) = (&staged.content, &staged.record) {
1036 occupied.extend(self.placed(staged.section, locked));
1037 }
1038 }
1039 let mut placements: Vec<Vec<(PathBuf, Vec<PathBuf>, String)>> = Vec::new();
1041 for staged in staged {
1042 let planned = match &staged.content {
1043 Content::Items(items) => {
1044 let folders = self.folders(staged.section)?;
1045 let plan = |(name, source, hash): &(String, PathBuf, String)| {
1046 let targets = folders.iter().map(|folder| folder.join(name)).collect();
1047 (source.clone(), targets, hash.clone())
1048 };
1049 items.iter().map(plan).collect()
1050 }
1051 Content::Store { staging, dir, hash } => {
1052 vec![(staging.clone(), vec![dir.clone()], hash.clone())]
1053 }
1054 Content::Nothing => Vec::new(),
1055 };
1056 for (_, targets, hash) in &planned {
1057 for target in targets {
1058 let refuse =
1059 |why: &str| invalid(format!("{}: {why} {}", staged.id, target.display()));
1060 place::within(&root, target)
1061 .map_err(|err| invalid(format!("{}: {err}", staged.id)))?;
1062 if !occupied.insert(target.clone()) {
1063 return Err(refuse("another entry also installs"));
1064 }
1065 if target.symlink_metadata().is_err() {
1066 continue;
1067 }
1068 match record.held(target) {
1069 Held::Owned => {}
1070 Held::Modified => {
1071 return Err(invalid(format!(
1072 "{}: {} was modified since chest installed it: remove it or restore it, then run chest again",
1073 staged.id,
1074 target.display()
1075 )));
1076 }
1077 Held::Unrecorded => {
1078 let lock_pins = pinned.get(target);
1079 let adoptable = lock_pins == Some(hash)
1080 && content_hash(target).as_ref() == lock_pins
1081 && !has_git(target);
1082 if !adoptable {
1083 return Err(refuse(
1084 "refusing to replace what chest did not install at",
1085 ));
1086 }
1087 record.adopt(target, hash);
1088 }
1089 }
1090 }
1091 }
1092 placements.push(planned);
1093 }
1094 record.save(record_path)?;
1095 for (staged, planned) in staged.iter().zip(placements) {
1096 for (source, targets, hash) in planned {
1097 let before: Vec<_> = targets.iter().map(|target| record.get(target)).collect();
1098 for target in &targets {
1100 record.expect(target, &hash);
1101 }
1102 record.save(record_path)?;
1103 let placed = match &staged.content {
1104 Content::Store { dir, .. } => npm::swap(&source, dir, Ok::<(), ChestError>(())),
1105 _ => {
1106 let folders = self.folders(staged.section)?;
1107 let name = targets[0].file_name().unwrap_or_default().to_string_lossy();
1108 place::place(&source, &root, &folders, &name).map(drop)
1109 }
1110 };
1111 for (target, before) in targets.iter().zip(before) {
1114 if placed.is_ok() || content_hash(target).as_ref() == Some(&hash) {
1115 record.noted(target, &hash);
1116 } else {
1117 record.restore(target, before);
1118 }
1119 }
1120 record.save(record_path)?;
1121 placed?;
1122 }
1123 report.entries.push((staged.id.clone(), staged.outcome));
1124 }
1125 Ok(occupied.into_iter().collect())
1126 }
1127
1128 fn remove_dropped(
1136 &self,
1137 old: &Lock,
1138 occupied: &[PathBuf],
1139 record: &mut Record,
1140 record_path: &Path,
1141 report: &mut Report,
1142 ) -> Result<(), ChestError> {
1143 let real = |path: &Path| match (path.parent(), path.file_name()) {
1145 (Some(parent), Some(name)) => real_path(parent).join(name),
1146 _ => path.to_path_buf(),
1147 };
1148 let really: HashSet<PathBuf> = occupied.iter().map(|path| real(path)).collect();
1149 let occupied: HashSet<&PathBuf> = occupied.iter().collect();
1150 for path in record.paths() {
1151 if occupied.contains(&path) {
1152 continue;
1153 }
1154 if record.shared(&path) || really.contains(&real(&path)) {
1155 record.release(&path);
1156 record.save(record_path)?;
1157 continue;
1158 }
1159 if path.symlink_metadata().is_ok() {
1160 match record.held(&path) {
1161 Held::Modified => {
1162 report.warnings.push(format!(
1163 "{} was modified since chest installed it, so it stays: remove it or restore it",
1164 path.display()
1165 ));
1166 continue;
1167 }
1168 _ => {
1169 if let Err(err) = place::unplace(&self.root(), std::slice::from_ref(&path))
1170 {
1171 report.warnings.push(format!(
1172 "chest could not remove {} and forgets it: {err}",
1173 path.display()
1174 ));
1175 }
1176 }
1177 }
1178 }
1179 record.forget(&path);
1180 record.save(record_path)?;
1181 }
1182 for section in [Section::Skills, Section::Agents, Section::Extensions] {
1183 for locked in old.section(section) {
1184 if !self.manifest.section(section).contains_key(&locked.name) {
1185 let id = format!("{}.{}", section.key(), locked.name);
1186 report.entries.push((id, Outcome::Removed));
1187 }
1188 }
1189 }
1190 Ok(())
1191 }
1192
1193 fn write_ignore(&self, occupied: &[PathBuf]) -> Result<(), ChestError> {
1197 let root = self.root();
1198 let relative = |path: &Path| -> Result<String, ChestError> {
1199 let relative = path.strip_prefix(&root).map_err(|_| {
1200 invalid(format!("{} is outside {}", path.display(), root.display()))
1201 })?;
1202 Ok(relative.to_string_lossy().replace('\\', "/"))
1203 };
1204 let mut entries = Vec::new();
1205 for path in occupied {
1206 entries.push(relative(path)?);
1207 }
1208 let first = |section| self.folders(section).ok()?.into_iter().next();
1209 let copies = [
1210 first(Section::Skills),
1211 first(Section::Agents),
1212 Some(self.store()),
1213 ];
1214 for home in copies.into_iter().flatten() {
1215 if occupied
1216 .iter()
1217 .any(|path| path.parent() == Some(home.as_path()))
1218 {
1219 let home = relative(&home)?;
1220 entries.push(format!("{home}/.*-staging"));
1221 entries.push(format!("{home}/.*-old"));
1222 }
1223 }
1224 place::write_ignore(&root, &self.file_name(), &entries)
1225 }
1226
1227 fn path_notices(&self, report: &mut Report) {
1229 if self.scope != Scope::Project {
1230 return;
1231 }
1232 let entries = self
1233 .entries()
1234 .map(|(section, name, entry)| (format!("{}.{name}", section.key()), entry));
1235 let patches = self
1236 .manifest
1237 .patch
1238 .iter()
1239 .map(|(name, entry)| (format!("patch.{name}"), entry));
1240 for (id, entry) in entries.chain(patches) {
1241 let source = entry
1242 .path
1243 .as_deref()
1244 .filter(|_| entry.git.is_none() && entry.npm.is_none());
1245 let Some(path) = source else { continue };
1246 let resolved = self.resolve(path);
1247 if !resolved.starts_with(&self.base) {
1248 report.notices.push(format!(
1249 "{id}: `{path}` is outside the project, at {}",
1250 resolved.display()
1251 ));
1252 }
1253 }
1254 }
1255
1256 fn check_frozen(&self, report: &mut Report) -> Result<bool, ChestError> {
1261 let name = self.file_name();
1262 let lock = self
1263 .lock
1264 .as_ref()
1265 .ok_or_else(|| invalid(format!("{name} has no lock: run `chest install`")))?;
1266 let drift = lock.drift(&self.manifest);
1267 if !drift.is_empty() {
1268 return Err(invalid(format!(
1269 "The lock of {name} is out of date for {}: run `chest install`",
1270 drift.join(", ")
1271 )));
1272 }
1273 let problems = self.problems(lock);
1274 let wrong: Vec<String> = problems
1275 .iter()
1276 .filter(|(_, _, problem)| !matches!(problem, Problem::Missing))
1277 .map(describe)
1278 .collect();
1279 if !wrong.is_empty() {
1280 return Err(invalid(wrong.join("\n")));
1281 }
1282 let missing = self.entries().any(|(section, entry_name, _)| {
1283 let locked = lock.section(section).iter();
1284 let mut found = locked.filter(|locked| &locked.name == entry_name);
1285 found.any(|locked| !self.present(section, locked))
1286 });
1287 if !missing {
1288 for (section, entry_name, _) in self.entries() {
1289 let id = format!("{}.{entry_name}", section.key());
1290 report.entries.push((id, Outcome::Unchanged));
1291 }
1292 }
1293 Ok(missing)
1294 }
1295
1296 fn problems(&self, lock: &Lock) -> Vec<(String, PathBuf, Problem)> {
1300 let mut problems = Vec::new();
1301 for section in [Section::Skills, Section::Agents, Section::Extensions] {
1302 let homes = self.homes(section);
1303 for locked in lock.section(section) {
1304 if self.manifest.patch.contains_key(&locked.name) {
1305 continue;
1306 }
1307 let id = format!("{}.{}", section.key(), locked.name);
1308 for (item, hash) in locked_items(section, locked) {
1309 let Some(hash) = hash else { continue };
1310 let canonical = homes.first().map(|home| home.join(&item));
1311 for (index, home) in homes.iter().enumerate() {
1312 let path = home.join(&item);
1313 let problem = match path.symlink_metadata() {
1314 Err(_) => Some(Problem::Missing),
1315 Ok(metadata) if metadata.is_symlink() => {
1316 let target = path.canonicalize().ok();
1317 let canonical =
1318 canonical.as_ref().and_then(|c| c.canonicalize().ok());
1319 (index == 0 || target != canonical)
1320 .then(|| Problem::Link(homes[0].join(&item)))
1321 }
1322 Ok(_) => (tree_hash(&path).ok().as_ref() != Some(&hash))
1323 .then_some(Problem::Mismatch),
1324 };
1325 if let Some(problem) = problem {
1326 problems.push((id.clone(), path, problem));
1327 }
1328 }
1329 }
1330 }
1331 }
1332 problems
1333 }
1334
1335 pub fn installed_root(&self, name: &str) -> Option<PathBuf> {
1339 self.extension_roots(name)
1340 .map(|(root, _)| root)
1341 .filter(|root| root.exists())
1342 }
1343
1344 pub fn extension_roots(&self, name: &str) -> Option<(PathBuf, PathBuf)> {
1349 let entry = self.manifest.extensions.get(name)?;
1350 let patch = self
1351 .manifest
1352 .patch
1353 .get(name)
1354 .and_then(|patch| patch.path.as_deref());
1355 let store = self.store().join(name);
1356 Some(
1357 match (patch, &entry.npm, &entry.git, entry.path.as_deref()) {
1358 (Some(path), ..) | (None, None, None, Some(path)) => {
1359 let root = self.resolve(path);
1360 (root.clone(), root)
1361 }
1362 (None, Some(npm), _, _) => (store.join("node_modules").join(npm), store),
1363 (None, None, Some(_), _) => (store.clone(), store),
1364 (None, None, None, None) => return None,
1365 },
1366 )
1367 }
1368
1369 fn startup_without_lock(
1374 &mut self,
1375 offline: bool,
1376 report: &mut Report,
1377 ) -> Result<bool, ChestError> {
1378 self.lock = self.read_lock()?;
1379 let Some(lock) = &self.lock else {
1380 report.warnings.push(format!(
1381 "{} has no lock, so nothing from it was installed: run `chest install`",
1382 self.file_name()
1383 ));
1384 return Ok(true);
1385 };
1386 let mut entries = Vec::new();
1387 let mut stale = false;
1388 let mut missing = Vec::new();
1389 for (section, name, entry) in self.entries() {
1390 let locked = lock
1391 .section(section)
1392 .iter()
1393 .find(|locked| &locked.name == name);
1394 let id = format!("{}.{name}", section.key());
1395 let outcome = match self.at_startup(section, name, entry, locked, offline) {
1396 AtStartup::Stale => {
1397 stale = true;
1398 Outcome::Skipped
1399 }
1400 AtStartup::Present => Outcome::Unchanged,
1401 AtStartup::Missing => {
1402 missing.push(id.clone());
1403 Outcome::Skipped
1404 }
1405 AtStartup::Replay => return Ok(false),
1406 };
1407 entries.push((id, outcome));
1408 }
1409 report.entries = entries;
1410 if stale {
1411 report.warnings.push(self.stale_warning());
1412 }
1413 if !missing.is_empty() {
1414 report.warnings.push(format!(
1415 "{}: {} could not be put back offline, so they were skipped",
1416 self.file_name(),
1417 missing.join(", ")
1418 ));
1419 }
1420 Ok(true)
1421 }
1422
1423 fn at_startup(
1427 &self,
1428 section: Section,
1429 name: &str,
1430 entry: &Entry,
1431 locked: Option<&Locked>,
1432 offline: bool,
1433 ) -> AtStartup {
1434 let patched = self.manifest.patch.contains_key(name);
1435 if !patched && !locked.is_some_and(|locked| fresh(entry, locked)) {
1436 return AtStartup::Stale;
1437 }
1438 if patched || locked.is_some_and(|locked| self.present(section, locked)) {
1440 return AtStartup::Present;
1441 }
1442 if offline {
1443 AtStartup::Missing
1444 } else {
1445 AtStartup::Replay
1446 }
1447 }
1448
1449 fn stale_warning(&self) -> String {
1450 format!(
1451 "{}: entries that are not locked, or whose lock is stale, were skipped: run `chest install`",
1452 self.file_name()
1453 )
1454 }
1455
1456 pub fn verify(&self) -> Result<Vec<String>, ChestError> {
1461 let name = self.file_name();
1462 let lock = self
1463 .lock
1464 .as_ref()
1465 .ok_or_else(|| invalid(format!("{name} has no lock: run `chest install`")))?;
1466 Ok(self.problems(lock).iter().map(describe).collect())
1467 }
1468
1469 pub async fn outdated(&self, npm: &mut Npm) -> Result<Vec<Outdated>, ChestError> {
1473 let Some(lock) = &self.lock else {
1474 return Ok(Vec::new());
1475 };
1476 let mut found = Vec::new();
1477 for (section, name, entry) in self.entries() {
1478 let Some(locked) = lock
1479 .section(section)
1480 .iter()
1481 .find(|locked| &locked.name == name)
1482 else {
1483 continue;
1484 };
1485 let id = format!("{}.{name}", section.key());
1486 let failed = |err: ChestError| ChestError::Failed(format!("{id}: {err}"));
1487 if let Some(repo) = &entry.git {
1488 let wanted = fetch::resolve(&self.cache, repo, entry.rev.as_deref())
1489 .await
1490 .map_err(failed)?;
1491 let locked = locked.commit.clone().unwrap_or_default();
1492 found.push(Outdated {
1493 id,
1494 locked,
1495 wanted,
1496 latest: None,
1497 });
1498 } else if let Some(package) = &entry.npm {
1499 let range = entry.version.as_deref().unwrap_or("latest");
1500 let wanted = npm
1501 .latest(package, range)
1502 .await
1503 .map_err(|err| failed(err.into()))?;
1504 let latest = npm.latest(package, "latest").await.ok();
1505 let locked = locked.version.clone().unwrap_or_default();
1506 found.push(Outdated {
1507 id,
1508 locked,
1509 wanted,
1510 latest,
1511 });
1512 }
1513 }
1514 Ok(found)
1515 }
1516
1517 pub async fn fetch_package(&self, entry: &Entry, npm: &mut Npm) -> Result<Fetched, ChestError> {
1522 if entry.git.is_none() && entry.npm.is_none() {
1523 let path = entry
1524 .path
1525 .as_deref()
1526 .ok_or_else(|| invalid("needs `git`, `npm` or `path`"))?;
1527 return Ok(Fetched {
1528 root: self.resolve(path),
1529 _work: None,
1530 });
1531 }
1532 let mut work = fetch::Work::new(&self.cache)?;
1533 let root = work.path();
1534 if let Some(repo) = &entry.git {
1535 let commit = fetch::resolve(&self.cache, repo, entry.rev.as_deref()).await?;
1536 fetch::checkout(&self.cache, repo, &commit, &root).await?;
1537 } else if let Some(package) = &entry.npm {
1538 npm::fetch(npm, package, entry.version.as_deref(), &root).await?;
1539 }
1540 Ok(Fetched {
1541 root,
1542 _work: Some(work),
1543 })
1544 }
1545}
1546
1547#[derive(Clone, Debug, PartialEq, Eq)]
1549enum Problem {
1550 Missing,
1552 Mismatch,
1554 Link(PathBuf),
1556}
1557
1558fn waiting_for(file: &Path) -> String {
1560 format!(
1561 "Waiting for another chest command, which holds the lock of {}...",
1562 file.display()
1563 )
1564}
1565
1566fn describe((id, path, problem): &(String, PathBuf, Problem)) -> String {
1567 let what = match problem {
1568 Problem::Missing => "is missing".to_owned(),
1569 Problem::Mismatch => "does not match the lock".to_owned(),
1570 Problem::Link(canonical) => format!("does not link to {}", canonical.display()),
1571 };
1572 format!("{id}: {} {what}", path.display())
1573}
1574
1575fn locked_items(section: Section, locked: &Locked) -> Vec<(String, Option<String>)> {
1578 if section == Section::Extensions {
1579 let stored = locked.git.is_some() || locked.npm.is_some();
1580 return stored
1581 .then(|| (locked.name.clone(), locked.hash.clone()))
1582 .into_iter()
1583 .collect();
1584 }
1585 if locked.items.is_empty() {
1586 return vec![(
1587 fetch::placed_name(section, &locked.name),
1588 locked.hash.clone(),
1589 )];
1590 }
1591 let items = locked.items.iter();
1592 items
1593 .map(|item| (item.name.clone(), Some(item.hash.clone())))
1594 .collect()
1595}
1596
1597fn has_git(path: &Path) -> bool {
1600 fn walk(dir: &Path) -> bool {
1601 let Ok(entries) = std::fs::read_dir(dir) else {
1602 return false;
1603 };
1604 entries.flatten().any(|entry| {
1605 entry.file_name() == ".git"
1606 || entry.file_type().is_ok_and(|kind| kind.is_dir()) && walk(&entry.path())
1607 })
1608 }
1609 let held = match path.symlink_metadata() {
1610 Ok(metadata) if metadata.is_symlink() => path.canonicalize().ok(),
1611 Ok(_) => Some(path.to_path_buf()),
1612 Err(_) => None,
1613 };
1614 held.is_some_and(|held| walk(&held))
1615}
1616
1617fn copy_checkout(package: &Path, subpath: Option<&str>, staging: &Path) -> Result<(), ChestError> {
1622 let source = package.join(subpath.unwrap_or_default());
1623 fetch::inside(package, &source)?;
1624 if !source.is_dir() {
1625 return Err(invalid(format!(
1626 "`path` must name the folder that holds the extension, not a file: {}",
1627 subpath.unwrap_or_default()
1628 )));
1629 }
1630 place::copy(&source, staging)?;
1631 let modules = staging.join("node_modules");
1632 if modules.symlink_metadata().is_ok() {
1633 std::fs::remove_dir_all(&modules).map_err(io(&modules))?;
1634 }
1635 Ok(())
1636}