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>
This commit is contained in:
+197
-13
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user