Etapa F paquetería #5: DB de instalados + `hammer uninstall`/`installed`
Funcionalidad de gestor de paquetes: rastrear qué hay instalado y poder quitarlo.
- hammer-core/installed.rs: `InstalledDb` (name→{version,hash,files}) load/save JSON; record
(upsert), remove, `owned_by_others` (refcount por ruta). Rutas absolutas ⇒ uninstall no
necesita el root. 4 tests.
- hammer-cli: run_apply ahora DEVUELVE los ficheros que CREA (hidratados + file_drop + init_rule;
config_edit modifica, no crea ⇒ no se registra ni se deshace). install los registra en la DB
(--db, default /var/lib/hammer/installed.json). `uninstall <nombre>` borra esos ficheros salvo
los que otro paquete instalado aporta (refcount) y quita la entrada. `installed` lista.
- Validado E2E REAL: install bwrap (con dep libcap) → registra 2 ficheros → `installed` los
lista → `uninstall bwrap` los borra (prefix vacío, DB vacía). 31 suites verde.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
9d1b2d4a762d2bc185908bf908666bd1cb9dc4f3 · por sergio · 2026-06-21T11:01:46Z (hace 4 meses) · padre 977f07ca6 · ver el árbol en este commit
Delta semántico · 22
crates/hammer-cli/src/main.rs
crates/hammer-core/src/installed.rs
crates/hammer-core/src/lib.rs
diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs
index 7d78b04e..8742d190 100644
--- a/crates/hammer-cli/src/main.rs
+++ b/crates/hammer-cli/src/main.rs
@@ -227,6 +227,23 @@ enum Cmd {
/// Raíz de estado para el overlay (modo sin `--prefix`).
#[arg(long)]
state_root: Option<PathBuf>,
+ /// Base de datos de instalados donde registrar el paquete (para `uninstall`).
+ #[arg(long, default_value = hammer_core::installed::DEFAULT_DB)]
+ db: PathBuf,
+ },
+ /// [Etapa F] Desinstala un paquete: borra los ficheros que registró (refcount: respeta los
+ /// que otro paquete instalado también aporta) y lo quita de la DB de instalados.
+ Uninstall {
+ /// Nombre del paquete a desinstalar.
+ name: String,
+ /// Base de datos de instalados.
+ #[arg(long, default_value = hammer_core::installed::DEFAULT_DB)]
+ db: PathBuf,
+ },
+ /// [Etapa F] Lista los paquetes instalados (de la DB).
+ Installed {
+ #[arg(long, default_value = hammer_core::installed::DEFAULT_DB)]
+ db: PathBuf,
},
/// [Etapa F] Inspecciona un repositorio de paquetes.
Repo {
@@ -773,6 +790,7 @@ fn main() -> anyhow::Result<()> {
base_ref,
trust,
state_root,
+ db,
} => {
run_install(
&cli.store,
@@ -783,8 +801,11 @@ fn main() -> anyhow::Result<()> {
base_ref.as_deref(),
trust.as_deref(),
state_root.as_deref(),
+ &db,
)?;
}
+ Cmd::Uninstall { name, db } => run_uninstall(&name, &db)?,
+ Cmd::Installed { db } => run_installed_list(&db)?,
Cmd::Repo { sub } => match sub {
RepoCmd::List { repo } => run_repo_list(&repo)?,
RepoCmd::Sign { repo, key, by } => run_repo_sign(&repo, &key, by.as_deref())?,
@@ -1339,6 +1360,7 @@ fn run_install(
base_ref: Option<&std::path::Path>,
trust: Option<&std::path::Path>,
state_root: Option<&std::path::Path>,
+ db_path: &std::path::Path,
) -> anyhow::Result<()> {
let index = hammer_core::RepoIndex::load(repo_dir)?;
@@ -1432,16 +1454,88 @@ fn run_install(
}
}
+ // Identidad del paquete para la DB (antes de mover nada; `entry` vive mientras `index`).
+ let pkg_name = entry.name.clone();
+ let pkg_version = entry.version.clone();
+ let pkg_hash = entry.expected_hash.clone();
+
// El resto (schema, base, reproduce source_patch CON sus deps del catálogo, hidrata) es
// exactamente `apply`: build_source_patch usa el MISMO catalog_dir determinista.
- run_apply(
+ let created = run_apply(
store_path,
swm_path_str,
prefix,
skip_source_patch,
base_ref,
state_root,
- )
+ )?;
+
+ // Registrar en la DB de instalados (salvo dry-run de schema). Permite `hammer uninstall`.
+ if !skip_source_patch {
+ let mut idb = hammer_core::InstalledDb::load(db_path)?;
+ let files: Vec<String> = created.iter().map(|p| p.to_string_lossy().into_owned()).collect();
+ let nfiles = files.len();
+ idb.record(hammer_core::InstalledPackage {
+ name: pkg_name.clone(),
+ version: pkg_version,
+ hash: pkg_hash,
+ files,
+ });
+ idb.save(db_path)?;
+ eprintln!("registrado {pkg_name} ({nfiles} fichero(s)) en {}", db_path.display());
+ }
+ Ok(())
+}
+
+/// Lista los paquetes instalados de la DB.
+fn run_installed_list(db_path: &std::path::Path) -> anyhow::Result<()> {
+ let idb = hammer_core::InstalledDb::load(db_path)?;
+ if idb.packages.is_empty() {
+ println!("nada instalado (DB {})", db_path.display());
+ return Ok(());
+ }
+ println!("{} paquete(s) instalado(s) (DB {}):", idb.packages.len(), db_path.display());
+ for p in &idb.packages {
+ println!(" {:<16} {:<10} {} fichero(s)", p.name, p.version, p.files.len());
+ }
+ Ok(())
+}
+
+/// Desinstala un paquete: borra los ficheros que registró (salvo los que otro paquete instalado
+/// también aporta — refcount) y quita su entrada de la DB. NO deshace `config_edit` (modifican
+/// ficheros ajenos, no creados por el paquete).
+fn run_uninstall(name: &str, db_path: &std::path::Path) -> anyhow::Result<()> {
+ let mut idb = hammer_core::InstalledDb::load(db_path)?;
+ let Some(pkg) = idb.find(name).cloned() else {
+ let avail: Vec<&str> = idb.packages.iter().map(|p| p.name.as_str()).collect();
+ anyhow::bail!(
+ "'{name}' no está instalado (DB {}). Instalados: {}",
+ db_path.display(),
+ if avail.is_empty() { "(ninguno)".into() } else { avail.join(", ") }
+ );
+ };
+
+ let (mut removed, mut kept) = (0usize, 0usize);
+ for f in &pkg.files {
+ // Refcount: no borramos un fichero que otro paquete instalado también aporta.
+ if idb.owned_by_others(name, f) {
+ kept += 1;
+ continue;
+ }
+ match std::fs::remove_file(f) {
+ Ok(()) => removed += 1,
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} // ya no estaba
+ Err(e) => eprintln!(" aviso: no pude borrar {f}: {e}"),
+ }
+ }
+ idb.remove(name);
+ idb.save(db_path)?;
+ eprintln!(
+ "desinstalado {name} {} — {removed} fichero(s) borrado(s){}",
+ pkg.version,
+ if kept > 0 { format!(", {kept} conservado(s) (los aporta otro paquete)") } else { String::new() }
+ );
+ Ok(())
}
/// Devuelve la primera mutación `source_patch` de un `.swm` empaquetado (el caso de `pack`).
@@ -1576,9 +1670,12 @@ fn run_apply(
skip_source_patch: bool,
base_ref: Option<&std::path::Path>,
state_root: Option<&std::path::Path>,
-) -> anyhow::Result<()> {
+) -> anyhow::Result<Vec<PathBuf>> {
let swm = load_swm(file)?;
swm.verify_schema()?;
+ // Ficheros que este apply CREA (hidratados + file_drop + init_rule). Sirven para registrar el
+ // paquete en la DB de instalados (config_edit MODIFICA, no crea ⇒ no se registra).
+ let mut created: Vec<PathBuf> = Vec::new();
if let Some(local) = load_local_base(base_ref)? {
match swm.verify_base(&local) {
@@ -1636,6 +1733,7 @@ fn run_apply(
abs.display()
);
}
+ created.extend(report.files.iter().map(|f| f.dst.clone()));
}
hammer_core::Mutation::ConfigEdit { file: f, inline_diff } => {
counts.1 += 1;
@@ -1649,6 +1747,7 @@ fn run_apply(
hammer_core::apply::rebase_path(hammer_core::apply::INIT_RULES_DIR, prefix);
let p = hammer_core::apply::apply_init_rule(&rules_dir, action, service, command)?;
eprintln!(" init_rule #{}: {action} {service} → {}", i + 1, p.display());
+ created.push(p);
}
hammer_core::Mutation::FileDrop { path, content_hash, content_b64, content_url } => {
counts.3 += 1;
@@ -1657,6 +1756,7 @@ fn run_apply(
(Some(b64), None) => {
hammer_core::apply::apply_file_drop(&target, b64, content_hash)?;
eprintln!(" file_drop #{} → {}", i + 1, target.display());
+ created.push(target.clone());
}
(None, Some(url)) => {
// Descarga remota (fuera de cualquier sandbox) + verificación BLAKE3
@@ -1669,6 +1769,7 @@ fn run_apply(
bytes.len(),
target.display()
);
+ created.push(target.clone());
}
_ => unreachable!("verify_schema lo descarta"),
}
@@ -1683,7 +1784,7 @@ fn run_apply(
println!("{id}");
eprintln!("overlay listo: usa `hammer commit {id}` para promocionar al FHS real.");
}
- Ok(())
+ Ok(created)
}
/// Exporta el diario actual como `.swm`. Provenance-aware:
diff --git a/crates/hammer-core/src/installed.rs b/crates/hammer-core/src/installed.rs
new file mode 100644
index 00000000..1d8d240b
--- /dev/null
+++ b/crates/hammer-core/src/installed.rs
@@ -0,0 +1,142 @@
+//! La **base de datos de paquetes instalados** (Etapa F): qué paquetes hay puestos en un root y
+//! qué ficheros aportó cada uno. Es lo que hace posible `hammer uninstall`: sin un registro de
+//! "este paquete creó estos ficheros", quitar un paquete sería adivinar.
+//!
+//! Modelo simple y honesto: `install` registra los ficheros que CREÓ (los hidratados del
+//! `source_patch` + `file_drop` + `init_rule`; un `config_edit` MODIFICA un fichero ajeno, no lo
+//! crea, así que no se registra ni se deshace). `uninstall` borra los ficheros del paquete, salvo
+//! los que OTRO paquete instalado también aporta (refcount por ruta) — nunca rompe a un tercero.
+//!
+//! Las rutas se guardan ABSOLUTAS (bajo el prefix o `/` según donde se instaló), así `uninstall`
+//! no necesita saber el root: borra exactamente lo que registró el `install`.
+
+use std::path::Path;
+
+use serde::{Deserialize, Serialize};
+
+/// Nombre por defecto de la base de datos de instalados.
+pub const DEFAULT_DB: &str = "/var/lib/hammer/installed.json";
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct InstalledDb {
+ #[serde(default)]
+ pub packages: Vec<InstalledPackage>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct InstalledPackage {
+ pub name: String,
+ pub version: String,
+ /// `expected_hash` del paquete (si lo tenía) — sólo informativo en la DB.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub hash: Option<String>,
+ /// Rutas ABSOLUTAS que este paquete creó (orden de instalación). Lo que `uninstall` borra.
+ pub files: Vec<String>,
+}
+
+impl InstalledDb {
+ /// Carga la DB. Un fichero ausente es una DB VACÍA válida (nada instalado todavía).
+ pub fn load(path: impl AsRef<Path>) -> crate::Result<InstalledDb> {
+ match std::fs::read_to_string(path.as_ref()) {
+ Ok(text) => serde_json::from_str(&text)
+ .map_err(|e| crate::Error::Serde(format!("{}: {e}", path.as_ref().display()))),
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(InstalledDb::default()),
+ Err(e) => Err(crate::Error::Io(e)),
+ }
+ }
+
+ /// Escribe la DB (creando el directorio padre si hace falta). JSON con saltos, diffeable.
+ pub fn save(&self, path: impl AsRef<Path>) -> crate::Result<()> {
+ let path = path.as_ref();
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+ let text = serde_json::to_string_pretty(self)
+ .map_err(|e| crate::Error::Serde(e.to_string()))?;
+ std::fs::write(path, text)?;
+ Ok(())
+ }
+
+ pub fn find(&self, name: &str) -> Option<&InstalledPackage> {
+ self.packages.iter().find(|p| p.name == name)
+ }
+
+ /// Registra (o reemplaza por nombre) un paquete instalado. Re-instalar actualiza la lista de
+ /// ficheros (una versión nueva puede aportar otros).
+ pub fn record(&mut self, pkg: InstalledPackage) {
+ if let Some(slot) = self.packages.iter_mut().find(|p| p.name == pkg.name) {
+ *slot = pkg;
+ } else {
+ self.packages.push(pkg);
+ self.packages.sort_by(|a, b| a.name.cmp(&b.name));
+ }
+ }
+
+ /// Quita el registro de un paquete y lo devuelve (para que el caller borre sus ficheros).
+ pub fn remove(&mut self, name: &str) -> Option<InstalledPackage> {
+ let i = self.packages.iter().position(|p| p.name == name)?;
+ Some(self.packages.remove(i))
+ }
+
+ /// ¿Algún OTRO paquete instalado (≠ `name`) aporta también `file`? Si sí, `uninstall` no debe
+ /// borrarlo (lo seguiría necesitando ese tercero).
+ pub fn owned_by_others(&self, name: &str, file: &str) -> bool {
+ self.packages
+ .iter()
+ .any(|p| p.name != name && p.files.iter().any(|f| f == file))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn pkg(name: &str, files: &[&str]) -> InstalledPackage {
+ InstalledPackage {
+ name: name.into(),
+ version: "1".into(),
+ hash: None,
+ files: files.iter().map(|s| s.to_string()).collect(),
+ }
+ }
+
+ #[test]
+ fn load_missing_is_empty() {
+ let d = tempfile::tempdir().unwrap();
+ let db = InstalledDb::load(d.path().join("installed.json")).unwrap();
+ assert!(db.packages.is_empty());
+ }
+
+ #[test]
+ fn record_save_load_find() {
+ let d = tempfile::tempdir().unwrap();
+ let p = d.path().join("installed.json");
+ let mut db = InstalledDb::default();
+ db.record(pkg("ripgrep", &["/usr/bin/rg"]));
+ db.save(&p).unwrap();
+ let back = InstalledDb::load(&p).unwrap();
+ assert_eq!(back.find("ripgrep").unwrap().files, vec!["/usr/bin/rg"]);
+ assert!(back.find("nope").is_none());
+ }
+
+ #[test]
+ fn remove_returns_entry() {
+ let mut db = InstalledDb::default();
+ db.record(pkg("rg", &["/usr/bin/rg"]));
+ let removed = db.remove("rg").unwrap();
+ assert_eq!(removed.files, vec!["/usr/bin/rg"]);
+ assert!(db.find("rg").is_none());
+ assert!(db.remove("rg").is_none());
+ }
+
+ #[test]
+ fn owned_by_others_refcount() {
+ let mut db = InstalledDb::default();
+ db.record(pkg("a", &["/usr/lib/libshared.so", "/usr/bin/a"]));
+ db.record(pkg("b", &["/usr/lib/libshared.so", "/usr/bin/b"]));
+ // El .so lo aportan ambos: al desinstalar 'a' no debe borrarse.
+ assert!(db.owned_by_others("a", "/usr/lib/libshared.so"));
+ // El binario propio de 'a' no lo aporta nadie más.
+ assert!(!db.owned_by_others("a", "/usr/bin/a"));
+ }
+}
diff --git a/crates/hammer-core/src/lib.rs b/crates/hammer-core/src/lib.rs
index 2f55000c..2d250192 100644
--- a/crates/hammer-core/src/lib.rs
+++ b/crates/hammer-core/src/lib.rs
@@ -7,6 +7,7 @@
pub mod apply;
pub mod caps;
pub mod hash;
+pub mod installed;
pub mod proto;
pub mod query;
pub mod recipe;
@@ -17,6 +18,7 @@ pub mod swm;
pub use caps::{AgentCapsConfig, CapRule};
pub use hash::ArtifactHash;
+pub use installed::{InstalledDb, InstalledPackage};
pub use recipe::{Compiler, Deps, LinkMode, Phases, Recipe, Source, SourceKind};
pub use repo::{PackageEntry, RepoIndex};
pub use sign::{KeyPair, SigStatus, TrustStore};
diff --git a/docs/06-swm-format.md b/docs/06-swm-format.md
index aa91e47a..7f3d55bb 100644
--- a/docs/06-swm-format.md
+++ b/docs/06-swm-format.md
@@ -147,6 +147,10 @@ CLI:
- `hammer repo sign --repo DIR --key KEY` — firma el ÍNDICE entero (release). Re-firmá tras
publicar (cada `pack --repo` invalida la firma del release).
- `hammer repo verify --repo DIR [--trust DIR]` — verifica la firma del release.
+- `hammer uninstall <nombre> [--db FILE]` — borra los ficheros que el paquete registró (refcount:
+ respeta los que otro paquete instalado también aporta) y lo quita de la DB de instalados.
+- `hammer installed [--db FILE]` — lista los paquetes instalados. `install` registra cada paquete
+ (nombre, versión, hash, ficheros creados) en la DB (`/var/lib/hammer/installed.json` por defecto).
## 7. Repositorio de paquetes (Etapa F)