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, - /// 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, + /// Publica el paquete en este repositorio: escribe `/-.swm` y + /// actualiza `/index.json` (upsert por nombre). Compatible con `--out`. + #[arg(long)] + repo: Option, /// `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, }, + /// [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, + /// 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, + /// 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, + /// Raíz de estado para el overlay (modo sin `--prefix`). + #[arg(long)] + state_root: Option, + }, + /// [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 `` (`-` 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 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 ` 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, +} + +/// 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, + /// 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, +} + +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 ` arranca un repo desde cero. + pub fn load(repo_dir: impl AsRef) -> crate::Result { + 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) -> 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 { + 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, 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 [--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 (`/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`).