gioser

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 1184Shell 70Rust 62Markdown 501503 Definiciones

Etapa F paquetería #2: repositorio + `hammer install <nombre>` / `repo list`

Cierra el lazo "packié un .swm → lo instalo por nombre". El repo es el namespace que
le da identidad a los .swm (que en sí no la llevan).

- hammer-core/repo.rs: `RepoIndex` + `PackageEntry` (load/save index.json, find, upsert
  idempotente por nombre que reporta el .swm huérfano). Índice JSON plano, ordenado,
  diffeable, firmable a futuro como release. 4 tests.
- hammer-cli:
  * `pack --repo DIR` PUBLICA (escribe <repo>/<name>-<version>.swm + upsert al índice con
    distro_version/expected_hash/signed_by; retira el huérfano de una versión vieja).
  * `install <nombre> [--repo] [--trust] [--base-ref] [--prefix] [--skip-source-patch]`
    CONSUME: resuelve nombre→.swm, verifica firma (con --trust) ANTES de reproducir, delega
    en el camino de apply (reproduce source_patch + hidrata). Nunca corre binario ajeno.
    Nombre inexistente → error legible con los disponibles.
  * `repo list` imprime el catálogo.
- Validado E2E en host: publicar ripgrep (firmado) + findutils, repo list, index.json limpio,
  install ripgrep --trust → "firma: trusted (by alice)" → apply OK. 31 suites verde.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

e867bac3882ac89164156ab8d3b53a08131a13e1 · por sergio · (hace 4 meses) · padre 339a7b07e · ver el árbol en este commit

Delta semántico · 22

crates/hammer-cli/src/main.rs

crates/hammer-core/src/lib.rs

  • nueva mod repo pub

crates/hammer-core/src/repo.rs

4 archivos · +371 −13

diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs
index b26d7485..95a5d62d 100644
--- a/crates/hammer-cli/src/main.rs
+++ b/crates/hammer-cli/src/main.rs
@@ -9,6 +9,7 @@ use std::path::PathBuf;
 use clap::{Parser, Subcommand};
 
 const DEFAULT_STORE: &str = "/store";
+const DEFAULT_REPO: &str = "/var/lib/hammer/repo";
 
 #[derive(Parser)]
 #[command(
