tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1534Shell 199Rust 109Markdown 993713 Definiciones

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 · (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

4 archivos · +253 −4

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)