Windows review findings on the bundle provenance code: - clones now pin core.autocrlf=false and core.eol=lf so recorded sha256 values reflect repository bytes, not the machine's git config (autocrlf on Windows previously made every text file a false conflict on update), plus core.longpaths=true for deep bundle trees - is_safe_relative_path additionally rejects NTFS alternate data stream colons, reserved device names (con, nul, COM1..), and trailing dots or spaces; such names never come from a valid checkout and previously desynced or failed on Windows - file ownership dedupe compares paths case-insensitively on Windows and macOS where case variants denote one physical file (uninstalling one bundle could previously delete another bundle's file) - a failed git clone no longer leaks its partial tree in the temp dir, and temp cleanup failures are logged instead of swallowed - recording a bundle file outside the config dir (asset dir override) now warns instead of silently producing an undeletable record
1557 lines
51 KiB
Rust
1557 lines
51 KiB
Rust
use super::install_remote::{
|
|
canonical_source_url, owner_qualifier, repo_name_slug, validate_bundle_name,
|
|
};
|
|
use super::{paths, request_context};
|
|
use crate::config::AssetCategory;
|
|
use crate::function::write_file_atomic;
|
|
use crate::utils::IS_STDOUT_TERMINAL;
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
use chrono::{SecondsFormat, Utc};
|
|
use inquire::Confirm;
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
use std::collections::BTreeMap;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub(crate) enum FileAction {
|
|
New,
|
|
Replaced,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub(crate) enum McpAction {
|
|
Added,
|
|
Replaced,
|
|
Renamed,
|
|
Transferred,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub(crate) struct FileRecord {
|
|
/// Relative to the config dir.
|
|
pub(crate) path: String,
|
|
pub(crate) category: String,
|
|
/// Content hash at install time; a later mismatch means the user modified the file.
|
|
pub(crate) sha256: String,
|
|
pub(crate) action: FileAction,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub(crate) struct McpServerRecord {
|
|
pub(crate) name: String,
|
|
pub(crate) action: McpAction,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub(crate) renamed_to: Option<String>,
|
|
/// Hash of the mcp.json entry as written; a later mismatch means the user
|
|
/// modified it. Absent on records made before entry hashing existed.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub(crate) sha256: Option<String>,
|
|
}
|
|
|
|
impl McpServerRecord {
|
|
/// The key this entry actually occupies in mcp.json (the rename target, if any).
|
|
pub(crate) fn effective_key(&self) -> &str {
|
|
self.renamed_to.as_deref().unwrap_or(&self.name)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub(crate) struct BundleRecord {
|
|
pub(crate) source: String,
|
|
#[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")]
|
|
pub(crate) git_ref: Option<String>,
|
|
pub(crate) commit: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub(crate) version: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub(crate) description: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub(crate) homepage: Option<String>,
|
|
pub(crate) installed_at: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub(crate) updated_at: Option<String>,
|
|
#[serde(default)]
|
|
pub(crate) files: Vec<FileRecord>,
|
|
#[serde(default)]
|
|
pub(crate) mcp_servers: Vec<McpServerRecord>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct InstallMetadata {
|
|
pub(crate) source: String,
|
|
pub(crate) git_ref: Option<String>,
|
|
pub(crate) commit: String,
|
|
pub(crate) version: Option<String>,
|
|
pub(crate) description: Option<String>,
|
|
pub(crate) homepage: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub(crate) struct ResolvedBundleName {
|
|
pub(crate) name: String,
|
|
/// The unqualified name this install asked for, when it had to be owner-qualified.
|
|
pub(crate) qualified_from: Option<String>,
|
|
/// Source URL of the different-source bundle that already holds the unqualified name.
|
|
pub(crate) same_name_other_source: Option<String>,
|
|
}
|
|
|
|
const STORE_VERSION: u32 = 1;
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
struct StoreContents {
|
|
#[serde(default)]
|
|
version: u32,
|
|
#[serde(default)]
|
|
bundles: BTreeMap<String, BundleRecord>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct StoreContentsRef<'a> {
|
|
version: u32,
|
|
bundles: &'a BTreeMap<String, BundleRecord>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) struct BundleStore {
|
|
path: PathBuf,
|
|
bundles: BTreeMap<String, BundleRecord>,
|
|
}
|
|
|
|
impl BundleStore {
|
|
pub(crate) fn load() -> Result<Self> {
|
|
Self::load_from(paths::installed_bundles_file())
|
|
}
|
|
|
|
/// A corrupt store is an error, never an empty store: treating it as empty
|
|
/// would let a reinstall re-acquire ownership over files the user may have
|
|
/// modified since.
|
|
pub(crate) fn load_from(path: PathBuf) -> Result<Self> {
|
|
if !path.exists() {
|
|
return Ok(Self {
|
|
path,
|
|
bundles: BTreeMap::new(),
|
|
});
|
|
}
|
|
|
|
let content = fs::read_to_string(&path)
|
|
.with_context(|| format!("failed to read {}", path.display()))?;
|
|
let contents: StoreContents = serde_yaml::from_str(&content).with_context(|| {
|
|
format!(
|
|
"failed to parse {}; refusing to treat it as empty. \
|
|
Restore or repair the file to continue; removing it forfeits \
|
|
uninstall tracking for every installed bundle",
|
|
path.display()
|
|
)
|
|
})?;
|
|
|
|
if contents.version > STORE_VERSION {
|
|
bail!(
|
|
"{} has store version {}, but this coyote build supports up to \
|
|
{STORE_VERSION}; update coyote or restore a matching store",
|
|
path.display(),
|
|
contents.version
|
|
);
|
|
}
|
|
|
|
Ok(Self {
|
|
path,
|
|
bundles: contents.bundles,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn save(&self) -> Result<()> {
|
|
let content = serde_yaml::to_string(&StoreContentsRef {
|
|
version: STORE_VERSION,
|
|
bundles: &self.bundles,
|
|
})
|
|
.context("failed to serialize the installed-bundles store")?;
|
|
|
|
if let Some(parent) = self.path.parent() {
|
|
fs::create_dir_all(parent)
|
|
.with_context(|| format!("failed to create directory {}", parent.display()))?;
|
|
}
|
|
|
|
write_file_atomic(&self.path, &content, None)
|
|
.with_context(|| format!("failed to write {}", self.path.display()))
|
|
}
|
|
|
|
pub(crate) fn get(&self, name: &str) -> Option<&BundleRecord> {
|
|
self.bundles.get(name)
|
|
}
|
|
|
|
pub(crate) fn iter(&self) -> impl Iterator<Item = (&str, &BundleRecord)> {
|
|
self.bundles
|
|
.iter()
|
|
.map(|(name, record)| (name.as_str(), record))
|
|
}
|
|
|
|
pub(crate) fn bundle_names(&self) -> Vec<&str> {
|
|
self.bundles.keys().map(String::as_str).collect()
|
|
}
|
|
|
|
pub(crate) fn find_by_source(&self, url: &str) -> Option<(&str, &BundleRecord)> {
|
|
let canonical = canonical_source_url(url);
|
|
self.bundles
|
|
.iter()
|
|
.find(|(_, record)| canonical_source_url(&record.source) == canonical)
|
|
.map(|(name, record)| (name.as_str(), record))
|
|
}
|
|
|
|
/// A URL already tracked under a different key migrates to the new key, so
|
|
/// the same URL never gets a second record. A name held by a
|
|
/// different-source bundle is owner-qualified instead.
|
|
pub(crate) fn resolve_bundle_name(
|
|
&mut self,
|
|
url: &str,
|
|
manifest_name: Option<&str>,
|
|
) -> Result<ResolvedBundleName> {
|
|
let canonical = canonical_source_url(url);
|
|
let existing_key = self
|
|
.bundles
|
|
.iter()
|
|
.find(|(_, record)| canonical_source_url(&record.source) == canonical)
|
|
.map(|(name, _)| name.clone());
|
|
|
|
let base = match manifest_name {
|
|
Some(name) => {
|
|
validate_bundle_name(name)?;
|
|
name.to_string()
|
|
}
|
|
None => {
|
|
let slug = sanitize_name_segment(&repo_name_slug(url));
|
|
if slug.is_empty() {
|
|
bail!(
|
|
"cannot derive a bundle name from '{url}'; \
|
|
add a coyote-bundle.yaml manifest with a name"
|
|
);
|
|
}
|
|
slug
|
|
}
|
|
};
|
|
|
|
let mut resolved = ResolvedBundleName {
|
|
name: base.clone(),
|
|
qualified_from: None,
|
|
same_name_other_source: None,
|
|
};
|
|
|
|
let reserved = AssetCategory::parse(&base).is_some();
|
|
let collision = self.source_of_other_bundle(&base, &canonical);
|
|
if reserved || collision.is_some() {
|
|
let reason = match &collision {
|
|
Some(other_source) => {
|
|
format!("is already used by an install from '{other_source}'")
|
|
}
|
|
None => "is reserved for an asset category".to_string(),
|
|
};
|
|
|
|
if base.contains('/') {
|
|
bail!(
|
|
"bundle name '{base}' {reason} and cannot be qualified further; \
|
|
uninstall it or pick a different manifest name"
|
|
);
|
|
}
|
|
|
|
let owner = owner_qualifier(url)
|
|
.map(|owner| sanitize_name_segment(&owner))
|
|
.filter(|owner| !owner.is_empty());
|
|
let Some(owner) = owner else {
|
|
bail!(
|
|
"bundle name '{base}' {reason}, and no owner qualifier \
|
|
can be derived from '{url}'"
|
|
);
|
|
};
|
|
let qualified = format!("{owner}/{base}");
|
|
|
|
if let Some(source) = self.source_of_other_bundle(&qualified, &canonical) {
|
|
bail!(
|
|
"bundle name '{base}' {reason}, and '{qualified}' is already \
|
|
used by an install from '{source}'; \
|
|
uninstall one or pick a different manifest name"
|
|
);
|
|
}
|
|
|
|
if let Some(other_source) = &collision
|
|
&& manifest_name.is_some()
|
|
&& *IS_STDOUT_TERMINAL
|
|
{
|
|
let proceed = Confirm::new(&format!(
|
|
"Bundle name '{base}' is already used by an install from \
|
|
'{other_source}' (a fork or typo-squat?). Track this install \
|
|
as '{qualified}'?"
|
|
))
|
|
.with_default(false)
|
|
.prompt()
|
|
.with_context(|| "failed to read bundle name confirmation")?;
|
|
if !proceed {
|
|
bail!("install aborted: bundle name '{base}' {reason}");
|
|
}
|
|
}
|
|
resolved.name = qualified;
|
|
resolved.qualified_from = Some(base);
|
|
resolved.same_name_other_source = collision;
|
|
}
|
|
|
|
let already_recorded = existing_key.as_deref() == Some(resolved.name.as_str());
|
|
if let Some(old_key) = existing_key
|
|
&& !already_recorded
|
|
{
|
|
if self.bundles.contains_key(&resolved.name) {
|
|
bail!(
|
|
"records '{old_key}' and '{}' both track source '{url}'; \
|
|
uninstall one or repair installed-bundles.yaml before continuing",
|
|
resolved.name
|
|
);
|
|
}
|
|
let record = self
|
|
.bundles
|
|
.remove(&old_key)
|
|
.expect("existing_key was found in the map");
|
|
self.bundles.insert(resolved.name.clone(), record);
|
|
println!(
|
|
"Bundle '{old_key}' from {url} is now tracked as '{}'.",
|
|
resolved.name
|
|
);
|
|
self.save()?;
|
|
}
|
|
|
|
if let (Some(from), false) = (&resolved.qualified_from, already_recorded) {
|
|
let detail = match resolved.same_name_other_source.as_deref() {
|
|
Some(other) => format!("is already used by an install from '{other}'"),
|
|
None => "is reserved for an asset category".to_string(),
|
|
};
|
|
println!(
|
|
"Bundle name '{from}' {detail}; tracking this install as '{}'.",
|
|
resolved.name
|
|
);
|
|
}
|
|
|
|
debug_assert!(
|
|
validate_bundle_name(&resolved.name).is_ok(),
|
|
"derived bundle names must satisfy validate_bundle_name"
|
|
);
|
|
Ok(resolved)
|
|
}
|
|
|
|
pub(crate) fn upsert_bundle(&mut self, name: &str, metadata: InstallMetadata) -> Result<()> {
|
|
match self.bundles.get_mut(name) {
|
|
Some(record) => {
|
|
record.source = metadata.source;
|
|
record.git_ref = metadata.git_ref;
|
|
record.commit = metadata.commit;
|
|
record.version = metadata.version;
|
|
record.description = metadata.description;
|
|
record.homepage = metadata.homepage;
|
|
}
|
|
None => {
|
|
self.bundles.insert(
|
|
name.to_string(),
|
|
BundleRecord {
|
|
source: metadata.source,
|
|
git_ref: metadata.git_ref,
|
|
commit: metadata.commit,
|
|
version: metadata.version,
|
|
description: metadata.description,
|
|
homepage: metadata.homepage,
|
|
installed_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
|
updated_at: None,
|
|
files: Vec::new(),
|
|
mcp_servers: Vec::new(),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
self.save()
|
|
}
|
|
|
|
pub(crate) fn mark_updated(&mut self, name: &str) -> Result<()> {
|
|
self.ensure_bundle_exists(name)?;
|
|
let record = self
|
|
.bundles
|
|
.get_mut(name)
|
|
.expect("bundle existence checked above");
|
|
|
|
record.updated_at = Some(Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true));
|
|
|
|
self.save()
|
|
}
|
|
|
|
/// Written only after an update's files and mcp entries land, so an
|
|
/// aborted update cannot leave the record claiming a commit whose content
|
|
/// never finished applying.
|
|
pub(crate) fn set_bundle_versions(
|
|
&mut self,
|
|
name: &str,
|
|
commit: &str,
|
|
version: Option<String>,
|
|
) -> Result<()> {
|
|
self.ensure_bundle_exists(name)?;
|
|
let record = self
|
|
.bundles
|
|
.get_mut(name)
|
|
.expect("bundle existence checked above");
|
|
|
|
record.commit = commit.to_string();
|
|
record.version = version;
|
|
|
|
self.save()
|
|
}
|
|
|
|
pub(crate) fn remove_file_record(&mut self, bundle: &str, path: &str) -> Result<()> {
|
|
self.ensure_bundle_exists(bundle)?;
|
|
let record = self
|
|
.bundles
|
|
.get_mut(bundle)
|
|
.expect("bundle existence checked above");
|
|
|
|
record.files.retain(|owned| owned.path != path);
|
|
|
|
self.save()
|
|
}
|
|
|
|
pub(crate) fn remove_mcp_record(&mut self, bundle: &str, effective_key: &str) -> Result<()> {
|
|
self.ensure_bundle_exists(bundle)?;
|
|
let record = self
|
|
.bundles
|
|
.get_mut(bundle)
|
|
.expect("bundle existence checked above");
|
|
|
|
record
|
|
.mcp_servers
|
|
.retain(|owned| owned.effective_key() != effective_key);
|
|
|
|
self.save()
|
|
}
|
|
|
|
pub(crate) fn remove_bundle(&mut self, name: &str) -> Result<()> {
|
|
self.ensure_bundle_exists(name)?;
|
|
self.bundles.remove(name);
|
|
self.save()
|
|
}
|
|
|
|
/// Persists per call, so an install aborted partway through still has
|
|
/// provenance for everything already on disk.
|
|
/// A path owned by another bundle transfers to `bundle`.
|
|
pub(crate) fn record_file(&mut self, bundle: &str, file: FileRecord) -> Result<()> {
|
|
self.ensure_bundle_exists(bundle)?;
|
|
for (name, record) in self.bundles.iter_mut() {
|
|
if name != bundle {
|
|
record
|
|
.files
|
|
.retain(|owned| !same_installed_path(&owned.path, &file.path));
|
|
}
|
|
}
|
|
let record = self
|
|
.bundles
|
|
.get_mut(bundle)
|
|
.expect("bundle existence checked above");
|
|
|
|
record
|
|
.files
|
|
.retain(|owned| !same_installed_path(&owned.path, &file.path));
|
|
record.files.push(file);
|
|
|
|
self.save()
|
|
}
|
|
|
|
/// An entry whose key any bundle already owns transfers to `bundle`, and
|
|
/// its `replaced` action upgrades to `transferred` only when the prior
|
|
/// record proves bundle origin. A prior `replaced` record marks a
|
|
/// pre-existing user entry that uninstall must never delete, and that
|
|
/// marker survives re-records and cross-bundle transfers.
|
|
pub(crate) fn record_mcp_servers(
|
|
&mut self,
|
|
bundle: &str,
|
|
entries: Vec<McpServerRecord>,
|
|
) -> Result<()> {
|
|
self.ensure_bundle_exists(bundle)?;
|
|
for mut entry in entries {
|
|
let key = entry.effective_key().to_string();
|
|
let mut previously_owned = false;
|
|
let mut prior_user_origin = false;
|
|
for record in self.bundles.values_mut() {
|
|
let before = record.mcp_servers.len();
|
|
record.mcp_servers.retain(|owned| {
|
|
if owned.effective_key() != key {
|
|
return true;
|
|
}
|
|
|
|
if owned.action == McpAction::Replaced {
|
|
prior_user_origin = true;
|
|
}
|
|
|
|
false
|
|
});
|
|
previously_owned |= record.mcp_servers.len() != before;
|
|
}
|
|
|
|
if previously_owned && !prior_user_origin && entry.action == McpAction::Replaced {
|
|
entry.action = McpAction::Transferred;
|
|
}
|
|
|
|
self.bundles
|
|
.get_mut(bundle)
|
|
.expect("bundle existence checked above")
|
|
.mcp_servers
|
|
.push(entry);
|
|
}
|
|
|
|
self.save()
|
|
}
|
|
|
|
fn ensure_bundle_exists(&self, bundle: &str) -> Result<()> {
|
|
if !self.bundles.contains_key(bundle) {
|
|
bail!(
|
|
"no installed bundle named '{bundle}' (installed: {})",
|
|
if self.bundles.is_empty() {
|
|
"none".to_string()
|
|
} else {
|
|
self.bundle_names().join(", ")
|
|
}
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn source_of_other_bundle(&self, name: &str, canonical: &str) -> Option<String> {
|
|
self.bundles
|
|
.get(name)
|
|
.filter(|record| canonical_source_url(&record.source) != canonical)
|
|
.map(|record| record.source.clone())
|
|
}
|
|
}
|
|
|
|
pub(crate) fn hash_bytes(bytes: &[u8]) -> String {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(bytes);
|
|
format!("{:x}", hasher.finalize())
|
|
}
|
|
|
|
pub(crate) fn hash_file(path: &Path) -> Result<String> {
|
|
let bytes =
|
|
fs::read(path).with_context(|| format!("failed to read {} for hashing", path.display()))?;
|
|
Ok(hash_bytes(&bytes))
|
|
}
|
|
|
|
#[derive(Debug, Default, PartialEq, Eq)]
|
|
pub(crate) struct DriftSummary {
|
|
pub(crate) intact: usize,
|
|
pub(crate) modified: usize,
|
|
pub(crate) missing: usize,
|
|
}
|
|
|
|
impl DriftSummary {
|
|
pub(crate) fn display(&self) -> String {
|
|
if self.intact + self.modified + self.missing == 0 {
|
|
return "-".to_string();
|
|
}
|
|
let mut parts = Vec::new();
|
|
|
|
if self.intact > 0 {
|
|
parts.push(format!("{} intact", self.intact));
|
|
}
|
|
|
|
if self.modified > 0 {
|
|
parts.push(format!("{} modified locally", self.modified));
|
|
}
|
|
|
|
if self.missing > 0 {
|
|
parts.push(format!("{} missing", self.missing));
|
|
}
|
|
|
|
parts.join(", ")
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) struct BundleListRow {
|
|
pub(crate) name: String,
|
|
pub(crate) version: String,
|
|
pub(crate) source: String,
|
|
pub(crate) git_ref: String,
|
|
pub(crate) installed_at: String,
|
|
pub(crate) file_counts: String,
|
|
pub(crate) drift: DriftSummary,
|
|
}
|
|
|
|
/// NTFS and default APFS resolve file names case-insensitively, so records
|
|
/// differing only in case denote the same physical file there. Linux keeps
|
|
/// exact matching because case variants are genuinely distinct files.
|
|
fn same_installed_path(a: &str, b: &str) -> bool {
|
|
if cfg!(any(windows, target_os = "macos")) {
|
|
a.eq_ignore_ascii_case(b)
|
|
} else {
|
|
a == b
|
|
}
|
|
}
|
|
|
|
/// An unreadable file counts as locally modified: it exists but its integrity
|
|
/// cannot be verified.
|
|
pub(crate) fn bundle_list_rows(store: &BundleStore, config_dir: &Path) -> Vec<BundleListRow> {
|
|
store
|
|
.iter()
|
|
.map(|(name, record)| {
|
|
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
|
|
let mut drift = DriftSummary::default();
|
|
for file in &record.files {
|
|
*counts.entry(file.category.as_str()).or_default() += 1;
|
|
let path = config_dir.join(&file.path);
|
|
if !path.exists() {
|
|
drift.missing += 1;
|
|
} else {
|
|
match hash_file(&path) {
|
|
Ok(hash) if hash == file.sha256 => drift.intact += 1,
|
|
_ => drift.modified += 1,
|
|
}
|
|
}
|
|
}
|
|
let file_counts = if counts.is_empty() {
|
|
"-".to_string()
|
|
} else {
|
|
counts
|
|
.iter()
|
|
.map(|(category, count)| format!("{category}: {count}"))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
};
|
|
BundleListRow {
|
|
name: name.to_string(),
|
|
version: record
|
|
.version
|
|
.clone()
|
|
.unwrap_or_else(|| record.commit.chars().take(7).collect()),
|
|
source: record.source.clone(),
|
|
git_ref: record.git_ref.clone().unwrap_or_else(|| "-".to_string()),
|
|
installed_at: record.installed_at.clone(),
|
|
file_counts,
|
|
drift,
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub fn list_installed_bundles() -> Result<()> {
|
|
let store = BundleStore::load()?;
|
|
let rows = bundle_list_rows(&store, &paths::config_dir());
|
|
if rows.is_empty() {
|
|
println!(
|
|
"No bundles installed. Install one with `coyote --install <git-url|owner/repo>` \
|
|
or, from the REPL, `.install <git-url|owner/repo>` (see `.install --help`)."
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
let mut table = request_context::asset_table(&[
|
|
"name",
|
|
"version",
|
|
"source",
|
|
"ref",
|
|
"installed",
|
|
"files",
|
|
"drift",
|
|
]);
|
|
for row in rows {
|
|
table.add_row(vec![
|
|
row.name.as_str(),
|
|
&row.version,
|
|
&row.source,
|
|
&row.git_ref,
|
|
&row.installed_at,
|
|
&row.file_counts,
|
|
&row.drift.display(),
|
|
]);
|
|
}
|
|
|
|
println!("Bundles:");
|
|
println!("{table}");
|
|
Ok(())
|
|
}
|
|
|
|
fn sanitize_name_segment(segment: &str) -> String {
|
|
segment
|
|
.chars()
|
|
.map(|c| {
|
|
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
|
c
|
|
} else {
|
|
'-'
|
|
}
|
|
})
|
|
.collect::<String>()
|
|
.trim_matches('-')
|
|
.to_string()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::utils::{get_env_name, temp_file};
|
|
use serial_test::serial;
|
|
use std::env;
|
|
use std::ffi::OsString;
|
|
|
|
struct TempStoreDir(PathBuf);
|
|
|
|
impl TempStoreDir {
|
|
fn new(label: &str) -> Self {
|
|
let dir = temp_file(label, "");
|
|
fs::create_dir_all(&dir).unwrap();
|
|
Self(dir)
|
|
}
|
|
|
|
fn store_path(&self) -> PathBuf {
|
|
self.0.join("installed-bundles.yaml")
|
|
}
|
|
|
|
fn store(&self) -> BundleStore {
|
|
BundleStore::load_from(self.store_path()).unwrap()
|
|
}
|
|
}
|
|
|
|
impl Drop for TempStoreDir {
|
|
fn drop(&mut self) {
|
|
let _ = fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
fn metadata(source: &str, commit: &str) -> InstallMetadata {
|
|
InstallMetadata {
|
|
source: source.to_string(),
|
|
git_ref: None,
|
|
commit: commit.to_string(),
|
|
version: None,
|
|
description: None,
|
|
homepage: None,
|
|
}
|
|
}
|
|
|
|
fn file_record(path: &str, contents: &str) -> FileRecord {
|
|
FileRecord {
|
|
path: path.to_string(),
|
|
category: "macros".to_string(),
|
|
sha256: hash_bytes(contents.as_bytes()),
|
|
action: FileAction::New,
|
|
}
|
|
}
|
|
|
|
fn mcp_record(name: &str, action: McpAction, renamed_to: Option<&str>) -> McpServerRecord {
|
|
McpServerRecord {
|
|
name: name.to_string(),
|
|
action,
|
|
renamed_to: renamed_to.map(str::to_string),
|
|
sha256: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn load_missing_file_yields_empty_store() {
|
|
let dir = TempStoreDir::new("bundles-empty");
|
|
|
|
let store = dir.store();
|
|
|
|
assert!(store.bundle_names().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn corrupt_store_fails_closed() {
|
|
let dir = TempStoreDir::new("bundles-corrupt");
|
|
fs::write(dir.store_path(), "bundles:\n - this is not a map\n").unwrap();
|
|
|
|
let result = BundleStore::load_from(dir.store_path());
|
|
|
|
let message = format!("{:#}", result.unwrap_err());
|
|
assert!(
|
|
message.contains("refusing to treat it as empty"),
|
|
"{message}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn save_and_reload_roundtrip() {
|
|
let dir = TempStoreDir::new("bundles-roundtrip");
|
|
let mut store = dir.store();
|
|
store
|
|
.upsert_bundle(
|
|
"omc",
|
|
InstallMetadata {
|
|
source: "https://github.com/x/omc".to_string(),
|
|
git_ref: Some("main".to_string()),
|
|
commit: "abc123".to_string(),
|
|
version: Some("1.4.0".to_string()),
|
|
description: Some("Opinionated roles and macros".to_string()),
|
|
homepage: Some("https://github.com/x/omc".to_string()),
|
|
},
|
|
)
|
|
.unwrap();
|
|
store
|
|
.record_file("omc", file_record("macros/a.yaml", "a"))
|
|
.unwrap();
|
|
store
|
|
.record_mcp_servers("omc", vec![mcp_record("srv", McpAction::Added, None)])
|
|
.unwrap();
|
|
|
|
let raw = fs::read_to_string(dir.store_path()).unwrap();
|
|
let reloaded = dir.store();
|
|
|
|
assert!(raw.contains("ref: main"), "{raw}");
|
|
assert!(!raw.contains("git_ref"), "{raw}");
|
|
assert!(raw.contains("action: added"), "{raw}");
|
|
let record = reloaded.get("omc").unwrap();
|
|
assert_eq!(record.git_ref.as_deref(), Some("main"));
|
|
assert_eq!(record.version.as_deref(), Some("1.4.0"));
|
|
assert_eq!(
|
|
record.description.as_deref(),
|
|
Some("Opinionated roles and macros")
|
|
);
|
|
assert_eq!(record.homepage.as_deref(), Some("https://github.com/x/omc"));
|
|
assert_eq!(record.files.len(), 1);
|
|
assert_eq!(record.mcp_servers.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn files_recorded_before_an_abort_survive_it() {
|
|
let dir = TempStoreDir::new("bundles-abort");
|
|
let mut store = dir.store();
|
|
store
|
|
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
|
|
.unwrap();
|
|
|
|
store
|
|
.record_file("omc", file_record("macros/a.yaml", "a"))
|
|
.unwrap();
|
|
store
|
|
.record_file("omc", file_record("skills/b.md", "b"))
|
|
.unwrap();
|
|
drop(store);
|
|
|
|
let reloaded = dir.store();
|
|
let paths: Vec<&str> = reloaded
|
|
.get("omc")
|
|
.unwrap()
|
|
.files
|
|
.iter()
|
|
.map(|f| f.path.as_str())
|
|
.collect();
|
|
assert_eq!(paths, vec!["macros/a.yaml", "skills/b.md"]);
|
|
}
|
|
|
|
#[test]
|
|
fn recording_the_same_path_twice_updates_in_place() {
|
|
let dir = TempStoreDir::new("bundles-dedupe");
|
|
let mut store = dir.store();
|
|
store
|
|
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
|
|
.unwrap();
|
|
|
|
store
|
|
.record_file("omc", file_record("macros/a.yaml", "v1"))
|
|
.unwrap();
|
|
let mut updated = file_record("macros/a.yaml", "v2");
|
|
updated.action = FileAction::Replaced;
|
|
store.record_file("omc", updated).unwrap();
|
|
|
|
let record = store.get("omc").unwrap();
|
|
assert_eq!(record.files.len(), 1);
|
|
assert_eq!(record.files[0].sha256, hash_bytes(b"v2"));
|
|
assert_eq!(record.files[0].action, FileAction::Replaced);
|
|
}
|
|
|
|
#[test]
|
|
fn overwritten_file_transfers_ownership() {
|
|
let dir = TempStoreDir::new("bundles-transfer");
|
|
let mut store = dir.store();
|
|
store
|
|
.upsert_bundle("alpha", metadata("https://github.com/a/alpha", "abc123"))
|
|
.unwrap();
|
|
store
|
|
.upsert_bundle("beta", metadata("https://github.com/b/beta", "def456"))
|
|
.unwrap();
|
|
store
|
|
.record_file("alpha", file_record("macros/shared.yaml", "a"))
|
|
.unwrap();
|
|
|
|
let mut taken = file_record("macros/shared.yaml", "b");
|
|
taken.action = FileAction::Replaced;
|
|
store.record_file("beta", taken).unwrap();
|
|
|
|
let reloaded = dir.store();
|
|
assert!(reloaded.get("alpha").unwrap().files.is_empty());
|
|
let beta_files = &reloaded.get("beta").unwrap().files;
|
|
assert_eq!(beta_files.len(), 1);
|
|
assert_eq!(beta_files[0].path, "macros/shared.yaml");
|
|
}
|
|
|
|
#[test]
|
|
fn overwritten_mcp_entry_transfers_ownership_as_transferred() {
|
|
let dir = TempStoreDir::new("bundles-mcp-transfer");
|
|
let mut store = dir.store();
|
|
store
|
|
.upsert_bundle("alpha", metadata("https://github.com/a/alpha", "abc123"))
|
|
.unwrap();
|
|
store
|
|
.upsert_bundle("beta", metadata("https://github.com/b/beta", "def456"))
|
|
.unwrap();
|
|
store
|
|
.record_mcp_servers("alpha", vec![mcp_record("srv", McpAction::Added, None)])
|
|
.unwrap();
|
|
|
|
store
|
|
.record_mcp_servers("beta", vec![mcp_record("srv", McpAction::Replaced, None)])
|
|
.unwrap();
|
|
|
|
let reloaded = dir.store();
|
|
assert!(reloaded.get("alpha").unwrap().mcp_servers.is_empty());
|
|
let beta_servers = &reloaded.get("beta").unwrap().mcp_servers;
|
|
assert_eq!(beta_servers.len(), 1);
|
|
assert_eq!(beta_servers[0].action, McpAction::Transferred);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_replacement_of_unowned_entry_stays_replaced() {
|
|
let dir = TempStoreDir::new("bundles-mcp-replaced");
|
|
let mut store = dir.store();
|
|
store
|
|
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
|
|
.unwrap();
|
|
|
|
store
|
|
.record_mcp_servers(
|
|
"omc",
|
|
vec![mcp_record("user-srv", McpAction::Replaced, None)],
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
store.get("omc").unwrap().mcp_servers[0].action,
|
|
McpAction::Replaced
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_rerecord_of_own_entry_upgrades_replaced_to_transferred() {
|
|
let dir = TempStoreDir::new("bundles-mcp-self-update");
|
|
let mut store = dir.store();
|
|
store
|
|
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
|
|
.unwrap();
|
|
store
|
|
.record_mcp_servers("omc", vec![mcp_record("srv", McpAction::Added, None)])
|
|
.unwrap();
|
|
|
|
let mut updated = mcp_record("srv", McpAction::Replaced, None);
|
|
updated.sha256 = Some("deadbeef".to_string());
|
|
store.record_mcp_servers("omc", vec![updated]).unwrap();
|
|
|
|
let servers = &store.get("omc").unwrap().mcp_servers;
|
|
assert_eq!(servers.len(), 1);
|
|
assert_eq!(servers[0].action, McpAction::Transferred);
|
|
assert_eq!(servers[0].sha256.as_deref(), Some("deadbeef"));
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_rerecord_of_own_replaced_entry_stays_replaced() {
|
|
let dir = TempStoreDir::new("bundles-mcp-sticky-self");
|
|
let mut store = dir.store();
|
|
store
|
|
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
|
|
.unwrap();
|
|
store
|
|
.record_mcp_servers(
|
|
"omc",
|
|
vec![mcp_record("user-srv", McpAction::Replaced, None)],
|
|
)
|
|
.unwrap();
|
|
|
|
store
|
|
.record_mcp_servers(
|
|
"omc",
|
|
vec![mcp_record("user-srv", McpAction::Replaced, None)],
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
store.get("omc").unwrap().mcp_servers[0].action,
|
|
McpAction::Replaced
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_transfer_of_replaced_entry_keeps_user_origin_marker() {
|
|
let dir = TempStoreDir::new("bundles-mcp-sticky-transfer");
|
|
let mut store = dir.store();
|
|
store
|
|
.upsert_bundle("alpha", metadata("https://github.com/a/alpha", "abc123"))
|
|
.unwrap();
|
|
store
|
|
.upsert_bundle("beta", metadata("https://github.com/b/beta", "def456"))
|
|
.unwrap();
|
|
store
|
|
.record_mcp_servers(
|
|
"alpha",
|
|
vec![mcp_record("user-srv", McpAction::Replaced, None)],
|
|
)
|
|
.unwrap();
|
|
|
|
store
|
|
.record_mcp_servers(
|
|
"beta",
|
|
vec![mcp_record("user-srv", McpAction::Replaced, None)],
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(store.get("alpha").unwrap().mcp_servers.is_empty());
|
|
assert_eq!(
|
|
store.get("beta").unwrap().mcp_servers[0].action,
|
|
McpAction::Replaced
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_transfer_matches_renamed_entries_by_effective_key() {
|
|
let dir = TempStoreDir::new("bundles-mcp-renamed");
|
|
let mut store = dir.store();
|
|
store
|
|
.upsert_bundle("alpha", metadata("https://github.com/a/alpha", "abc123"))
|
|
.unwrap();
|
|
store
|
|
.upsert_bundle("beta", metadata("https://github.com/b/beta", "def456"))
|
|
.unwrap();
|
|
store
|
|
.record_mcp_servers(
|
|
"alpha",
|
|
vec![mcp_record("srv", McpAction::Renamed, Some("srv-remote"))],
|
|
)
|
|
.unwrap();
|
|
|
|
store
|
|
.record_mcp_servers(
|
|
"beta",
|
|
vec![mcp_record("srv-remote", McpAction::Replaced, None)],
|
|
)
|
|
.unwrap();
|
|
|
|
let reloaded = dir.store();
|
|
assert!(reloaded.get("alpha").unwrap().mcp_servers.is_empty());
|
|
assert_eq!(
|
|
reloaded.get("beta").unwrap().mcp_servers[0].action,
|
|
McpAction::Transferred
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_same_url_same_name_is_an_update() {
|
|
let dir = TempStoreDir::new("bundles-resolve-update");
|
|
let mut store = dir.store();
|
|
store
|
|
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
|
|
.unwrap();
|
|
|
|
let resolved = store
|
|
.resolve_bundle_name("git@github.com:x/omc.git", None)
|
|
.unwrap();
|
|
|
|
assert_eq!(resolved.name, "omc");
|
|
assert_eq!(resolved.qualified_from, None);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_migrates_record_when_identity_changes() {
|
|
let dir = TempStoreDir::new("bundles-migrate");
|
|
let mut store = dir.store();
|
|
store
|
|
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
|
|
.unwrap();
|
|
store
|
|
.record_file("omc", file_record("macros/a.yaml", "a"))
|
|
.unwrap();
|
|
|
|
let resolved = store
|
|
.resolve_bundle_name("git@github.com:x/omc.git", Some("oh-my-coyote"))
|
|
.unwrap();
|
|
|
|
assert_eq!(resolved.name, "oh-my-coyote");
|
|
let reloaded = dir.store();
|
|
assert!(reloaded.get("omc").is_none());
|
|
assert_eq!(reloaded.get("oh-my-coyote").unwrap().files.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_qualifies_colliding_name_from_https_source() {
|
|
let dir = TempStoreDir::new("bundles-qualify-https");
|
|
let mut store = dir.store();
|
|
store
|
|
.upsert_bundle("repo", metadata("https://github.com/a/repo", "abc123"))
|
|
.unwrap();
|
|
|
|
let resolved = store
|
|
.resolve_bundle_name("https://gitlab.com/b/repo.git", None)
|
|
.unwrap();
|
|
|
|
assert_eq!(resolved.name, "b/repo");
|
|
assert_eq!(resolved.qualified_from.as_deref(), Some("repo"));
|
|
assert_eq!(
|
|
resolved.same_name_other_source.as_deref(),
|
|
Some("https://github.com/a/repo")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_qualifies_colliding_name_from_scp_source() {
|
|
let dir = TempStoreDir::new("bundles-qualify-scp");
|
|
let mut store = dir.store();
|
|
store
|
|
.upsert_bundle("repo", metadata("https://github.com/a/repo", "abc123"))
|
|
.unwrap();
|
|
|
|
let resolved = store
|
|
.resolve_bundle_name("git@bitbucket.org:c/repo.git", None)
|
|
.unwrap();
|
|
|
|
assert_eq!(resolved.name, "c/repo");
|
|
assert_eq!(resolved.qualified_from.as_deref(), Some("repo"));
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_of_already_qualified_record_is_stable() {
|
|
let dir = TempStoreDir::new("bundles-qualify-stable");
|
|
let mut store = dir.store();
|
|
store
|
|
.upsert_bundle("repo", metadata("https://github.com/a/repo", "abc123"))
|
|
.unwrap();
|
|
store
|
|
.upsert_bundle("b/repo", metadata("https://gitlab.com/b/repo", "def456"))
|
|
.unwrap();
|
|
|
|
let resolved = store
|
|
.resolve_bundle_name("https://gitlab.com/b/repo.git", None)
|
|
.unwrap();
|
|
|
|
assert_eq!(resolved.name, "b/repo");
|
|
assert_eq!(resolved.qualified_from.as_deref(), Some("repo"));
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_sanitizes_derived_names() {
|
|
let dir = TempStoreDir::new("bundles-sanitize");
|
|
let mut store = dir.store();
|
|
|
|
let resolved = store
|
|
.resolve_bundle_name("https://github.com/vercel/next.js.git", None)
|
|
.unwrap();
|
|
|
|
assert_eq!(resolved.name, "next-js");
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_reserves_asset_category_names() {
|
|
let dir = TempStoreDir::new("bundles-reserved");
|
|
let mut store = dir.store();
|
|
|
|
let resolved = store
|
|
.resolve_bundle_name("https://github.com/x/agents", None)
|
|
.unwrap();
|
|
|
|
assert_eq!(resolved.name, "x/agents");
|
|
assert_eq!(resolved.qualified_from.as_deref(), Some("agents"));
|
|
assert!(resolved.same_name_other_source.is_none());
|
|
|
|
let resolved = store
|
|
.resolve_bundle_name("https://github.com/y/repo", Some("mcp_config"))
|
|
.unwrap();
|
|
|
|
assert_eq!(resolved.name, "y/mcp_config");
|
|
assert_eq!(resolved.qualified_from.as_deref(), Some("mcp_config"));
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_rejects_invalid_manifest_names() {
|
|
let dir = TempStoreDir::new("bundles-invalid-name");
|
|
let mut store = dir.store();
|
|
|
|
let result = store.resolve_bundle_name("https://github.com/x/repo", Some("a/b/c"));
|
|
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn upsert_merges_metadata_and_preserves_files_and_install_time() {
|
|
let dir = TempStoreDir::new("bundles-merge");
|
|
let mut store = dir.store();
|
|
store
|
|
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
|
|
.unwrap();
|
|
store
|
|
.record_file("omc", file_record("macros/a.yaml", "a"))
|
|
.unwrap();
|
|
let installed_at = store.get("omc").unwrap().installed_at.clone();
|
|
|
|
store
|
|
.upsert_bundle("omc", metadata("https://github.com/x/omc.git", "def456"))
|
|
.unwrap();
|
|
|
|
let record = store.get("omc").unwrap();
|
|
assert_eq!(record.commit, "def456");
|
|
assert_eq!(record.source, "https://github.com/x/omc.git");
|
|
assert_eq!(record.installed_at, installed_at);
|
|
assert_eq!(record.files.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn recording_against_unknown_bundle_fails() {
|
|
let dir = TempStoreDir::new("bundles-unknown");
|
|
let mut store = dir.store();
|
|
|
|
let result = store.record_file("ghost", file_record("macros/a.yaml", "a"));
|
|
|
|
assert!(result.unwrap_err().to_string().contains("ghost"));
|
|
}
|
|
|
|
#[test]
|
|
fn mark_updated_stamps_and_round_trips() {
|
|
let dir = TempStoreDir::new("bundles-mark-updated");
|
|
let mut store = dir.store();
|
|
store
|
|
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
|
|
.unwrap();
|
|
let raw_before = fs::read_to_string(dir.store_path()).unwrap();
|
|
assert!(!raw_before.contains("updated_at"), "{raw_before}");
|
|
assert_eq!(store.get("omc").unwrap().updated_at, None);
|
|
|
|
store.mark_updated("omc").unwrap();
|
|
|
|
let raw_after = fs::read_to_string(dir.store_path()).unwrap();
|
|
assert!(raw_after.contains("updated_at"), "{raw_after}");
|
|
let reloaded = dir.store();
|
|
assert!(reloaded.get("omc").unwrap().updated_at.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn mark_updated_unknown_bundle_fails() {
|
|
let dir = TempStoreDir::new("bundles-mark-unknown");
|
|
let mut store = dir.store();
|
|
|
|
let result = store.mark_updated("ghost");
|
|
|
|
assert!(result.unwrap_err().to_string().contains("ghost"));
|
|
}
|
|
|
|
#[test]
|
|
fn remove_file_record_drops_only_the_named_path() {
|
|
let dir = TempStoreDir::new("bundles-remove-file");
|
|
let mut store = dir.store();
|
|
store
|
|
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
|
|
.unwrap();
|
|
store
|
|
.record_file("omc", file_record("macros/a.yaml", "a"))
|
|
.unwrap();
|
|
store
|
|
.record_file("omc", file_record("macros/b.yaml", "b"))
|
|
.unwrap();
|
|
|
|
store.remove_file_record("omc", "macros/a.yaml").unwrap();
|
|
|
|
let reloaded = dir.store();
|
|
let paths: Vec<&str> = reloaded
|
|
.get("omc")
|
|
.unwrap()
|
|
.files
|
|
.iter()
|
|
.map(|f| f.path.as_str())
|
|
.collect();
|
|
assert_eq!(paths, vec!["macros/b.yaml"]);
|
|
}
|
|
|
|
#[test]
|
|
fn remove_file_record_unknown_bundle_fails() {
|
|
let dir = TempStoreDir::new("bundles-remove-unknown");
|
|
let mut store = dir.store();
|
|
|
|
let result = store.remove_file_record("ghost", "macros/a.yaml");
|
|
|
|
assert!(result.unwrap_err().to_string().contains("ghost"));
|
|
}
|
|
|
|
#[test]
|
|
fn remove_mcp_record_drops_only_the_named_effective_key() {
|
|
let dir = TempStoreDir::new("bundles-remove-mcp");
|
|
let mut store = dir.store();
|
|
store
|
|
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
|
|
.unwrap();
|
|
store
|
|
.record_mcp_servers(
|
|
"omc",
|
|
vec![
|
|
mcp_record("srv", McpAction::Renamed, Some("srv-remote")),
|
|
mcp_record("other", McpAction::Added, None),
|
|
],
|
|
)
|
|
.unwrap();
|
|
|
|
store.remove_mcp_record("omc", "srv-remote").unwrap();
|
|
|
|
let reloaded = dir.store();
|
|
let keys: Vec<&str> = reloaded
|
|
.get("omc")
|
|
.unwrap()
|
|
.mcp_servers
|
|
.iter()
|
|
.map(|s| s.effective_key())
|
|
.collect();
|
|
assert_eq!(keys, vec!["other"]);
|
|
}
|
|
|
|
#[test]
|
|
fn remove_bundle_deletes_the_record_and_persists() {
|
|
let dir = TempStoreDir::new("bundles-remove-bundle");
|
|
let mut store = dir.store();
|
|
store
|
|
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
|
|
.unwrap();
|
|
|
|
store.remove_bundle("omc").unwrap();
|
|
|
|
let reloaded = dir.store();
|
|
assert!(reloaded.get("omc").is_none());
|
|
assert!(reloaded.bundle_names().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn remove_bundle_unknown_name_fails() {
|
|
let dir = TempStoreDir::new("bundles-remove-bundle-unknown");
|
|
let mut store = dir.store();
|
|
|
|
let result = store.remove_bundle("ghost");
|
|
|
|
assert!(result.unwrap_err().to_string().contains("ghost"));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn failed_save_preserves_the_existing_store() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let dir = TempStoreDir::new("bundles-atomic");
|
|
let mut store = dir.store();
|
|
store
|
|
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
|
|
.unwrap();
|
|
let before = fs::read_to_string(dir.store_path()).unwrap();
|
|
|
|
fs::set_permissions(&dir.0, fs::Permissions::from_mode(0o555)).unwrap();
|
|
let probe = dir.0.join(".write-probe");
|
|
if fs::write(&probe, "x").is_ok() {
|
|
// A privileged user bypasses permission bits; the failure path
|
|
// cannot be provoked this way.
|
|
let _ = fs::remove_file(&probe);
|
|
fs::set_permissions(&dir.0, fs::Permissions::from_mode(0o755)).unwrap();
|
|
return;
|
|
}
|
|
|
|
let result = store.record_file("omc", file_record("macros/a.yaml", "a"));
|
|
fs::set_permissions(&dir.0, fs::Permissions::from_mode(0o755)).unwrap();
|
|
|
|
assert!(result.is_err());
|
|
assert_eq!(fs::read_to_string(dir.store_path()).unwrap(), before);
|
|
let reloaded = dir.store();
|
|
assert!(reloaded.get("omc").unwrap().files.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn hash_helpers_are_stable() {
|
|
let dir = TempStoreDir::new("bundles-hash");
|
|
let path = dir.0.join("artifact.yaml");
|
|
fs::write(&path, "hello").unwrap();
|
|
|
|
assert_eq!(
|
|
hash_bytes(b"hello"),
|
|
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
|
);
|
|
assert_eq!(hash_file(&path).unwrap(), hash_bytes(b"hello"));
|
|
assert_ne!(hash_bytes(b"hello"), hash_bytes(b"hello!"));
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn default_store_path_follows_the_config_dir() {
|
|
let dir = TempStoreDir::new("bundles-env");
|
|
let key = get_env_name("config_dir");
|
|
let previous: Option<OsString> = env::var_os(&key);
|
|
unsafe {
|
|
env::set_var(&key, &dir.0);
|
|
}
|
|
|
|
let result = (|| -> Result<()> {
|
|
let mut store = BundleStore::load()?;
|
|
assert!(store.bundle_names().is_empty());
|
|
store.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))?;
|
|
let reloaded = BundleStore::load()?;
|
|
assert!(reloaded.get("omc").is_some());
|
|
assert!(dir.store_path().is_file());
|
|
Ok(())
|
|
})();
|
|
|
|
unsafe {
|
|
match &previous {
|
|
Some(value) => env::set_var(&key, value),
|
|
None => env::remove_var(&key),
|
|
}
|
|
}
|
|
result.unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn bundle_rows_classify_drift_per_file() {
|
|
let dir = TempStoreDir::new("bundles-list-drift");
|
|
let mut store = dir.store();
|
|
store
|
|
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
|
|
.unwrap();
|
|
|
|
fs::create_dir_all(dir.0.join("macros")).unwrap();
|
|
fs::write(dir.0.join("macros/intact.yaml"), "a").unwrap();
|
|
store
|
|
.record_file("omc", file_record("macros/intact.yaml", "a"))
|
|
.unwrap();
|
|
|
|
fs::create_dir_all(dir.0.join("skills")).unwrap();
|
|
fs::write(dir.0.join("skills/modified.md"), "changed").unwrap();
|
|
let mut modified = file_record("skills/modified.md", "original");
|
|
modified.category = "skills".to_string();
|
|
store.record_file("omc", modified).unwrap();
|
|
|
|
let mut missing = file_record("roles/missing.md", "gone");
|
|
missing.category = "roles".to_string();
|
|
store.record_file("omc", missing).unwrap();
|
|
|
|
let rows = bundle_list_rows(&store, &dir.0);
|
|
|
|
assert_eq!(rows.len(), 1);
|
|
let row = &rows[0];
|
|
assert_eq!(row.name, "omc");
|
|
assert_eq!(row.source, "https://github.com/x/omc");
|
|
assert_eq!(
|
|
row.drift,
|
|
DriftSummary {
|
|
intact: 1,
|
|
modified: 1,
|
|
missing: 1,
|
|
}
|
|
);
|
|
assert_eq!(row.file_counts, "macros: 1, roles: 1, skills: 1");
|
|
assert_eq!(
|
|
row.drift.display(),
|
|
"1 intact, 1 modified locally, 1 missing"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bundle_rows_fall_back_to_the_short_commit_when_unversioned() {
|
|
let dir = TempStoreDir::new("bundles-list-fallback");
|
|
let mut store = dir.store();
|
|
store
|
|
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123def456"))
|
|
.unwrap();
|
|
|
|
let rows = bundle_list_rows(&store, &dir.0);
|
|
|
|
assert_eq!(rows[0].version, "abc123d");
|
|
assert_eq!(rows[0].git_ref, "-");
|
|
assert_eq!(rows[0].file_counts, "-");
|
|
assert_eq!(rows[0].drift, DriftSummary::default());
|
|
assert_eq!(rows[0].drift.display(), "-");
|
|
}
|
|
|
|
#[test]
|
|
fn bundle_rows_show_manifest_version_and_pinned_ref() {
|
|
let dir = TempStoreDir::new("bundles-list-versioned");
|
|
let mut store = dir.store();
|
|
store
|
|
.upsert_bundle(
|
|
"omc",
|
|
InstallMetadata {
|
|
source: "git@github.com:x/omc.git".to_string(),
|
|
git_ref: Some("v1.4.0".to_string()),
|
|
commit: "abc123def456".to_string(),
|
|
version: Some("1.4.0".to_string()),
|
|
description: None,
|
|
homepage: None,
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
let rows = bundle_list_rows(&store, &dir.0);
|
|
|
|
assert_eq!(rows[0].version, "1.4.0");
|
|
assert_eq!(rows[0].git_ref, "v1.4.0");
|
|
assert_eq!(rows[0].source, "git@github.com:x/omc.git");
|
|
assert!(!rows[0].installed_at.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn bundle_rows_are_empty_for_an_empty_store() {
|
|
let dir = TempStoreDir::new("bundles-list-empty");
|
|
|
|
let rows = bundle_list_rows(&dir.store(), &dir.0);
|
|
|
|
assert!(rows.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn same_installed_path_matches_filesystem_case_semantics() {
|
|
assert!(same_installed_path("macros/a.yaml", "macros/a.yaml"));
|
|
assert!(!same_installed_path("macros/a.yaml", "macros/b.yaml"));
|
|
assert_eq!(
|
|
same_installed_path("macros/Foo.yaml", "macros/foo.yaml"),
|
|
cfg!(any(windows, target_os = "macos"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn record_file_transfers_case_variant_ownership_on_case_insensitive_hosts() {
|
|
let dir = TempStoreDir::new("bundles-case-variant");
|
|
let mut store = dir.store();
|
|
store
|
|
.upsert_bundle("alpha", metadata("https://x/a", "aaa"))
|
|
.unwrap();
|
|
store
|
|
.upsert_bundle("beta", metadata("https://x/b", "bbb"))
|
|
.unwrap();
|
|
store
|
|
.record_file("alpha", file_record("macros/Shared.yaml", "one"))
|
|
.unwrap();
|
|
|
|
store
|
|
.record_file("beta", file_record("macros/shared.yaml", "two"))
|
|
.unwrap();
|
|
|
|
let alpha_still_owns = store
|
|
.get("alpha")
|
|
.unwrap()
|
|
.files
|
|
.iter()
|
|
.any(|f| f.path == "macros/Shared.yaml");
|
|
assert_eq!(
|
|
alpha_still_owns,
|
|
!cfg!(any(windows, target_os = "macos")),
|
|
"case-variant paths are one physical file on case-insensitive hosts"
|
|
);
|
|
assert!(
|
|
store
|
|
.get("beta")
|
|
.unwrap()
|
|
.files
|
|
.iter()
|
|
.any(|f| f.path == "macros/shared.yaml")
|
|
);
|
|
}
|
|
}
|