@@ -178,9 +179,13 @@ enum Cmd {
         /// verifica que algo esperado quedó en su sitio.)
         #[arg(long)]
         target_bin: Option<String>,
-        /// Archivo `.swm` de salida. Sin esto, se imprime a stdout.
+        /// Archivo `.swm` de salida. Sin esto (ni `--repo`), se imprime a stdout.
         #[arg(long, short)]
         out: Option<PathBuf>,
+        /// Publica el paquete en este repositorio: escribe `<repo>/<name>-<version>.swm` y
+        /// actualiza `<repo>/index.json` (upsert por nombre). Compatible con `--out`.
+        #[arg(long)]
+        repo: Option<PathBuf>,
         /// `distro_version` de la base del paquete (la base local del receptor debe coincidir).
         #[arg(long, default_value = "dev")]
         distro_version: String,
@@ -197,6 +202,37 @@ enum Cmd {
         #[arg(long)]
         sign: Option<PathBuf>,
     },
+    /// [Etapa F] Instala un paquete por nombre desde un repositorio: resuelve `nombre` en el
+    /// índice, verifica la firma (con `--trust`) y la base, reproduce el `source_patch` desde
+    /// fuente y lo hidrata. Es `apply` con resolución por nombre — nunca corre un binario ajeno.
+    Install {
+        /// Nombre del paquete a instalar (clave del índice del repo).
+        name: String,
+        /// Directorio del repositorio (con `index.json`).
+        #[arg(long, default_value = DEFAULT_REPO)]
+        repo: PathBuf,
+        /// Re-rootea las mutaciones bajo este prefix (tests/staging) en vez de abrir overlay.
+        #[arg(long)]
+        prefix: Option<PathBuf>,
+        /// Verifica el `source_patch` y la base, pero NO construye ni hidrata (dry-run de schema).
+        #[arg(long)]
+        skip_source_patch: bool,
+        /// Base local (JSON) contra la que validar la base del paquete. Sin esto no se chequea.
+        #[arg(long)]
+        base_ref: Option<PathBuf>,
+        /// Directorio de claves de confianza para validar la firma del `.swm`. Sin esto, la
+        /// firma se informa pero no bloquea (igual que `swm-verify` sin `--trust`).
+        #[arg(long)]
+        trust: Option<PathBuf>,
+        /// Raíz de estado para el overlay (modo sin `--prefix`).
+        #[arg(long)]
+        state_root: Option<PathBuf>,
+    },
+    /// [Etapa F] Inspecciona un repositorio de paquetes.
+    Repo {
+        #[command(subcommand)]
+        sub: RepoCmd,
+    },
     /// [Fase 6] Ejecuta el bucle agéntico: traduce una intención NL a un .swm vía catálogo
     /// mock y la aplica a un overlay (o prefix) sin promover al FHS. Imprime el Proposal.
     Ai {
@@ -293,6 +329,16 @@ enum Cmd {
     },
 }
 
+#[derive(Subcommand)]
+enum RepoCmd {
+    /// Lista los paquetes publicados en el repositorio.
+    List {
+        /// Directorio del repositorio (con `index.json`).
+        #[arg(long, default_value = DEFAULT_REPO)]
+        repo: PathBuf,
+    },
+}
+
 #[derive(Subcommand)]
 enum UpgradeCmd {
     /// Aplica el árbol `<tree-dir>` (`<hash>-<name>` del store) al root vivo, dejando una generación.
@@ -681,6 +727,7 @@ fn main() -> anyhow::Result<()> {
             recipe,
             target_bin,
             out,
+            repo,
             distro_version,
             expected,
             build,
@@ -690,6 +737,7 @@ fn main() -> anyhow::Result<()> {
                 &recipe,
                 target_bin.as_deref(),
                 out.as_deref(),
+                repo.as_deref(),
                 &distro_version,
                 expected.as_deref(),
                 build,
@@ -697,6 +745,29 @@ fn main() -> anyhow::Result<()> {
                 &cli.store,
             )?;
         }
+        Cmd::Install {
+            name,
+            repo,
+            prefix,
+            skip_source_patch,
+            base_ref,
+            trust,
+            state_root,
+        } => {
+            run_install(
+                &cli.store,
+                &name,
+                &repo,
+                prefix.as_deref(),
+                skip_source_patch,
+                base_ref.as_deref(),
+                trust.as_deref(),
+                state_root.as_deref(),
+            )?;
+        }
+        Cmd::Repo { sub } => match sub {
+            RepoCmd::List { repo } => run_repo_list(&repo)?,
+        },
         Cmd::Ai {
             intent,
             catalog,
@@ -1096,6 +1167,7 @@ fn run_pack(
     recipe_path: &std::path::Path,
     target_bin: Option<&str>,
     out: Option<&std::path::Path>,
+    repo: Option<&std::path::Path>,
     distro_version: &str,
     expected: Option<&str>,
     do_build: bool,
@@ -1163,7 +1235,7 @@ fn run_pack(
         &recipe,
         target_bin,
         patch_text,
-        expected_hash,
+        expected_hash.clone(),
         distro_version,
     )?;
 
@@ -1180,18 +1252,130 @@ fn run_pack(
     }
 
     let yaml = swm.to_yaml()?;
-    match out {
-        Some(dest) => {
-            std::fs::write(dest, &yaml)?;
-            eprintln!(
-                "empaquetada {} → {} ({} mutación source_patch{})",
-                recipe.name,
-                dest.display(),
-                swm.mutations.len(),
-                if swm.signature.is_some() { ", firmado" } else { "" },
-            );
+    let signed = swm.signature.is_some();
+    let mut emitted = false;
+
+    if let Some(dest) = out {
+        std::fs::write(dest, &yaml)?;
+        eprintln!(
+            "empaquetada {} → {} ({} mutación source_patch{})",
+            recipe.name,
+            dest.display(),
+            swm.mutations.len(),
+            if signed { ", firmado" } else { "" },
+        );
+        emitted = true;
+    }
+
+    if let Some(repo_dir) = repo {
+        // Publicar: el repo asigna la identidad (nombre+versión de la receta). El `.swm` en sí
+        // no lleva nombre — el índice es el namespace. Upsert por nombre (idempotente).
+        let file_name = format!("{}-{}.swm", recipe.name, recipe.version);
+        std::fs::create_dir_all(repo_dir)?;
+        std::fs::write(repo_dir.join(&file_name), &yaml)?;
+        let mut index = hammer_core::RepoIndex::load(repo_dir)?;
+        let orphan = index.upsert(hammer_core::PackageEntry {
+            name: recipe.name.clone(),
+            version: recipe.version.clone(),
+            file: file_name,
+            distro_version: distro_version.to_string(),
+            expected_hash: expected_hash.clone(),
+            signed_by: swm.signature.as_ref().map(|s| s.by.clone()),
+        });
+        if let Some(old) = orphan {
+            // Una versión nueva deja huérfano el `.swm` viejo: lo retiramos (best-effort).
+            let _ = std::fs::remove_file(repo_dir.join(old));
         }
-        None => print!("{yaml}"),
+        index.save(repo_dir)?;
+        eprintln!(
+            "publicado {}-{} en repo {} ({} paquete(s) en el índice)",
+            recipe.name,
+            recipe.version,
+            repo_dir.display(),
+            index.packages.len(),
+        );
+        emitted = true;
+    }
+
+    if !emitted {
+        print!("{yaml}");
+    }
+    Ok(())
+}
+
+/// Instala un paquete por nombre desde un repo (Etapa F): resuelve el nombre en el índice,
+/// verifica firma (con `--trust`) y delega en el camino de `apply` (reproduce + hidrata). Nunca
+/// corre un binario ajeno — sólo reconstruye desde fuente y compara.
+#[allow(clippy::too_many_arguments)]
+fn run_install(
+    store_path: &str,
+    name: &str,
+    repo_dir: &std::path::Path,
+    prefix: Option<&std::path::Path>,
+    skip_source_patch: bool,
+    base_ref: Option<&std::path::Path>,
+    trust: Option<&std::path::Path>,
+    state_root: Option<&std::path::Path>,
+) -> anyhow::Result<()> {
+    let index = hammer_core::RepoIndex::load(repo_dir)?;
+    let entry = index.find(name).ok_or_else(|| {
+        let avail: Vec<&str> = index.packages.iter().map(|p| p.name.as_str()).collect();
+        anyhow::anyhow!(
+            "paquete '{name}' no está en el repo {}. Disponibles: {}",
+            repo_dir.display(),
+            if avail.is_empty() { "(ninguno)".into() } else { avail.join(", ") }
+        )
+    })?;
+    let swm_path = hammer_core::RepoIndex::swm_path(repo_dir, entry);
+    eprintln!(
+        "install {} {} ← {}",
+        entry.name,
+        entry.version,
+        swm_path.display()
+    );
+    let swm_path_str = swm_path
+        .to_str()
+        .ok_or_else(|| anyhow::anyhow!("ruta de .swm no es UTF-8: {}", swm_path.display()))?;
+
+    // Verificación de firma ANTES de reproducir nada (sólo con --trust; sin él, se informa
+    // que no se verificó, como el resto de la CLI).
+    if trust.is_some() {
+        let swm = load_swm(swm_path_str)?;
+        print_sig_status(&swm, trust)?;
+    } else {
+        eprintln!("firma: no verificada (pasá --trust <dir> para validar la autoría)");
+    }
+
+    // El resto (schema, base, reproduce source_patch, hidrata) es exactamente `apply`.
+    run_apply(
+        store_path,
+        swm_path_str,
+        prefix,
+        skip_source_patch,
+        base_ref,
+        state_root,
+    )
+}
+
+/// Lista los paquetes publicados en un repositorio.
+fn run_repo_list(repo_dir: &std::path::Path) -> anyhow::Result<()> {
+    let index = hammer_core::RepoIndex::load(repo_dir)?;
+    if index.packages.is_empty() {
+        println!("repo {} vacío (sin index.json o sin paquetes)", repo_dir.display());
+        return Ok(());
+    }
+    println!("repo {} — {} paquete(s):", repo_dir.display(), index.packages.len());
+    for p in &index.packages {
+        let sig = p.signed_by.as_deref().map(|b| format!(" [firmado por {b}]")).unwrap_or_default();
+        let anchor = p
+            .expected_hash
+            .as_deref()
+            .map(|h| format!(" expected={h}"))
+            .unwrap_or_default();
+        println!(
+            "  {:<16} {:<10} base={} {}{}{}",
+            p.name, p.version, p.distro_version, p.file, anchor, sig
+        );
     }
     Ok(())
 }
diff --git a/crates/hammer-core/src/lib.rs b/crates/hammer-core/src/lib.rs
index 88fc4233..af312885 100644
--- a/crates/hammer-core/src/lib.rs
+++ b/crates/hammer-core/src/lib.rs
@@ -10,6 +10,7 @@ pub mod hash;
 pub mod proto;
 pub mod query;
 pub mod recipe;
+pub mod repo;
 pub mod sign;
 pub mod store;
 pub mod swm;
@@ -17,6 +18,7 @@ pub mod swm;
 pub use caps::{AgentCapsConfig, CapRule};
 pub use hash::ArtifactHash;
 pub use recipe::{Compiler, LinkMode, Phases, Recipe, Source, SourceKind};
+pub use repo::{PackageEntry, RepoIndex};
 pub use sign::{KeyPair, SigStatus, TrustStore};
 pub use store::Store;
 pub use swm::{Base, BaseCompat, BaseRef, Mutation, PinDiff, Signature, Swm, SwmBuild};
diff --git a/crates/hammer-core/src/repo.rs b/crates/hammer-core/src/repo.rs
new file mode 100644
index 00000000..60ea81e3
--- /dev/null
+++ b/crates/hammer-core/src/repo.rs
@@ -0,0 +1,155 @@
+//! El **repositorio de paquetes** (Etapa F): un directorio con `.swm` + un `index.json` que
+//! mapea `nombre → paquete`. Es el catálogo que `hammer install <nombre>` resuelve.
+//!
+//! Un `.swm` es un manifiesto de mutación sin identidad propia (no lleva un campo "nombre de
+//! paquete"); la **identidad** (nombre+versión) la asigna el repo cuando se publica. Esto
+//! mantiene el formato honesto (describe una transformación, no se arroga ser "el paquete X")
+//! y deja al repo ser el namespace. `hammer pack --repo` publica con el nombre/versión de la
+//! receta; `install` resuelve por nombre.
+//!
+//! El índice es CONTENIDO PLANO (serde_json), no un binario: legible, diffeable, firmable más
+//! adelante como una release. El transporte del repo (filesystem / sshfs / mirror) es ortogonal
+//! — ver `hammer-mirror` para el CAS del store; aquí el repo es simplemente un directorio.
+
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+
+/// Nombre canónico del índice dentro del directorio del repo.
+pub const INDEX_FILE: &str = "index.json";
+
+/// El índice del repo: la lista de paquetes publicados. Un nombre aparece una sola vez
+/// (la última publicación gana, ver [`RepoIndex::upsert`]).
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct RepoIndex {
+    #[serde(default)]
+    pub packages: Vec<PackageEntry>,
+}
+
+/// Una entrada del índice: la identidad de un paquete + el puntero a su `.swm` y los metadatos
+/// que `install` necesita para decidir ANTES de leer el `.swm` (base compatible, autoría).
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PackageEntry {
+    pub name: String,
+    pub version: String,
+    /// Ruta del `.swm` relativa al directorio del repo.
+    pub file: String,
+    /// `base.distro_version` del `.swm` — permite filtrar incompatibles sin abrir el manifiesto.
+    pub distro_version: String,
+    /// `expected_hash` del `source_patch` (si lo lleva): el ancla "verificar, no confiar".
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub expected_hash: Option<String>,
+    /// Autor de la firma del `.swm` (si está firmado). `install --trust` lo valida de verdad
+    /// contra la clave; este campo es sólo el metadato legible del índice.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub signed_by: Option<String>,
+}
+
+impl RepoIndex {
+    /// Carga el índice del repo. Un directorio sin `index.json` es un repo VACÍO válido (no es
+    /// error) — así `pack --repo <dir-nuevo>` arranca un repo desde cero.
+    pub fn load(repo_dir: impl AsRef<Path>) -> crate::Result<RepoIndex> {
+        let path = repo_dir.as_ref().join(INDEX_FILE);
+        match std::fs::read_to_string(&path) {
+            Ok(text) => serde_json::from_str(&text)
+                .map_err(|e| crate::Error::Serde(format!("{}: {e}", path.display()))),
+            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(RepoIndex::default()),
+            Err(e) => Err(crate::Error::Io(e)),
+        }
+    }
+
+    /// Escribe el índice (creando el directorio del repo si hace falta). JSON con saltos para
+    /// que el repo sea diffeable.
+    pub fn save(&self, repo_dir: impl AsRef<Path>) -> crate::Result<()> {
+        let dir = repo_dir.as_ref();
+        std::fs::create_dir_all(dir)?;
+        let text = serde_json::to_string_pretty(self)
+            .map_err(|e| crate::Error::Serde(e.to_string()))?;
+        std::fs::write(dir.join(INDEX_FILE), text)?;
+        Ok(())
+    }
+
+    /// Resuelve un nombre a su entrada (la última publicada).
+    pub fn find(&self, name: &str) -> Option<&PackageEntry> {
+        self.packages.iter().find(|p| p.name == name)
+    }
+
+    /// Inserta o reemplaza la entrada de un nombre (publicar es idempotente por nombre: una
+    /// nueva versión del mismo paquete sustituye a la anterior en el índice). Devuelve el
+    /// `.swm` viejo que quedó huérfano, si la ruta cambió (para que el caller lo borre).
+    pub fn upsert(&mut self, entry: PackageEntry) -> Option<String> {
+        if let Some(slot) = self.packages.iter_mut().find(|p| p.name == entry.name) {
+            let old_file = slot.file.clone();
+            let orphan = (old_file != entry.file).then_some(old_file);
+            *slot = entry;
+            orphan
+        } else {
+            self.packages.push(entry);
+            self.packages.sort_by(|a, b| a.name.cmp(&b.name));
+            None
+        }
+    }
+
+    /// Ruta absoluta del `.swm` de una entrada, relativa al directorio del repo.
+    pub fn swm_path(repo_dir: impl AsRef<Path>, entry: &PackageEntry) -> PathBuf {
+        repo_dir.as_ref().join(&entry.file)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn entry(name: &str, ver: &str) -> PackageEntry {
+        PackageEntry {
+            name: name.into(),
+            version: ver.into(),
+            file: format!("{name}-{ver}.swm"),
+            distro_version: "dev".into(),
+            expected_hash: None,
+            signed_by: None,
+        }
+    }
+
+    #[test]
+    fn load_missing_index_is_empty_repo() {
+        let d = tempfile::tempdir().unwrap();
+        let idx = RepoIndex::load(d.path()).unwrap();
+        assert!(idx.packages.is_empty());
+    }
+
+    #[test]
+    fn save_load_roundtrip_and_find() {
+        let d = tempfile::tempdir().unwrap();
+        let mut idx = RepoIndex::default();
+        idx.upsert(entry("ripgrep", "14.1.1"));
+        idx.upsert(entry("findutils", "0.9.1"));
+        idx.save(d.path()).unwrap();
+
+        let back = RepoIndex::load(d.path()).unwrap();
+        assert_eq!(back.packages.len(), 2);
+        assert_eq!(back.find("ripgrep").unwrap().version, "14.1.1");
+        assert!(back.find("nope").is_none());
+        // Orden estable (alfabético) ⇒ índice diffeable.
+        assert_eq!(back.packages[0].name, "findutils");
+    }
+
+    #[test]
+    fn upsert_replaces_and_reports_orphan() {
+        let mut idx = RepoIndex::default();
+        idx.upsert(entry("rg", "1.0.0"));
+        // Nueva versión ⇒ archivo distinto ⇒ el viejo queda huérfano.
+        let orphan = idx.upsert(entry("rg", "2.0.0"));
+        assert_eq!(orphan.as_deref(), Some("rg-1.0.0.swm"));
+        assert_eq!(idx.packages.len(), 1, "no duplica por nombre");
+        assert_eq!(idx.find("rg").unwrap().version, "2.0.0");
+    }
+
+    #[test]
+    fn upsert_same_file_no_orphan() {
+        let mut idx = RepoIndex::default();
+        idx.upsert(entry("rg", "1.0.0"));
+        let orphan = idx.upsert(entry("rg", "1.0.0"));
+        assert!(orphan.is_none());
+    }
+}
diff --git a/docs/06-swm-format.md b/docs/06-swm-format.md
index 45948037..350ddbd5 100644
--- a/docs/06-swm-format.md
+++ b/docs/06-swm-format.md
@@ -137,3 +137,20 @@ CLI:
   `strip_components`; los patches de la receta viajan inline (concatenados). **Aún no modela
   `deps`** (resolución entre paquetes = pieza posterior): si la receta declara build/runtime
   deps, `pack` lo advierte y el receptor debe tenerlas ya en su store para reproducir.
+  Con `--repo DIR` **publica** en un repositorio (ver abajo) en vez de (o además de) `--out`.
+- `hammer install <nombre> [--repo DIR]` — **consume** del repositorio: resuelve `nombre` en el
+  índice, verifica la firma (con `--trust DIR`) y la base (con `--base-ref`), y delega en el
+  camino de `apply` (reproduce el `source_patch` desde fuente + hidrata). Nunca corre un binario
+  ajeno. `--prefix`/`--skip-source-patch` para staging y dry-run de schema.
+- `hammer repo list [--repo DIR]` — lista el catálogo (`<repo>/index.json`).
+
+## 7. Repositorio de paquetes (Etapa F)
+
+Un **repo** es un directorio con los `.swm` + un `index.json` que mapea `nombre → paquete`
+(`{name, version, file, distro_version, expected_hash?, signed_by?}`, orden alfabético,
+diffeable). Un `.swm` no lleva identidad propia (es un manifiesto de mutación, no "el paquete
+X"); la identidad la asigna el repo al publicar — el índice es el namespace. `pack --repo`
+publica con el nombre/versión de la receta (upsert idempotente por nombre; una versión nueva
+retira el `.swm` huérfano). El transporte del repo (filesystem / sshfs / mirror) es ortogonal:
+ver `hammer-mirror` para el CAS del store. Tipos en `hammer-core::repo` (`RepoIndex`,
+`PackageEntry`).