El menú de arranque como navegación del grafo content-addressed de estados (no una lista de kernels). Sube el modelo de generaciones in-place a un grafo navegable y define el contrato de datos con mirada. - hammer_upgrade::boot_graph: BootGraph/BootNode (formato exacto del contrato), build() arma el DAG desde las generaciones (id = of_tree sin b3:, parents = of_tree del padre, Base para la raíz del linaje), nodo Recovery sintético colgando de la viva, emit() atómico a /run/hammer/boot-graph.json. - activate(): resuelve el id content-addressed a una generación y deja el sistema en ese nodo — no-op si ya viva, rollback paso a paso a un ancestro (reusa el rollback E4), recovery = un rollback, error honesto ante un "redo" hacia una generación huérfana (pide re-aplicar el árbol). - CLI `hammer boot graph [--out|--stdout]` y `hammer boot activate <id> [--from-select]` (lee el id que mirada deja en /run/hammer/boot-select). - Tests: DAG + activate a ancestro + recovery + redo-falla; rfc3339 sin deps. Validado e2e por el binario (3 generaciones → grafo → activar → recovery). Cierra el lado `proceso` de SDD 15 §H4 (arrancar = activar un nodo del DAG). mirada ya puede maquetar contra el grafo real (HANDOFF-arranque-grafo.md). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3305 lines
140 KiB
Rust
3305 lines
140 KiB
Rust
//! `hammer` — el punto de entrada de todo flujo manual. Ver `docs/`.
|
|
//!
|
|
//! Los subcomandos están mapeados a las fases del roadmap (`docs/10-roadmap.md`). Los que aún
|
|
//! no están implementados devuelven un error claro indicando su fase, para que el esqueleto
|
|
//! sea navegable desde el día uno.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use clap::{Parser, Subcommand};
|
|
|
|
mod alpine_import;
|
|
mod nix_import;
|
|
|
|
const DEFAULT_STORE: &str = "/store";
|
|
const DEFAULT_REPO: &str = "/var/lib/hammer/repo";
|
|
|
|
#[derive(Parser)]
|
|
#[command(
|
|
name = "hammer",
|
|
version,
|
|
about = "Laboratorio funcional en el sótano, terminal mutable arriba.",
|
|
long_about = "hammer: distro AI-nativa. Ver docs/ para el diseño completo."
|
|
)]
|
|
struct Cli {
|
|
/// Ruta del content-addressed store.
|
|
#[arg(long, default_value = DEFAULT_STORE, global = true)]
|
|
store: String,
|
|
|
|
#[command(subcommand)]
|
|
cmd: Cmd,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum Cmd {
|
|
/// [Fase 0] Compila una receta y la sella en el store.
|
|
Build {
|
|
/// Ruta a la receta TOML.
|
|
recipe: String,
|
|
},
|
|
/// [Atestación] Verifica un árbol FHS (p.ej. un product-rootfs hidratado) contra su
|
|
/// `/ente/attest.json`: recomputa el BLAKE3 de cada binario crítico y lo compara. Es el gate de
|
|
/// integridad que arje aplicará al boot, hecho hoy por hammer ("reproducir, no confiar"). Exit≠0
|
|
/// si algún binario diverge o falta.
|
|
Attest {
|
|
/// Directorio raíz del rootfs a verificar (contiene `ente/attest.json`).
|
|
#[arg(long)]
|
|
rootfs: String,
|
|
},
|
|
/// [Fase 1] Proyecta un artefacto del store al FHS (real o de overlay).
|
|
Hydrate {
|
|
/// Hash del artefacto (acepta prefijos cortos; con o sin `b3:`).
|
|
hash: String,
|
|
#[arg(long, default_value = "/")]
|
|
into: String,
|
|
/// Modo de enlazado: `static` (hardlink directo) | `dynamic` (requiere patchelf).
|
|
#[arg(long, default_value = "static")]
|
|
link: String,
|
|
},
|
|
/// [Fase 2] Monta un overlay de experimentación sobre los directorios del sistema.
|
|
/// Sin targets → cubre el FHS clásico (/usr/bin, /bin, /usr/lib, /lib, /etc, …).
|
|
Try {
|
|
/// Directorios a poner bajo overlay. Si vacío, usa la lista por defecto.
|
|
targets: Vec<PathBuf>,
|
|
/// Raíz donde viven los manifiestos y los upper/work. Default `/var/lib/hammer/overlays`.
|
|
#[arg(long)]
|
|
state_root: Option<PathBuf>,
|
|
},
|
|
/// [Fase 2+3] Fusiona un overlay al FHS real y desmonta. Por defecto registra cada
|
|
/// archivo promocionado en el diario; usa `--no-journal` para saltarse el registro.
|
|
Commit {
|
|
id: String,
|
|
#[arg(long)]
|
|
state_root: Option<PathBuf>,
|
|
/// Directorio del diario donde anotar la promoción.
|
|
#[arg(long, default_value = "/var/lib/hammer/journal")]
|
|
journal: PathBuf,
|
|
/// Saltarse el registro en el diario (útil para experimentos efímeros).
|
|
#[arg(long)]
|
|
no_journal: bool,
|
|
},
|
|
/// [Fase 2] Desmonta un overlay y borra su upper. El sistema vuelve al estado base.
|
|
Discard {
|
|
id: String,
|
|
#[arg(long)]
|
|
state_root: Option<PathBuf>,
|
|
},
|
|
/// [Fase 2] Lista los overlays activos según los manifiestos persistidos.
|
|
Status {
|
|
#[arg(long)]
|
|
state_root: Option<PathBuf>,
|
|
},
|
|
/// [Fase 3] Ver/seguir el diario de mutaciones.
|
|
Journal {
|
|
/// Directorio del diario (mismo que `hammerd --journal`).
|
|
#[arg(long, default_value = "/var/lib/hammer/journal")]
|
|
dir: PathBuf,
|
|
/// Muestra los últimos N eventos. Default: 20.
|
|
#[arg(long, short, default_value = "20")]
|
|
tail: usize,
|
|
/// Sigue el archivo: imprime nuevos eventos según aparecen.
|
|
#[arg(long, short)]
|
|
follow: bool,
|
|
/// Formato. `pretty` (humano, default) o `json` (raw passthrough).
|
|
#[arg(long, default_value = "pretty")]
|
|
format: String,
|
|
},
|
|
/// [Fase 4] Aplica un manifiesto .swm. Por defecto abre un overlay sobre el FHS y aplica
|
|
/// las mutaciones ahí; con `--prefix DIR` opera directamente en `DIR` sin overlay
|
|
/// (útil para tests offline o staging en otro filesystem).
|
|
Apply {
|
|
file: String,
|
|
/// Si está, re-rootea todas las rutas absolutas del .swm bajo este prefix y NO abre
|
|
/// overlay. `--prefix /mnt/foo` ⇒ `/etc/network.conf` → `/mnt/foo/etc/network.conf`.
|
|
#[arg(long)]
|
|
prefix: Option<PathBuf>,
|
|
/// Si está, salta source_patch silenciosamente. Útil para probar el flujo de
|
|
/// config_edit/file_drop sin lab disponible.
|
|
#[arg(long)]
|
|
skip_source_patch: bool,
|
|
/// Tras aplicar, intenta verificar pin contra base local de `--base-ref`.
|
|
#[arg(long)]
|
|
base_ref: Option<PathBuf>,
|
|
/// Raíz del overlay (sólo en modo overlay).
|
|
#[arg(long)]
|
|
state_root: Option<PathBuf>,
|
|
},
|
|
/// [Fase 4] Verifica un .swm: schema, base (si se da `--base-ref`), el hash de cada
|
|
/// `file_drop` inline y, con `--trust DIR`, la firma Ed25519 contra el TrustStore local.
|
|
SwmVerify {
|
|
file: String,
|
|
#[arg(long)]
|
|
base_ref: Option<PathBuf>,
|
|
/// Directorio de claves de confianza (`*.ed25519.pub`). Default:
|
|
/// /var/lib/hammer/trust. La firma reporta estado, NO autoriza promover (ver SDD 09).
|
|
#[arg(long)]
|
|
trust: Option<PathBuf>,
|
|
/// [H1] Además del schema/firma, REPRODUCE cada paquete source_patch y corre su bloque
|
|
/// `evidence` (proof-carrying recipes) en el sandbox. Falla ⇒ exit != 0 (no proponer).
|
|
#[arg(long)]
|
|
evidence: bool,
|
|
},
|
|
/// [Fase 4] Genera un par de claves Ed25519 para firmar `.swm`. Escribe `<name>.ed25519`
|
|
/// (privada, 0600) y `<name>.ed25519.pub` en `--out` (default: /var/lib/hammer/trust).
|
|
Keygen {
|
|
/// Nombre de la identidad (autoría). Será el `by` de las firmas.
|
|
name: String,
|
|
#[arg(long)]
|
|
out: Option<PathBuf>,
|
|
},
|
|
/// [Fase 4] Firma un `.swm` con una clave privada y escribe el manifiesto firmado.
|
|
SwmSign {
|
|
file: String,
|
|
/// Ruta a la clave privada `<name>.ed25519`.
|
|
#[arg(long)]
|
|
key: PathBuf,
|
|
/// Autoría declarada (`signature.by`). Default: el basename de la clave sin sufijo.
|
|
#[arg(long)]
|
|
by: Option<String>,
|
|
/// Destino. Si se omite, sobrescribe el propio `file` in-place.
|
|
#[arg(long, short)]
|
|
out: Option<PathBuf>,
|
|
},
|
|
/// [Fase 4] Exporta el diario actual como manifiesto .swm a stdout.
|
|
/// Mutaciones trazables ⇒ file_drop con content_b64 y hash. Mutaciones opacas ⇒
|
|
/// mismas pero con `note` indicando el origen (warning legible para el receptor).
|
|
Export {
|
|
/// Archivo JSON con la base local; se inserta como `base` en el .swm. Si no se da,
|
|
/// se emite una base con `distro_version: "unknown"` y se imprime un warning.
|
|
#[arg(long)]
|
|
base_ref: Option<PathBuf>,
|
|
/// Directorio del diario.
|
|
#[arg(long, default_value = "/var/lib/hammer/journal")]
|
|
journal: PathBuf,
|
|
/// Sólo eventos a partir de este timestamp (RFC 3339). Si no se da, exporta todo.
|
|
#[arg(long)]
|
|
since: Option<String>,
|
|
},
|
|
/// [Etapa F] Empaqueta una receta del corpus (`recipes/foo.toml`) como un `.swm` de un
|
|
/// único `source_patch`: la dirección **forward** que vuelve una receta que el sistema ya
|
|
/// sabe construir un paquete distribuible y reproducible-desde-fuente. Inversa de `apply`.
|
|
Pack {
|
|
/// Ruta a la receta a empaquetar (p. ej. `recipes/ripgrep.toml`).
|
|
recipe: PathBuf,
|
|
/// Ancla de sanity: ruta absoluta del binario que debe existir tras hidratar el
|
|
/// artefacto. Default `/usr/bin/<name>`. (apply hidrata el artefacto ENTERO; esto sólo
|
|
/// verifica que algo esperado quedó en su sitio.)
|
|
#[arg(long)]
|
|
target_bin: Option<String>,
|
|
/// 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,
|
|
/// `expected_hash` declarado (`b3:…`) — el ancla de "verificar, no confiar". Si se omite
|
|
/// y no se pasa `--build`, el paquete sale sin ancla (el receptor reproduce igual, pero
|
|
/// no hay hash autor contra el que comparar).
|
|
#[arg(long)]
|
|
expected: Option<String>,
|
|
/// Construir la receta AHORA en el lab para sellar el `expected_hash` real (necesita
|
|
/// store + sandbox). Mutuamente informativo con `--expected` (este último gana si ambos).
|
|
#[arg(long)]
|
|
build: bool,
|
|
/// Firmar el `.swm` resultante con esta clave privada Ed25519 (como `swm-sign`).
|
|
#[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,
|
|
/// Repositorio: un directorio local (con `index.json`) o una URL HTTP(S) (`http(s)://…`).
|
|
#[arg(long, default_value = DEFAULT_REPO)]
|
|
repo: String,
|
|
/// 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>,
|
|
/// Base de datos de instalados donde registrar el paquete (para `uninstall`).
|
|
#[arg(long, default_value = hammer_core::installed::DEFAULT_DB)]
|
|
db: PathBuf,
|
|
/// Modo estricto: abortar si el release del repo no está firmado por una clave confiada
|
|
/// (no basta con que el `.swm` reproduzca — exige autoría del catálogo verificada).
|
|
#[arg(long)]
|
|
require_signed: bool,
|
|
/// [H4b] Ante una COLISIÓN de superficie (el paquete reclama un slot que ya ocupa otra
|
|
/// config, p. ej. el logo), adopta la nueva en vez de abortar. No afecta a un requisito
|
|
/// insatisfecho (ese rechazo es duro: el paquete no encaja con lo que tenés).
|
|
#[arg(long)]
|
|
force_slots: bool,
|
|
},
|
|
/// [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,
|
|
},
|
|
/// [H4d] BÚSQUEDA de compatibilidad: particiona un repo entero contra el estado instalado en
|
|
/// {compatibles, requieren-elección, incompatibles} — sin construir ni tocar nada. Responde
|
|
/// "cuando busco, ¿cuáles puedo adoptar?" (SDD 15 §H4).
|
|
Compat {
|
|
/// Repositorio: directorio local (con `index.json`) o URL HTTP(S).
|
|
#[arg(long, default_value = DEFAULT_REPO)]
|
|
repo: String,
|
|
/// Base de datos de instalados contra la que evaluar.
|
|
#[arg(long, default_value = hammer_core::installed::DEFAULT_DB)]
|
|
db: PathBuf,
|
|
},
|
|
/// [Etapa G] Importa una receta DESDE nixpkgs: toma el JSON normalizado que produce
|
|
/// `scripts/nix-import.sh` (vía `nix eval`) y emite una receta hammer (`.toml`). Trae la
|
|
/// RECETA (source+hash+deps+flags), nunca el binario del cache de nix — hammer reconstruye.
|
|
/// Es un PUNTO DE PARTIDA: el build (zig/musl) suele necesitar adaptación por paquete.
|
|
ImportNix {
|
|
/// JSON normalizado del paquete nix. `-` o ausente ⇒ lee de stdin.
|
|
#[arg(default_value = "-")]
|
|
file: String,
|
|
/// Receta de salida (`.toml`). Sin esto, imprime a stdout.
|
|
#[arg(long, short)]
|
|
out: Option<PathBuf>,
|
|
},
|
|
/// [Etapa G] Importa una receta DESDE un APKBUILD de Alpine (aports). Crucial: carga los
|
|
/// `.patch` de musl de Alpine — lo que un import de nix pierde — así está MUCHO más cerca de
|
|
/// compilar en el lab estático de hammer. `scripts/alpine-import.sh <pkg>` baja el APKBUILD.
|
|
ImportAlpine {
|
|
/// El APKBUILD (texto). `-` o ausente ⇒ stdin.
|
|
#[arg(default_value = "-")]
|
|
file: String,
|
|
/// Receta de salida (`.toml`). Sin esto, imprime a stdout.
|
|
#[arg(long, short)]
|
|
out: Option<PathBuf>,
|
|
},
|
|
/// [Etapa G Fase 2] Ancla el `commit` de una receta git: si es un TAG flotante (v1.0.0),
|
|
/// lo resuelve al SHA inmutable vía `git ls-remote` (host-agnóstico, sin API ni tokens) y
|
|
/// reescribe la receta. El sello determinista al estilo Nix (ADR 0006). No-op si ya es un SHA.
|
|
Pin {
|
|
/// Receta a anclar (`recipes/foo.toml`).
|
|
recipe: PathBuf,
|
|
/// Salida. Sin esto, reescribe la receta IN-PLACE.
|
|
#[arg(long, short)]
|
|
out: 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 {
|
|
/// La intención en lenguaje natural. En modo mock (`--catalog`), debe coincidir
|
|
/// exactamente con una entrada del catálogo; en modo `--llm`, es texto libre.
|
|
intent: String,
|
|
/// Catálogo YAML de intent → swm (MockTranslator). Mutuamente excluyente con `--llm`.
|
|
#[arg(long)]
|
|
catalog: Option<PathBuf>,
|
|
/// Activa el traductor LLM real (Claude API). Requiere `ANTHROPIC_API_KEY` en el env
|
|
/// y un binario compilado con `--features llm-claude`. Modelo por defecto:
|
|
/// `claude-opus-4-8` (sobreescribible con `HAMMER_LLM_MODEL`).
|
|
#[arg(long)]
|
|
llm: bool,
|
|
/// Re-rootea las mutaciones bajo este prefix en vez de abrir overlay. Útil para
|
|
/// dev/CI sin root ni overlayfs.
|
|
#[arg(long)]
|
|
prefix: Option<PathBuf>,
|
|
/// Base local (JSON). Si está, el orquestador verifica `verify_base` contra ella.
|
|
#[arg(long)]
|
|
base_ref: Option<PathBuf>,
|
|
/// Socket de hammerd. Si está + el .swm trae source_patch, el orquestador llama
|
|
/// Compile por el bus. Si no, source_patch se omite con warning.
|
|
#[arg(long)]
|
|
bus: Option<PathBuf>,
|
|
/// Raíz de overlays para `try`. Default coincide con `hammer try`.
|
|
#[arg(long)]
|
|
state_root: Option<PathBuf>,
|
|
/// Si está, activa el bucle de auto-reparación: tras aplicar, espera en el bus
|
|
/// de eventos (mismo socket que `--bus` si no se da otro) un evento `Crashed`
|
|
/// durante `--repair-window-ms`. Si llega, traduce `(service, code)` a una nueva
|
|
/// intención y reaplica, hasta este máximo de intentos. Sin `--repair-max-attempts`,
|
|
/// el bucle agéntico corre una sola vez (compatible hacia atrás).
|
|
#[arg(long)]
|
|
repair_max_attempts: Option<u32>,
|
|
/// Ventana de espera por un Crashed tras cada intento. Default: 5000 ms.
|
|
#[arg(long, default_value = "5000")]
|
|
repair_window_ms: u64,
|
|
},
|
|
/// [Fase 5] Envía un comando al FIFO de control humano del init.
|
|
/// Por defecto: /run/init.control. Equivalente a `echo "<line>" > /run/init.control`,
|
|
/// pero con un mensaje de error claro si el FIFO no existe.
|
|
Ctl {
|
|
/// La línea a enviar (p. ej. "start web", "restart network").
|
|
line: String,
|
|
#[arg(long, default_value = "/run/init.control")]
|
|
fifo: PathBuf,
|
|
},
|
|
/// [Fase 6] Evalúa una expresión del mini-lenguaje (SDD 08 §6) contra el sistema local.
|
|
/// Forma: `kind:value`. Kinds soportados: `bin`, `file`, `pin`, `service`, `depends`.
|
|
/// Ejemplos:
|
|
/// hammer query bin:grep
|
|
/// hammer query file:/etc/hosts
|
|
/// hammer query depends:/usr/bin/curl
|
|
/// hammer query pin:musl --base-ref base.json
|
|
Query {
|
|
/// La expresión a evaluar.
|
|
expr: String,
|
|
/// Base local (JSON) para resolver `pin:<name>`. Opcional.
|
|
#[arg(long)]
|
|
base_ref: Option<PathBuf>,
|
|
/// Re-rootea las rutas absolutas bajo este prefix (útil para probar contra un overlay
|
|
/// o un prefix de test sin tocar el FHS real).
|
|
#[arg(long)]
|
|
fs_root: Option<PathBuf>,
|
|
},
|
|
/// [Track posterior] Bootstrap from-scratch (SDD 11). Encadena el toolchain semilla y el
|
|
/// userland mínimo hacia el auto-alojamiento.
|
|
Bootstrap {
|
|
#[command(subcommand)]
|
|
sub: BootstrapCmd,
|
|
},
|
|
/// [Etapa E3] Mirror del store content-addressed: replica artefactos por hash entre máquinas,
|
|
/// verificando `of_tree` (BLAKE3) en recepción. `--store` es el store local.
|
|
Mirror {
|
|
#[command(subcommand)]
|
|
sub: MirrorCmd,
|
|
},
|
|
|
|
/// [Etapa E4] Upgrades atómicos con rollback: aplica un árbol Stage1/producto del store al root
|
|
/// vivo dejando una generación nueva; `rollback` revierte exactamente al árbol anterior.
|
|
Upgrade {
|
|
/// Root del FHS vivo sobre el que se proyecta el árbol (default `/`).
|
|
#[arg(long, default_value = "/", global = true)]
|
|
root: String,
|
|
/// Directorio de estado de generaciones (default `/var/lib/hammer/upgrades`).
|
|
#[arg(long, default_value = hammer_upgrade::DEFAULT_STATE_ROOT, global = true)]
|
|
state_root: String,
|
|
/// Diario donde registrar cada cambio (default `/var/lib/hammer/journal`).
|
|
#[arg(long, default_value = "/var/lib/hammer/journal", global = true)]
|
|
journal: String,
|
|
#[command(subcommand)]
|
|
sub: UpgradeCmd,
|
|
},
|
|
|
|
/// [ADR 0010] Arranque por grafo: el menú de boot es la navegación del grafo de estados del
|
|
/// sistema. `graph` publica el grafo para mirada; `activate` deja el sistema en un nodo.
|
|
Boot {
|
|
/// Root del FHS vivo sobre el que se activa un nodo (default `/`).
|
|
#[arg(long, default_value = "/", global = true)]
|
|
root: String,
|
|
/// Directorio de estado de generaciones (default `/var/lib/hammer/upgrades`).
|
|
#[arg(long, default_value = hammer_upgrade::DEFAULT_STATE_ROOT, global = true)]
|
|
state_root: String,
|
|
/// Diario donde registrar cada cambio al activar (default `/var/lib/hammer/journal`).
|
|
#[arg(long, default_value = "/var/lib/hammer/journal", global = true)]
|
|
journal: String,
|
|
#[command(subcommand)]
|
|
sub: BootCmd,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum BootCmd {
|
|
/// Emite el grafo de estados a `--out` (default `/run/hammer/boot-graph.json`) para que mirada
|
|
/// dibuje el menú. Regenerable, world-readable.
|
|
Graph {
|
|
/// Path de salida del grafo.
|
|
#[arg(long, default_value = hammer_upgrade::boot_graph::BOOT_GRAPH_PATH)]
|
|
out: PathBuf,
|
|
/// En vez de escribir el fichero, imprime el grafo por stdout (para inspección/tests).
|
|
#[arg(long)]
|
|
stdout: bool,
|
|
},
|
|
/// Activa el nodo `<id>` del grafo (deja el sistema en ese estado). Con `--from-select` lee el id
|
|
/// que mirada dejó en `/run/hammer/boot-select` en vez de pasarlo por argumento.
|
|
Activate {
|
|
/// Id (BLAKE3, con o sin `b3:`) del nodo a activar. Omitir con `--from-select`.
|
|
id: Option<String>,
|
|
/// Lee el id desde `/run/hammer/boot-select` (canal de vuelta de mirada).
|
|
#[arg(long)]
|
|
from_select: bool,
|
|
},
|
|
}
|
|
|
|
#[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,
|
|
},
|
|
/// Firma el ÍNDICE entero (release): ancla qué paquetes/versiones/hashes existen. Re-firmá
|
|
/// tras publicar (cada `pack --repo` invalida la firma previa).
|
|
Sign {
|
|
#[arg(long, default_value = DEFAULT_REPO)]
|
|
repo: PathBuf,
|
|
/// Clave privada Ed25519 con la que firmar el release.
|
|
#[arg(long)]
|
|
key: PathBuf,
|
|
/// Autor de la firma. Default: basename de la clave sin `.ed25519`.
|
|
#[arg(long)]
|
|
by: Option<String>,
|
|
},
|
|
/// Verifica la firma del release del repositorio contra las claves de confianza.
|
|
Verify {
|
|
#[arg(long, default_value = DEFAULT_REPO)]
|
|
repo: PathBuf,
|
|
/// Directorio de claves de confianza. Default `/var/lib/hammer/trust`.
|
|
#[arg(long)]
|
|
trust: Option<PathBuf>,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum UpgradeCmd {
|
|
/// Aplica el árbol `<tree-dir>` (`<hash>-<name>` del store) al root vivo, dejando una generación.
|
|
Apply {
|
|
/// Directorio del artefacto en el store (p.ej. `b3hex…-product-rootfs`).
|
|
tree_dir: String,
|
|
/// `of_tree` esperado (`b3:…`) — de un índice de mirror o release firmada; si se da, se exige.
|
|
#[arg(long)]
|
|
expected: Option<String>,
|
|
},
|
|
/// Revierte la generación viva al árbol anterior (o al estado pre-upgrades).
|
|
Rollback,
|
|
/// Completa (o deshace con --rollback) un apply interrumpido por un corte/reinicio (intento pendiente).
|
|
Recover {
|
|
/// Deshace el apply a medias (vuelve al estado pre-apply) en vez de completarlo.
|
|
#[arg(long)]
|
|
rollback: bool,
|
|
},
|
|
/// Muestra la generación viva y el historial.
|
|
Status,
|
|
/// Borra generaciones que el rollback ya no alcanza (huérfanas tras un rollback). Recupera espacio.
|
|
Prune {
|
|
/// Recorta también la cadena viva a sus N generaciones más nuevas (limita la profundidad de
|
|
/// rollback). Sin flag, conserva toda la cadena y borra sólo las huérfanas.
|
|
#[arg(long)]
|
|
keep: Option<usize>,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum MirrorCmd {
|
|
/// Replica al store REMOTO los artefactos que le faltan (verificados antes de sellar).
|
|
Push {
|
|
/// Ruta del store remoto destino (puede ser un montaje de red/sshfs).
|
|
remote: String,
|
|
},
|
|
/// Trae del store REMOTO los artefactos que le faltan al local (verificados antes de sellar).
|
|
Pull {
|
|
/// Ruta del store remoto origen.
|
|
remote: String,
|
|
},
|
|
/// Muestra el diff entre el store local y uno remoto (qué falta en cada lado, conflictos). No copia.
|
|
Status {
|
|
/// Ruta del store remoto a comparar.
|
|
remote: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum BootstrapCmd {
|
|
/// [Stage 0] Ingiere un toolchain semilla pinned al store. Verifica el sha256 antes de
|
|
/// extraer y lo sella; idempotente si ya está. Imprime el hash del artefacto.
|
|
Stage0 {
|
|
/// URL del tarball del toolchain (cualquier esquema de curl; `file://` para offline).
|
|
#[arg(long)]
|
|
url: String,
|
|
/// sha256 hex del tarball, fijado. Se verifica antes de sellar.
|
|
#[arg(long)]
|
|
sha256: String,
|
|
/// Versión de la semilla (entra en la identidad/hash del artefacto).
|
|
#[arg(long)]
|
|
version: String,
|
|
/// Clase de semilla.
|
|
#[arg(long, default_value = "zig")]
|
|
seed: String,
|
|
},
|
|
/// [Stage 1] Cross-compila el userland mínimo (musl + busybox) con la semilla de Stage 0,
|
|
/// ensambla un rootfs FHS y lo sella. Imprime el hash del rootfs.
|
|
Stage1 {
|
|
/// Hash de la semilla ya sellada por Stage 0 (con o sin prefijo `b3:`).
|
|
#[arg(long)]
|
|
seed_hash: String,
|
|
/// Clase de semilla (debe coincidir con la de Stage 0).
|
|
#[arg(long, default_value = "zig")]
|
|
seed: String,
|
|
/// Directorio de recetas (`musl.toml`, `busybox.toml`).
|
|
#[arg(long, default_value = "recipes")]
|
|
recipes: String,
|
|
},
|
|
/// [Producto] Capa de SERVICIOS: toma el rootfs base verificado del 4/4 (`--rootfs`, el que
|
|
/// imprimió `stage1`) e inyecta por hidratación tardía los componentes de servicio (sshd/openssh
|
|
/// + netup) y sus configs, sellando un `product-rootfs` APARTE. El mecanismo base no se toca (su
|
|
/// `of_tree` sigue blindado para el selfhost-verify). Separación Mecanismo/Política.
|
|
Product {
|
|
/// Hash del rootfs base de Stage 1 (el que imprimió `stage1`; con o sin `b3:`).
|
|
#[arg(long)]
|
|
rootfs: String,
|
|
/// Directorio de recetas (`openssh.toml`, `netup.toml`, …).
|
|
#[arg(long, default_value = "recipes")]
|
|
recipes: String,
|
|
/// Produce además el producto ATESTADO (`product-attested-rootfs`): hidrata el init CON gate
|
|
/// (`arje-zero-attest`) y FIRMA la seed con `arje-packager`. Imprime el hash del atestado.
|
|
#[arg(long)]
|
|
attest: bool,
|
|
/// Política del gate de atestación: `halt` (default) | `degraded` | `warn`. Sólo con `--attest`.
|
|
#[arg(long, default_value = "halt")]
|
|
policy: String,
|
|
/// Fichero con la rootkey de 32 bytes raw para firmar. Sin él se usa la rootkey de desarrollo
|
|
/// determinista (firmas reproducibles). Sólo con `--attest`.
|
|
#[arg(long)]
|
|
rootkey: Option<String>,
|
|
/// [Dogfood] Arma el userland REPRODUCIÉNDOLO desde un REPO FIRMADO (dir local) en vez de las
|
|
/// recetas locales: verifica la firma del release y reconstruye cada paquete desde su `.swm`
|
|
/// comparando el `expected_hash`. El `product-rootfs` sale bit-idéntico al de la vía local (hash
|
|
/// por-contenido). Requiere `--trust`. La cadena de suministro de la propia imagen.
|
|
#[arg(long)]
|
|
from_repo: Option<String>,
|
|
/// Directorio de claves de confianza para verificar la firma del release del `--from-repo`.
|
|
#[arg(long)]
|
|
trust: Option<String>,
|
|
},
|
|
/// [Stage 2] Ancla el content-hash del rootfs de Stage 1 como referencia de reproducibilidad
|
|
/// (anota la línea del manifiesto). Con `--verify <hash>` compara contra un rebuild `stage1'`
|
|
/// (producido en la VM) y emite el veredicto de auto-alojamiento.
|
|
Stage2 {
|
|
/// Hash del rootfs de Stage 1 (el que imprimió `stage1`; con o sin `b3:`).
|
|
#[arg(long)]
|
|
rootfs: String,
|
|
/// Content-hash de un rebuild `stage1'` para comparar. Sin él, sólo ancla la referencia.
|
|
#[arg(long)]
|
|
verify: Option<String>,
|
|
},
|
|
/// [Stage 2 · builder] Ensambla el **builder rootfs** (Stage 1 + toolchain) que, booteado en la
|
|
/// VM, reconstruye `stage1'` con sus propias herramientas (auto-alojamiento, SDD 11 §7). Variante
|
|
/// pragmática: el toolchain entra desde `.dev-fs/alpine`.
|
|
Builder {
|
|
/// Hash del rootfs de Stage 1 (el que imprimió `stage1`; con o sin `b3:`).
|
|
#[arg(long)]
|
|
stage1: String,
|
|
/// Hash de la semilla sellada por Stage 0 (con o sin `b3:`).
|
|
#[arg(long)]
|
|
seed_hash: String,
|
|
/// Clase de semilla (debe coincidir con la de Stage 0).
|
|
#[arg(long, default_value = "zig")]
|
|
seed: String,
|
|
/// Directorio de recetas a embeber.
|
|
#[arg(long, default_value = "recipes")]
|
|
recipes: String,
|
|
/// Binario `hammer` estático a instalar en `/usr/bin/hammer`.
|
|
#[arg(long, default_value = "target/x86_64-unknown-linux-musl/release/hammer")]
|
|
hammer_bin: String,
|
|
/// Toolchain del sandbox de build → `/toolchain` (rootfs Alpine de `.dev-fs`).
|
|
#[arg(long, default_value = ".dev-fs/alpine")]
|
|
toolchain: String,
|
|
/// Etiqueta de identidad del toolchain (entra al hash lógico del builder).
|
|
#[arg(long, default_value = "alpine")]
|
|
toolchain_tag: String,
|
|
/// Content-hash `of_tree(stage1)` (referencia anclada por `stage2`) para que el rebuild se
|
|
/// auto-verifique en la VM. Sin él, el driver sólo imprime el content-hash de `stage1'`.
|
|
#[arg(long)]
|
|
ref_content: Option<String>,
|
|
/// Caché de fuentes (`work/`) a embeber en `/work` para un rebuild offline.
|
|
#[arg(long)]
|
|
work_cache: Option<String>,
|
|
/// Pieza del toolchain construida por hammer desde fuente (variante b, SDD 11 §7.2b) que pisa
|
|
/// la de Alpine en `/toolchain`. Formato `name=hash[:rel_path]`; `rel_path` por defecto
|
|
/// `usr/bin/<name>`. Repetible. Ej.: `--swap make=fbad44ac…`. Cada swap entra al hash lógico.
|
|
#[arg(long = "swap", value_name = "name=hash[:rel_path]")]
|
|
swaps: Vec<String>,
|
|
/// Dónde ensamblar el builder (se reensambla limpio en cada corrida).
|
|
#[arg(long, default_value = "work/builder-rootfs")]
|
|
out: String,
|
|
},
|
|
/// [Stage 0+1+2] Encadena las tres etapas (semilla → rootfs → referencia anclada) en una corrida
|
|
/// y deja el `bootstrap.json` poblado (SDD 11 §5). El veredicto `✓ REPRODUCIBLE` lo sella el
|
|
/// rebuild nativo dentro del rootfs (VM, `scripts/selfhost-verify.sh`), no esta corrida del host.
|
|
All {
|
|
/// URL del tarball del toolchain semilla (cualquier esquema de curl; `file://` para offline).
|
|
#[arg(long)]
|
|
url: String,
|
|
/// sha256 hex del tarball, fijado. Se verifica antes de sellar.
|
|
#[arg(long)]
|
|
sha256: String,
|
|
/// Versión de la semilla (entra en la identidad/hash del artefacto).
|
|
#[arg(long)]
|
|
version: String,
|
|
/// Clase de semilla.
|
|
#[arg(long, default_value = "zig")]
|
|
seed: String,
|
|
/// Directorio de recetas (`musl.toml`, `busybox.toml`, `hammerd.toml`, `arje-zero.toml`).
|
|
#[arg(long, default_value = "recipes")]
|
|
recipes: String,
|
|
},
|
|
/// Imprime el `bootstrap.json` del store: el log de transparencia (SDD 11 §4). Un tercero
|
|
/// reproduce el bootstrap y verifica que sus hashes coinciden con los de estas líneas.
|
|
Manifest,
|
|
}
|
|
|
|
/// Parsea la clase de semilla del flag `--seed`. Único punto de verdad para todas las subcomandos.
|
|
fn parse_seed_kind(s: &str) -> anyhow::Result<hammer_bootstrap::SeedKind> {
|
|
match s {
|
|
"zig" => Ok(hammer_bootstrap::SeedKind::Zig),
|
|
"musl-cross-make" => Ok(hammer_bootstrap::SeedKind::MuslCrossMake),
|
|
other => anyhow::bail!("--seed debe ser 'zig' o 'musl-cross-make', no '{other}'"),
|
|
}
|
|
}
|
|
|
|
fn print_event(ev: &hammer_journal::MutationEvent, format: &str) {
|
|
match format {
|
|
"json" => {
|
|
// Re-serializa. No imprimimos la línea original (no la tenemos a mano), pero el
|
|
// formato es estable porque MutationEvent es la fuente de verdad del schema.
|
|
match serde_json::to_string(ev) {
|
|
Ok(s) => println!("{s}"),
|
|
Err(e) => eprintln!("journal: error serializando: {e}"),
|
|
}
|
|
}
|
|
_ => {
|
|
let actor = match &ev.by.source {
|
|
hammer_journal::Source::HammerHydrate { artifact } => format!("hydrate {artifact}"),
|
|
hammer_journal::Source::HammerCommit { overlay, artifact } => match artifact {
|
|
Some(a) => format!("commit {overlay} ({a})"),
|
|
None => format!("commit {overlay}"),
|
|
},
|
|
hammer_journal::Source::External => {
|
|
let pid = ev.by.pid.map(|p| format!("pid={p}")).unwrap_or_default();
|
|
let uid = ev.by.uid.map(|u| format!("uid={u}")).unwrap_or_default();
|
|
format!("external {pid} {uid}").trim().to_string()
|
|
}
|
|
};
|
|
println!(
|
|
"{ts} {op:<7} {path} ← {actor}",
|
|
ts = ev.ts,
|
|
op = ev.op.as_str(),
|
|
path = ev.path.display(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn main() -> anyhow::Result<()> {
|
|
tracing_subscriber::fmt()
|
|
.with_writer(std::io::stderr)
|
|
.with_env_filter(
|
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| "info".into()),
|
|
)
|
|
.init();
|
|
|
|
let cli = Cli::parse();
|
|
|
|
// Sólo las cmds que de verdad tocan artefactos abren (y posiblemente crean) el store.
|
|
// Las cmds de overlay/diario/apply pueden correr en sistemas sin store local todavía.
|
|
match cli.cmd {
|
|
Cmd::Build { recipe } => {
|
|
let store = hammer_core::Store::open(&cli.store)?;
|
|
let recipe = hammer_core::Recipe::load_from_path(&recipe)?;
|
|
let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
|
|
let hash = hammer_build::build(&recipe, &cfg, &store)?;
|
|
println!("{hash}");
|
|
}
|
|
Cmd::Attest { rootfs } => {
|
|
let report = hammer_bootstrap::verify_attestation(std::path::Path::new(&rootfs))?;
|
|
for p in &report.ok {
|
|
println!(" ✓ {p}");
|
|
}
|
|
for p in &report.missing {
|
|
println!(" ✗ FALTA {p}");
|
|
}
|
|
for p in &report.mismatched {
|
|
println!(" ✗ DIVERGE {p}");
|
|
}
|
|
if report.passed() {
|
|
println!(
|
|
"✓ ATESTACIÓN OK: {} binarios críticos casan su BLAKE3 esperado",
|
|
report.ok.len()
|
|
);
|
|
} else {
|
|
anyhow::bail!(
|
|
"✗ ATESTACIÓN FALLÓ: {} diverge(n), {} falta(n) — integridad comprometida",
|
|
report.mismatched.len(),
|
|
report.missing.len()
|
|
);
|
|
}
|
|
}
|
|
Cmd::Hydrate { hash, into, link } => {
|
|
let store = hammer_core::Store::open(&cli.store)?;
|
|
let mode = match link.as_str() {
|
|
"static" => hammer_core::LinkMode::Static,
|
|
"dynamic" => hammer_core::LinkMode::Dynamic,
|
|
other => anyhow::bail!("--link debe ser 'static' o 'dynamic', no '{other}'"),
|
|
};
|
|
let artifact_dir = store.find_by_hash(&hash)?;
|
|
let report = hammer_build::run_hydrate(
|
|
&artifact_dir,
|
|
std::path::Path::new(&into),
|
|
mode,
|
|
None,
|
|
)?;
|
|
println!(
|
|
"hydrated {} archivo(s) ({} dir(s), {} symlink(s)) → {}",
|
|
report.files.len(),
|
|
report.dirs_created,
|
|
report.symlinks,
|
|
into
|
|
);
|
|
for f in &report.files {
|
|
println!(" {}", f.dst.display());
|
|
}
|
|
}
|
|
Cmd::Try { targets, state_root } => {
|
|
let root = state_root
|
|
.unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
|
|
std::fs::create_dir_all(&root)?;
|
|
let id = hammer_overlay::try_overlay(&targets, &root)?;
|
|
println!("{id}");
|
|
}
|
|
Cmd::Commit { id, state_root, journal, no_journal } => {
|
|
let root = state_root
|
|
.unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
|
|
let id = hammer_overlay::OverlayId(id);
|
|
let report = if no_journal {
|
|
hammer_overlay::commit(&id, &root)?
|
|
} else {
|
|
let j = hammer_journal::Journal::open(&journal)?;
|
|
hammer_overlay::commit_with_journal(&id, &root, &j)?
|
|
};
|
|
println!(
|
|
"commit OK — {} archivo(s) promocionados, {} eliminado(s){}",
|
|
report.copied.len(),
|
|
report.removed.len(),
|
|
if no_journal { "" } else { " (anotado en el diario)" }
|
|
);
|
|
}
|
|
Cmd::Discard { id, state_root } => {
|
|
let root = state_root
|
|
.unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
|
|
hammer_overlay::discard(&hammer_overlay::OverlayId(id), &root)?;
|
|
println!("discard OK");
|
|
}
|
|
Cmd::Status { state_root } => {
|
|
let root = state_root
|
|
.unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
|
|
let overlays = hammer_overlay::status(&root)?;
|
|
if overlays.is_empty() {
|
|
println!("(sin overlays activos)");
|
|
} else {
|
|
for o in overlays {
|
|
println!(
|
|
"{} created={} mounts={}",
|
|
o.id,
|
|
o.created_at,
|
|
o.mounts.len()
|
|
);
|
|
for m in &o.mounts {
|
|
println!(" └─ {} upper={}", m.target.display(), m.upper.display());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Cmd::Journal { dir, tail, follow, format } => {
|
|
let j = hammer_journal::Journal::open(&dir)?;
|
|
let initial = j.tail(tail)?;
|
|
for ev in &initial {
|
|
print_event(ev, &format);
|
|
}
|
|
if follow {
|
|
use std::io::Write;
|
|
let _ = std::io::stdout().flush();
|
|
let mut cursor = j.end_offset()?;
|
|
loop {
|
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
|
let (news, new_cursor) = j.read_from_offset(cursor)?;
|
|
cursor = new_cursor;
|
|
for ev in &news {
|
|
print_event(ev, &format);
|
|
}
|
|
let _ = std::io::stdout().flush();
|
|
}
|
|
}
|
|
}
|
|
Cmd::Apply { file, prefix, skip_source_patch, base_ref, state_root } => {
|
|
run_apply(
|
|
&cli.store,
|
|
&file,
|
|
prefix.as_deref(),
|
|
skip_source_patch,
|
|
base_ref.as_deref(),
|
|
state_root.as_deref(),
|
|
None, // `apply` aplica un .swm genérico: no conoce el nombre del paquete
|
|
)?;
|
|
}
|
|
Cmd::SwmVerify { file, base_ref, trust, evidence } => {
|
|
run_swm_verify(
|
|
&file,
|
|
base_ref.as_deref(),
|
|
trust.as_deref(),
|
|
evidence,
|
|
std::path::Path::new(&cli.store),
|
|
)?;
|
|
}
|
|
Cmd::Keygen { name, out } => {
|
|
run_keygen(&name, out.as_deref())?;
|
|
}
|
|
Cmd::SwmSign { file, key, by, out } => {
|
|
run_swm_sign(&file, &key, by.as_deref(), out.as_deref())?;
|
|
}
|
|
Cmd::Export { base_ref, journal, since } => {
|
|
run_export(base_ref.as_deref(), &journal, since.as_deref(), &cli.store)?;
|
|
}
|
|
Cmd::Pack {
|
|
recipe,
|
|
target_bin,
|
|
out,
|
|
repo,
|
|
distro_version,
|
|
expected,
|
|
build,
|
|
sign,
|
|
} => {
|
|
run_pack(
|
|
&recipe,
|
|
target_bin.as_deref(),
|
|
out.as_deref(),
|
|
repo.as_deref(),
|
|
&distro_version,
|
|
expected.as_deref(),
|
|
build,
|
|
sign.as_deref(),
|
|
&cli.store,
|
|
)?;
|
|
}
|
|
Cmd::Install {
|
|
name,
|
|
repo,
|
|
prefix,
|
|
skip_source_patch,
|
|
base_ref,
|
|
trust,
|
|
state_root,
|
|
db,
|
|
require_signed,
|
|
force_slots,
|
|
} => {
|
|
run_install(
|
|
&cli.store,
|
|
&name,
|
|
&repo,
|
|
prefix.as_deref(),
|
|
skip_source_patch,
|
|
base_ref.as_deref(),
|
|
trust.as_deref(),
|
|
state_root.as_deref(),
|
|
&db,
|
|
require_signed,
|
|
force_slots,
|
|
)?;
|
|
}
|
|
Cmd::Uninstall { name, db } => run_uninstall(&name, &db)?,
|
|
Cmd::Installed { db } => run_installed_list(&db)?,
|
|
Cmd::Compat { repo, db } => run_compat(&repo, &db)?,
|
|
Cmd::ImportNix { file, out } => run_import_nix(&file, out.as_deref())?,
|
|
Cmd::ImportAlpine { file, out } => run_import_alpine(&file, out.as_deref())?,
|
|
Cmd::Pin { recipe, out } => run_pin(&recipe, out.as_deref())?,
|
|
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())?,
|
|
RepoCmd::Verify { repo, trust } => run_repo_verify(&repo, trust.as_deref())?,
|
|
},
|
|
Cmd::Ai {
|
|
intent,
|
|
catalog,
|
|
llm,
|
|
prefix,
|
|
base_ref,
|
|
bus,
|
|
state_root,
|
|
repair_max_attempts,
|
|
repair_window_ms,
|
|
} => {
|
|
run_ai(
|
|
&intent,
|
|
catalog.as_deref(),
|
|
llm,
|
|
prefix.as_deref(),
|
|
base_ref.as_deref(),
|
|
bus.as_deref(),
|
|
state_root.as_deref(),
|
|
&cli.store,
|
|
repair_max_attempts,
|
|
repair_window_ms,
|
|
)?;
|
|
}
|
|
Cmd::Ctl { line, fifo } => {
|
|
run_ctl(&line, &fifo)?;
|
|
}
|
|
Cmd::Query { expr, base_ref, fs_root } => {
|
|
run_query(&expr, base_ref.as_deref(), fs_root.as_deref())?;
|
|
}
|
|
Cmd::Bootstrap { sub } => match sub {
|
|
BootstrapCmd::Stage0 { url, sha256, version, seed } => {
|
|
let kind = parse_seed_kind(&seed)?;
|
|
let store = hammer_core::Store::open(&cli.store)?;
|
|
let spec = hammer_bootstrap::SeedSpec { kind, version, url, sha256 };
|
|
let hash = hammer_bootstrap::stage0(&spec, &store)?;
|
|
println!("{hash}");
|
|
}
|
|
BootstrapCmd::Stage1 { seed_hash, seed, recipes } => {
|
|
let seed_kind = parse_seed_kind(&seed)?;
|
|
let store = hammer_core::Store::open(&cli.store)?;
|
|
let base_cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
|
|
let spec = hammer_bootstrap::Stage1Spec {
|
|
seed_hash: hammer_core::ArtifactHash::from_hex(seed_hash.trim_start_matches("b3:")),
|
|
seed_kind,
|
|
recipes_dir: std::path::PathBuf::from(recipes),
|
|
};
|
|
let hash = hammer_bootstrap::stage1(&spec, &base_cfg, &store)?;
|
|
println!("{hash}");
|
|
}
|
|
BootstrapCmd::Product { rootfs, recipes, attest, policy, rootkey, from_repo, trust } => {
|
|
let store = hammer_core::Store::open(&cli.store)?;
|
|
// El zig del lab (no la semilla): los servicios usan su `zig_version` propia
|
|
// (openssh ⇒ 0.13.0, hermano del default), y si ya están sellados, salen cacheados.
|
|
let base_cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
|
|
let base = hammer_core::ArtifactHash::from_hex(rootfs.trim_start_matches("b3:"));
|
|
let recipes_dir = std::path::Path::new(&recipes);
|
|
let hash = if let Some(repo) = from_repo {
|
|
// [Dogfood] userland desde el repo FIRMADO (la imagen arma su userland por la cadena
|
|
// de suministro de paquetes, no por recetas locales). --attest se combina como capa
|
|
// posterior; acá producimos el product-rootfs base reproducido del repo.
|
|
if attest {
|
|
anyhow::bail!("--from-repo y --attest aún no se combinan; producí el base con --from-repo y atestá aparte");
|
|
}
|
|
let trust_dir = trust.ok_or_else(|| {
|
|
anyhow::anyhow!("--from-repo requiere --trust <dir> (verificar la firma del release)")
|
|
})?;
|
|
hammer_bootstrap::product_from_repo(
|
|
&base,
|
|
std::path::Path::new(&repo),
|
|
std::path::Path::new(&trust_dir),
|
|
&base_cfg,
|
|
&store,
|
|
)?
|
|
} else if attest {
|
|
let mut cfg = hammer_bootstrap::AttestConfig { policy, ..Default::default() };
|
|
if let Some(path) = rootkey {
|
|
let bytes = std::fs::read(&path)?;
|
|
cfg.rootkey = bytes.as_slice().try_into().map_err(|_| {
|
|
anyhow::anyhow!("la rootkey {path} debe ser exactamente 32 bytes (son {})", bytes.len())
|
|
})?;
|
|
}
|
|
hammer_bootstrap::product_attested(&base, &base_cfg, recipes_dir, &store, &cfg)?
|
|
} else {
|
|
hammer_bootstrap::product(&base, &base_cfg, recipes_dir, &store)?
|
|
};
|
|
println!("{hash}");
|
|
}
|
|
BootstrapCmd::Stage2 { rootfs, verify } => {
|
|
let store = hammer_core::Store::open(&cli.store)?;
|
|
let rootfs_hash =
|
|
hammer_core::ArtifactHash::from_hex(rootfs.trim_start_matches("b3:"));
|
|
let report = hammer_bootstrap::stage2(&rootfs_hash, &store)?;
|
|
println!("stage1 content-hash: {}", report.stage1_content);
|
|
match verify {
|
|
None => println!("referencia anclada (rebuild nativo de stage1': correr en la VM)"),
|
|
Some(rebuilt) => {
|
|
let rb =
|
|
hammer_core::ArtifactHash::from_hex(rebuilt.trim_start_matches("b3:"));
|
|
match hammer_bootstrap::verify_against(&report, rb) {
|
|
hammer_bootstrap::Reproducibility::Reproducible => {
|
|
println!("✓ REPRODUCIBLE: stage1' == stage1 (auto-alojado bit a bit)");
|
|
}
|
|
hammer_bootstrap::Reproducibility::Divergent { stage1, rebuilt } => {
|
|
anyhow::bail!(
|
|
"✗ DIVERGENTE: stage1={stage1} rebuilt={rebuilt} — hay no-determinismo que cazar (SDD 09 §2)"
|
|
);
|
|
}
|
|
hammer_bootstrap::Reproducibility::RebuildPending => unreachable!(),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
BootstrapCmd::Builder {
|
|
stage1,
|
|
seed_hash,
|
|
seed,
|
|
recipes,
|
|
hammer_bin,
|
|
toolchain,
|
|
toolchain_tag,
|
|
ref_content,
|
|
work_cache,
|
|
swaps,
|
|
out,
|
|
} => {
|
|
let seed_kind = parse_seed_kind(&seed)?;
|
|
let store = hammer_core::Store::open(&cli.store)?;
|
|
let swaps = swaps.iter().map(|s| parse_swap(s)).collect::<anyhow::Result<Vec<_>>>()?;
|
|
let spec = hammer_bootstrap::BuilderSpec {
|
|
stage1_rootfs: hammer_core::ArtifactHash::from_hex(stage1.trim_start_matches("b3:")),
|
|
seed_hash: hammer_core::ArtifactHash::from_hex(seed_hash.trim_start_matches("b3:")),
|
|
seed_kind,
|
|
recipes_dir: std::path::PathBuf::from(recipes),
|
|
hammer_bin: std::path::PathBuf::from(hammer_bin),
|
|
toolchain_src: std::path::PathBuf::from(toolchain),
|
|
toolchain_tag,
|
|
ref_content: ref_content
|
|
.map(|h| hammer_core::ArtifactHash::from_hex(h.trim_start_matches("b3:").to_string())),
|
|
work_cache: work_cache.map(std::path::PathBuf::from),
|
|
swaps,
|
|
};
|
|
let out = std::path::PathBuf::from(out);
|
|
let report = hammer_bootstrap::builder_rootfs(&spec, &store, &out)?;
|
|
println!("builder rootfs: {}", report.builder_hash);
|
|
println!("ensamblado en: {}", report.out_dir.display());
|
|
println!(
|
|
"siguiente: empaquetar como initramfs y bootear en la VM; adentro corre `rebuild-stage1` (runbook §8c)"
|
|
);
|
|
}
|
|
BootstrapCmd::All { url, sha256, version, seed, recipes } => {
|
|
let kind = parse_seed_kind(&seed)?;
|
|
let store = hammer_core::Store::open(&cli.store)?;
|
|
let base_cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
|
|
let spec = hammer_bootstrap::SeedSpec { kind, version, url, sha256 };
|
|
let report = hammer_bootstrap::all(
|
|
&spec,
|
|
std::path::Path::new(&recipes),
|
|
&base_cfg,
|
|
&store,
|
|
)?;
|
|
println!("Stage 0 · semilla {}", report.seed_hash);
|
|
println!("Stage 1 · rootfs {}", report.stage1_rootfs);
|
|
println!("Stage 2 · referencia {}", report.stage2.stage1_content);
|
|
println!();
|
|
println!(
|
|
"bootstrap.json: {} línea(s) anotada(s) (`hammer bootstrap manifest` para verlo)",
|
|
report.manifest.entries.len()
|
|
);
|
|
println!(
|
|
"auto-alojamiento: el rebuild nativo que sella ✓ REPRODUCIBLE corre dentro del rootfs (VM):"
|
|
);
|
|
println!(
|
|
" ./scripts/selfhost-verify.sh # reconstruye stage1' y lo compara con la referencia de arriba"
|
|
);
|
|
}
|
|
BootstrapCmd::Manifest => {
|
|
let store = hammer_core::Store::open(&cli.store)?;
|
|
let manifest = hammer_bootstrap::BootstrapManifest::load(&store)?;
|
|
println!("{}", serde_json::to_string_pretty(&manifest)?);
|
|
}
|
|
},
|
|
Cmd::Mirror { sub } => {
|
|
let local = std::path::PathBuf::from(&cli.store);
|
|
match sub {
|
|
MirrorCmd::Push { remote } => {
|
|
let remote = std::path::PathBuf::from(&remote);
|
|
let r = hammer_mirror::sync(&local, &remote)?;
|
|
print_sync("push", &r);
|
|
if !r.ok() {
|
|
anyhow::bail!("mirror push incompleto: {} fallo(s), {} conflicto(s)", r.failed.len(), r.conflicts.len());
|
|
}
|
|
}
|
|
MirrorCmd::Pull { remote } => {
|
|
let remote = std::path::PathBuf::from(&remote);
|
|
let r = hammer_mirror::sync(&remote, &local)?;
|
|
print_sync("pull", &r);
|
|
if !r.ok() {
|
|
anyhow::bail!("mirror pull incompleto: {} fallo(s), {} conflicto(s)", r.failed.len(), r.conflicts.len());
|
|
}
|
|
}
|
|
MirrorCmd::Status { remote } => {
|
|
let remote = std::path::PathBuf::from(&remote);
|
|
let (missing_remote, missing_local, conflicts) = hammer_mirror::diff(&local, &remote)?;
|
|
println!("faltan en el remoto (push los lleva): {}", missing_remote.len());
|
|
for a in &missing_remote { println!(" → {}", a.dir); }
|
|
println!("faltan en el local (pull los trae): {}", missing_local.len());
|
|
for a in &missing_local { println!(" ← {}", a.dir); }
|
|
println!("conflictos (mismo hash, otro contenido): {}", conflicts.len());
|
|
for d in &conflicts { println!(" ✗ {d}"); }
|
|
}
|
|
}
|
|
}
|
|
Cmd::Upgrade { root, state_root, journal, sub } => {
|
|
let store = std::path::PathBuf::from(&cli.store);
|
|
let root = std::path::PathBuf::from(&root);
|
|
let state_root = std::path::PathBuf::from(&state_root);
|
|
match sub {
|
|
UpgradeCmd::Apply { tree_dir, expected } => {
|
|
let j = hammer_journal::Journal::open(&journal)?;
|
|
let r = hammer_upgrade::apply(
|
|
&store, &tree_dir, expected.as_deref(), &root, &state_root, Some(&j),
|
|
)?;
|
|
if r.already_current {
|
|
println!("generación {} ya viva — árbol idéntico, no-op", r.generation);
|
|
} else {
|
|
println!("✓ generación {} aplicada", r.generation);
|
|
println!(" + {} añadidos, ~ {} pisados, - {} retirados",
|
|
r.added.len(), r.replaced.len(), r.removed.len());
|
|
}
|
|
}
|
|
UpgradeCmd::Rollback => {
|
|
let j = hammer_journal::Journal::open(&journal)?;
|
|
let r = hammer_upgrade::rollback(&root, &state_root, Some(&j))?;
|
|
println!("✓ generación {} revertida", r.reverted);
|
|
match r.now_current {
|
|
Some(id) => println!(" ahora viva: generación {id}"),
|
|
None => println!(" ahora viva: estado pre-upgrades"),
|
|
}
|
|
println!(" {} restaurados, {} borrados", r.restored.len(), r.deleted.len());
|
|
}
|
|
UpgradeCmd::Recover { rollback } => {
|
|
let j = hammer_journal::Journal::open(&journal)?;
|
|
let r = hammer_upgrade::recover(&store, &root, &state_root, Some(&j), rollback)?;
|
|
if !r.recovered {
|
|
println!("no hay apply interrumpido — nada que recuperar");
|
|
} else if r.rolled_forward {
|
|
println!("✓ apply interrumpido COMPLETADO (generación {})", r.generation);
|
|
} else {
|
|
println!("✓ apply interrumpido DESHECHO (generación {} descartada)", r.generation);
|
|
}
|
|
}
|
|
UpgradeCmd::Status => {
|
|
if let Some(p) = hammer_upgrade::pending(&state_root)? {
|
|
println!("⚠ apply interrumpido pendiente (generación {}) — corré `hammer upgrade recover [--rollback]`", p.id);
|
|
}
|
|
let cur = hammer_upgrade::current(&state_root)?;
|
|
match cur {
|
|
Some(id) => println!("generación viva: {id}"),
|
|
None => println!("generación viva: (ninguna — sistema sin upgrades aplicados)"),
|
|
}
|
|
let gens = hammer_upgrade::list(&state_root)?;
|
|
println!("historial: {} generación(es)", gens.len());
|
|
for g in &gens {
|
|
let mark = if Some(g.id) == cur { "*" } else { " " };
|
|
println!(" {mark} {} → {} ({} ficheros)", g.id, g.tree_dir, g.files.len());
|
|
}
|
|
}
|
|
UpgradeCmd::Prune { keep } => {
|
|
let r = hammer_upgrade::prune(&state_root, keep)?;
|
|
println!("✓ prune: {} borradas, {} conservadas", r.removed.len(), r.kept.len());
|
|
for id in &r.removed { println!(" - {id}"); }
|
|
}
|
|
}
|
|
}
|
|
Cmd::Boot { root, state_root, journal, sub } => {
|
|
use hammer_upgrade::boot_graph;
|
|
let root = std::path::PathBuf::from(&root);
|
|
let state_root = std::path::PathBuf::from(&state_root);
|
|
match sub {
|
|
BootCmd::Graph { out, stdout } => {
|
|
if stdout {
|
|
let g = boot_graph::build(&state_root)?;
|
|
println!("{}", serde_json::to_string_pretty(&g)?);
|
|
} else {
|
|
boot_graph::emit(&state_root, &out)?;
|
|
let g = boot_graph::build(&state_root)?;
|
|
println!("✓ grafo de arranque escrito en {}", out.display());
|
|
println!(" {} nodo(s); vivo: {}", g.nodes.len(),
|
|
g.current.as_deref().unwrap_or("(ninguno)"));
|
|
}
|
|
}
|
|
BootCmd::Activate { id, from_select } => {
|
|
let id = match (id, from_select) {
|
|
(Some(id), _) => id,
|
|
(None, true) => std::fs::read_to_string(boot_graph::BOOT_SELECT_PATH)
|
|
.map_err(|e| anyhow::anyhow!(
|
|
"no pude leer {}: {e}", boot_graph::BOOT_SELECT_PATH))?
|
|
.trim().to_string(),
|
|
(None, false) => anyhow::bail!(
|
|
"pasá un <id> o usá --from-select para leerlo de {}",
|
|
boot_graph::BOOT_SELECT_PATH),
|
|
};
|
|
let j = hammer_journal::Journal::open(&journal)?;
|
|
let r = boot_graph::activate(&root, &state_root, Some(&j), &id)?;
|
|
if r.already_current {
|
|
println!("✓ nodo {} ya era el vivo — no-op", r.target);
|
|
} else if r.kind == boot_graph::NodeKind::Recovery {
|
|
println!("✓ recuperación: generación {:?} revertida", r.rolled_back);
|
|
} else {
|
|
println!("✓ nodo {} activado", r.target);
|
|
println!(" {} generación(es) revertida(s): {:?}",
|
|
r.rolled_back.len(), r.rolled_back);
|
|
}
|
|
// Republica el grafo: la vista de mirada debe reflejar el nuevo nodo vivo.
|
|
boot_graph::emit(&state_root, std::path::Path::new(boot_graph::BOOT_GRAPH_PATH)).ok();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Imprime el reporte de un `mirror push|pull`.
|
|
fn print_sync(verbo: &str, r: &hammer_mirror::SyncReport) {
|
|
for d in &r.copied {
|
|
println!(" ✓ {d}");
|
|
}
|
|
for (d, why) in &r.failed {
|
|
println!(" ✗ {d}: {why}");
|
|
}
|
|
for d in &r.conflicts {
|
|
println!(" ⚠ conflicto {d} (no sobrescrito)");
|
|
}
|
|
println!(
|
|
"mirror {verbo}: {} copiados, {} ya presentes, {} conflictos, {} fallos",
|
|
r.copied.len(),
|
|
r.skipped_present,
|
|
r.conflicts.len(),
|
|
r.failed.len()
|
|
);
|
|
}
|
|
|
|
/// Lee y deserializa un `.swm` desde disco; falla con mensaje útil si no parsea.
|
|
fn load_swm(file: &str) -> anyhow::Result<hammer_core::Swm> {
|
|
let yaml = std::fs::read_to_string(file)
|
|
.map_err(|e| anyhow::anyhow!("no pude leer {file}: {e}"))?;
|
|
hammer_core::Swm::from_yaml(&yaml)
|
|
.map_err(|e| anyhow::anyhow!("no pude parsear {file}: {e}"))
|
|
}
|
|
|
|
/// Lee una `BaseRef` local desde JSON; usada por `apply` y `swm verify` para verificar la
|
|
/// compatibilidad de pins. Si la ruta no existe, devuelve `Ok(None)` para que el caller
|
|
/// pueda decidir entre fallar o seguir con un warning.
|
|
fn load_local_base(path: Option<&std::path::Path>) -> anyhow::Result<Option<hammer_core::BaseRef>> {
|
|
let Some(p) = path else { return Ok(None) };
|
|
if !p.is_file() {
|
|
return Err(anyhow::anyhow!("base-ref {} no existe", p.display()));
|
|
}
|
|
let bytes = std::fs::read(p)?;
|
|
let base: hammer_core::BaseRef =
|
|
serde_json::from_slice(&bytes).map_err(|e| anyhow::anyhow!("base-ref inválido: {e}"))?;
|
|
Ok(Some(base))
|
|
}
|
|
|
|
fn print_base_compat(c: &hammer_core::BaseCompat) {
|
|
use hammer_core::BaseCompat::*;
|
|
match c {
|
|
Ok => eprintln!("verify_base: OK"),
|
|
DistroMismatch { local, swm } => eprintln!(
|
|
"verify_base: distro_version no coincide — local={local} swm={swm}"
|
|
),
|
|
PinMismatch(diffs) => {
|
|
eprintln!("verify_base: {} pin(s) divergente(s):", diffs.len());
|
|
for d in diffs {
|
|
let local = d.local.as_deref().unwrap_or("(ausente)");
|
|
eprintln!(" {}: local={local} swm={}", d.name, d.swm);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run_swm_verify(
|
|
file: &str,
|
|
base_ref: Option<&std::path::Path>,
|
|
trust: Option<&std::path::Path>,
|
|
evidence: bool,
|
|
store_path: &std::path::Path,
|
|
) -> anyhow::Result<()> {
|
|
let swm = load_swm(file)?;
|
|
swm.verify_schema()
|
|
.map_err(|e| anyhow::anyhow!("schema inválido: {e}"))?;
|
|
println!("schema: OK ({} mutación(es))", swm.mutations.len());
|
|
// Verificación de hash de los file_drop inline (no toca disco).
|
|
for (i, m) in swm.mutations.iter().enumerate() {
|
|
if let hammer_core::Mutation::FileDrop { content_b64: Some(b64), content_hash, path, .. } = m {
|
|
hammer_core::apply::verify_content_hash(b64, content_hash).map_err(|e| {
|
|
anyhow::anyhow!("file_drop #{} en {path}: {e}", i + 1)
|
|
})?;
|
|
}
|
|
}
|
|
println!("file_drop inline: OK");
|
|
if let Some(local) = load_local_base(base_ref)? {
|
|
print_base_compat(&swm.verify_base(&local));
|
|
} else {
|
|
eprintln!("verify_base: omitido (sin --base-ref)");
|
|
}
|
|
print_sig_status(&swm, trust)?;
|
|
if evidence {
|
|
run_swm_evidence(&swm, store_path)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// [H1b] Reproduce cada paquete `source_patch` del `.swm` y corre su bloque `evidence` en el sandbox
|
|
/// reproducible. Imprime el veredicto por check y ABORTA (exit != 0) si alguno falla — el gate de
|
|
/// "reproduce + estas N pruebas pasan" antes de que el humano proponga (SDD 15 §H1). Frontera:
|
|
/// paquetes con build-deps necesitan esas recetas resolubles en el catálogo del lab (como `install`);
|
|
/// para el caso self-contained (binario estático sin deps) alcanza con el `.swm`.
|
|
fn run_swm_evidence(swm: &hammer_core::Swm, store_path: &std::path::Path) -> anyhow::Result<()> {
|
|
// Corto-circuito ANTES de tocar el store/catálogo: si ninguna mutación trae evidencia, no hay
|
|
// nada que reproducir (y no forzamos abrir un store que quizá no exista/no sea escribible).
|
|
let has_evidence = swm.mutations.iter().any(|m| {
|
|
matches!(m, hammer_core::Mutation::SourcePatch { evidence, .. } if !evidence.is_empty())
|
|
});
|
|
if !has_evidence {
|
|
println!("evidence: ninguna mutación declara evidencia (nada que verificar)");
|
|
return Ok(());
|
|
}
|
|
|
|
let store = hammer_core::Store::open(store_path)?;
|
|
let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
|
|
let catalog_dir = hammer_build::swm_bridge::catalog_dir_for(&cfg, None);
|
|
std::fs::create_dir_all(&catalog_dir)?;
|
|
|
|
let mut ran = 0usize;
|
|
let mut failed = 0usize;
|
|
for m in &swm.mutations {
|
|
let hammer_core::Mutation::SourcePatch { evidence, target_bin, .. } = m else {
|
|
continue;
|
|
};
|
|
if evidence.is_empty() {
|
|
continue;
|
|
}
|
|
let recipe =
|
|
hammer_build::swm_bridge::recipe_from_source_patch(m, None, &catalog_dir)?;
|
|
println!(
|
|
"evidence: reproduciendo {target_bin} y corriendo {} check(s)…",
|
|
evidence.checks.len()
|
|
);
|
|
let report = hammer_build::run_evidence(&recipe, &cfg, &store)?;
|
|
for r in &report.results {
|
|
let mark = if r.outcome.passed { "✓" } else { "✗" };
|
|
println!(
|
|
" {mark} [{}] {} — {}",
|
|
r.kind.as_str(),
|
|
r.cmd,
|
|
r.outcome.detail
|
|
);
|
|
}
|
|
ran += report.results.len();
|
|
failed += report.results.iter().filter(|r| !r.outcome.passed).count();
|
|
if report.all_passed() {
|
|
if let Some(lvl) = report.max_level_passed() {
|
|
println!(" → verificado hasta el estrato '{}'", lvl.as_str());
|
|
}
|
|
}
|
|
}
|
|
|
|
if ran == 0 {
|
|
println!("evidence: ninguna mutación declara evidencia (nada que verificar)");
|
|
Ok(())
|
|
} else if failed > 0 {
|
|
anyhow::bail!("evidence: {failed}/{ran} check(s) fallaron ⇒ NO proponer");
|
|
} else {
|
|
println!("evidence: {ran} check(s), todos pasan ✓");
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
const DEFAULT_TRUST_DIR: &str = "/var/lib/hammer/trust";
|
|
|
|
/// Carga el TrustStore (de `--trust` o el default) y reporta el estado de la firma. La firma
|
|
/// informa autoría/integridad; NO autoriza promover (eso lo decide la reproducción + commit
|
|
/// humano). Ver `docs/09-trust-model.md`.
|
|
fn print_sig_status(
|
|
swm: &hammer_core::Swm,
|
|
trust: Option<&std::path::Path>,
|
|
) -> anyhow::Result<()> {
|
|
let trust_dir = trust.unwrap_or_else(|| std::path::Path::new(DEFAULT_TRUST_DIR));
|
|
let store = hammer_core::TrustStore::load(trust_dir)?;
|
|
let status = swm.verify_signature(&store);
|
|
match &status {
|
|
hammer_core::SigStatus::Trusted { by } => {
|
|
println!("firma: trusted (by {by})");
|
|
}
|
|
hammer_core::SigStatus::UnknownKey { by } => {
|
|
println!(
|
|
"firma: unknown-key (by {by}) — añade su clave a {} para confiar en la autoría",
|
|
trust_dir.display()
|
|
);
|
|
}
|
|
hammer_core::SigStatus::BadSig { by, reason } => {
|
|
eprintln!("firma: BAD-SIG (by {by}): {reason}");
|
|
anyhow::bail!("la firma del .swm no valida");
|
|
}
|
|
hammer_core::SigStatus::Unsigned => {
|
|
println!("firma: unsigned");
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn run_keygen(name: &str, out: Option<&std::path::Path>) -> anyhow::Result<()> {
|
|
let dir = out.unwrap_or_else(|| std::path::Path::new(DEFAULT_TRUST_DIR));
|
|
let kp = hammer_core::KeyPair::generate()?;
|
|
let pub_path = kp.write_to(dir, name)?;
|
|
println!("clave pública: {}", pub_path.display());
|
|
println!("clave privada: {} (modo 0600 — guárdala)", dir.join(format!("{name}.ed25519")).display());
|
|
println!("pub b64: {}", kp.public_b64());
|
|
Ok(())
|
|
}
|
|
|
|
/// Empaqueta una receta como un `.swm` de un único `source_patch` (Etapa F). Lee la receta,
|
|
/// materializa el texto de sus patches (resueltos contra `base_dir`, concatenados), opcionalmente
|
|
/// construye para sellar el `expected_hash` real, firma si se pide, y emite el manifiesto.
|
|
#[allow(clippy::too_many_arguments)]
|
|
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,
|
|
sign: Option<&std::path::Path>,
|
|
store_path: &str,
|
|
) -> anyhow::Result<()> {
|
|
let recipe = hammer_core::Recipe::load_from_path(recipe_path)
|
|
.map_err(|e| anyhow::anyhow!("no pude cargar la receta {}: {e}", recipe_path.display()))?;
|
|
|
|
// Las deps por NOMBRE viajan en el `source_patch` y en el índice; `install` resuelve el cierre
|
|
// desde el repo. Avisamos sólo para recordar que esas deps deben ESTAR publicadas en el mismo
|
|
// repo (si no, `install` fallará al resolver el catálogo).
|
|
if !recipe.deps.build.is_empty() {
|
|
eprintln!(
|
|
"nota: este paquete depende de {:?} — publicá esas deps en el mismo repo para que \
|
|
`install` pueda reproducirlo",
|
|
recipe.deps.build
|
|
);
|
|
}
|
|
|
|
// Texto de los patches: cada uno se resuelve relativo al directorio de la receta (igual que
|
|
// el lab) y se concatena. El `source_patch` del .swm modela un único `patch` inline; un diff
|
|
// unificado multi-fichero concatenado se aplica hunk-a-hunk igual (git apply / patch -p1).
|
|
let patch_text = if recipe.source.patches.is_empty() {
|
|
None
|
|
} else {
|
|
let mut buf = String::new();
|
|
for p in &recipe.source.patches {
|
|
let path = std::path::Path::new(p);
|
|
let abs = if path.is_absolute() {
|
|
path.to_path_buf()
|
|
} else {
|
|
recipe.base_dir.join(path)
|
|
};
|
|
let txt = std::fs::read_to_string(&abs)
|
|
.map_err(|e| anyhow::anyhow!("leyendo patch {}: {e}", abs.display()))?;
|
|
if !buf.is_empty() && !buf.ends_with('\n') {
|
|
buf.push('\n');
|
|
}
|
|
buf.push_str(&txt);
|
|
}
|
|
Some(buf)
|
|
};
|
|
|
|
// target_bin: `--target-bin` manda. Si no, y la receta Cargo declara `--bin <X>`, el binario
|
|
// instalado es X (no el nombre del paquete: ripgrep→rg, repgrep→rgr) ⇒ derivamos de ahí. Sólo
|
|
// si no hay `--bin`, caemos a `/usr/bin/{name}` (correcto para C y para name==bin).
|
|
let target_bin = target_bin.map(|s| s.to_string()).unwrap_or_else(|| {
|
|
let bin = recipe
|
|
.build
|
|
.flags
|
|
.windows(2)
|
|
.find(|w| w[0] == "--bin")
|
|
.map(|w| w[1].clone())
|
|
.unwrap_or_else(|| recipe.name.clone());
|
|
format!("/usr/bin/{bin}")
|
|
});
|
|
|
|
// expected_hash: `--expected` manda; si no, `--build` lo sella construyendo de verdad en el
|
|
// lab; si ninguno, el paquete sale sin ancla de verificación (válido, sólo más débil).
|
|
let expected_hash: Option<String> = if let Some(h) = expected {
|
|
Some(h.to_string())
|
|
} else if do_build {
|
|
let store = hammer_core::Store::open(store_path)?;
|
|
let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
|
|
let hash = hammer_build::build(&recipe, &cfg, &store)
|
|
.map_err(|e| anyhow::anyhow!("construyendo la receta para sellar expected_hash: {e}"))?;
|
|
eprintln!("construida: {hash}");
|
|
Some(hash.as_str().to_string())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let mut swm = hammer_core::Swm::from_recipe(
|
|
&recipe,
|
|
target_bin,
|
|
patch_text,
|
|
expected_hash.clone(),
|
|
distro_version,
|
|
)?;
|
|
|
|
if let Some(key) = sign {
|
|
let priv_b64 = std::fs::read_to_string(key)
|
|
.map_err(|e| anyhow::anyhow!("no pude leer la clave {}: {e}", key.display()))?;
|
|
let kp = hammer_core::KeyPair::from_private_b64(&priv_b64)?;
|
|
let by = key
|
|
.file_name()
|
|
.map(|f| f.to_string_lossy().trim_end_matches(".ed25519").to_string())
|
|
.unwrap_or_else(|| "anon".into());
|
|
swm.signature = Some(kp.sign(&swm, &by));
|
|
eprintln!("firmado por {by}");
|
|
}
|
|
|
|
let yaml = swm.to_yaml()?;
|
|
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()),
|
|
deps: recipe.deps.build.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));
|
|
}
|
|
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(())
|
|
}
|
|
|
|
/// De dónde viene un repo: un directorio local, o una URL HTTP(S) (el repo son ficheros
|
|
/// estáticos — `index.json` + los `.swm` —, así que cualquier servidor estático lo sirve).
|
|
enum RepoSource {
|
|
Local(PathBuf),
|
|
Http(String), // base sin barra final
|
|
}
|
|
|
|
impl RepoSource {
|
|
fn parse(s: &str) -> RepoSource {
|
|
if s.starts_with("http://") || s.starts_with("https://") {
|
|
RepoSource::Http(s.trim_end_matches('/').to_string())
|
|
} else {
|
|
RepoSource::Local(PathBuf::from(s))
|
|
}
|
|
}
|
|
|
|
fn label(&self) -> String {
|
|
match self {
|
|
RepoSource::Local(d) => d.display().to_string(),
|
|
RepoSource::Http(b) => b.clone(),
|
|
}
|
|
}
|
|
|
|
/// Lee el índice del repo (local: `index.json`; http: `GET <base>/index.json`).
|
|
fn read_index(&self) -> anyhow::Result<hammer_core::RepoIndex> {
|
|
match self {
|
|
RepoSource::Local(d) => Ok(hammer_core::RepoIndex::load(d)?),
|
|
RepoSource::Http(base) => {
|
|
let url = format!("{base}/index.json");
|
|
let bytes = hammer_build::download::fetch_url_bytes(&url)?;
|
|
serde_json::from_slice(&bytes)
|
|
.map_err(|e| anyhow::anyhow!("índice remoto {url}: {e}"))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Materializa un directorio de repo LOCAL del que el resto del flujo lee los `.swm`. Para
|
|
/// `Local` es el propio directorio; para `Http` baja `index.json` + los `.swm` de `files` a un
|
|
/// dir temporal (que el caller mantiene vivo) ⇒ el camino de instalación es idéntico.
|
|
fn materialize(
|
|
&self,
|
|
index: &hammer_core::RepoIndex,
|
|
files: &[&str],
|
|
tmp_holder: &mut Option<tempfile::TempDir>,
|
|
) -> anyhow::Result<PathBuf> {
|
|
match self {
|
|
RepoSource::Local(d) => Ok(d.clone()),
|
|
RepoSource::Http(base) => {
|
|
let tmp = tempfile::tempdir()?;
|
|
let dir = tmp.path().to_path_buf();
|
|
index.save(&dir)?; // index.json (con firma) re-cargable idéntico
|
|
for f in files {
|
|
let url = format!("{base}/{f}");
|
|
let bytes = hammer_build::download::fetch_url_bytes(&url)?;
|
|
std::fs::write(dir.join(f), bytes)?;
|
|
}
|
|
eprintln!("repo: bajados {} .swm de {base}", files.len());
|
|
*tmp_holder = Some(tmp); // mantener vivo hasta el final del install
|
|
Ok(dir)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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. `repo` puede ser un
|
|
/// directorio local o una URL HTTP(S).
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn run_install(
|
|
store_path: &str,
|
|
name: &str,
|
|
repo: &str,
|
|
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>,
|
|
db_path: &std::path::Path,
|
|
require_signed: bool,
|
|
force_slots: bool,
|
|
) -> anyhow::Result<()> {
|
|
let source = RepoSource::parse(repo);
|
|
let index = source.read_index()?;
|
|
|
|
// Verificación del RELEASE (firma del catálogo entero): si el índice está firmado, una firma
|
|
// mala = índice manipulado ⇒ abortamos antes de tocar nada. Sin firma se informa; con --trust
|
|
// se valida la autoría. Esto es ortogonal a la firma de cada `.swm`.
|
|
{
|
|
let trust_dir = trust.unwrap_or_else(|| std::path::Path::new(DEFAULT_TRUST_DIR));
|
|
let store = hammer_core::TrustStore::load(trust_dir)?;
|
|
match index.verify_signature(&store) {
|
|
hammer_core::SigStatus::Trusted { by } => eprintln!("release: trusted (by {by})"),
|
|
hammer_core::SigStatus::UnknownKey { by } => {
|
|
eprintln!("release: unknown-key (by {by}) — autoría del catálogo no confiada");
|
|
if require_signed {
|
|
anyhow::bail!("--require-signed: el release está firmado por '{by}', clave no confiada en {}", trust_dir.display());
|
|
}
|
|
}
|
|
hammer_core::SigStatus::BadSig { by, reason } => {
|
|
anyhow::bail!("release: BAD-SIG (by {by}): {reason} — el índice fue manipulado")
|
|
}
|
|
hammer_core::SigStatus::Unsigned => {
|
|
eprintln!("release: sin firmar");
|
|
if require_signed {
|
|
anyhow::bail!("--require-signed: el repo no tiene firma de release (firmalo con `hammer repo sign`)");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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: {}",
|
|
source.label(),
|
|
if avail.is_empty() { "(ninguno)".into() } else { avail.join(", ") }
|
|
)
|
|
})?;
|
|
|
|
// Materializamos un repo LOCAL con los `.swm` que vamos a tocar (el target + su cierre de
|
|
// deps). Para un repo local es el propio directorio; para HTTP, baja esos `.swm` a un temporal.
|
|
// De ahí en más el flujo es idéntico (lee ficheros de `repo_dir`).
|
|
let closure = index.resolve_closure(name)?;
|
|
let needed: Vec<&str> = closure.iter().map(|p| p.file.as_str()).collect();
|
|
let mut _tmp_repo: Option<tempfile::TempDir> = None;
|
|
let repo_dir = source.materialize(&index, &needed, &mut _tmp_repo)?;
|
|
let repo_dir = repo_dir.as_path();
|
|
|
|
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)");
|
|
}
|
|
|
|
// Gate de compatibilidad (H4b/H4c — configs compatibles, SDD 15 §H4). Antes de reconstruir o
|
|
// tocar nada, evaluamos las superficies que el paquete tocará contra el estado INSTALADO. Es el
|
|
// chequeo local y reproducible de "cuando instalo, ¿es compatible con lo que ya tengo?".
|
|
let entrant_swm = load_swm(swm_path_str)?;
|
|
|
|
// H4c — vía OBSERVADA: los paths que el paquete escribirá (declarados en el .swm, sin que el
|
|
// autor prometa `slots`). Si alguno lo posee OTRO paquete instalado, es una colisión de fichero
|
|
// (elección, no error) — salvo --force-slots. Se conoce antes de hidratar ⇒ aborta sin tocar nada.
|
|
{
|
|
let idb = hammer_core::InstalledDb::load(db_path)?;
|
|
let paths = hammer_core::compat::output_paths(&entrant_swm);
|
|
let cols = hammer_core::compat::path_collisions(&idb, name, &paths);
|
|
if !cols.is_empty() {
|
|
let detalle = cols
|
|
.iter()
|
|
.map(|c| format!("{} (de '{}')", c.path, c.propietario))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
if force_slots {
|
|
eprintln!("compat: {} colisión(es) de fichero: {detalle}; --force-slots ⇒ sobreescribo", cols.len());
|
|
} else {
|
|
anyhow::bail!(
|
|
"compat: COLISIÓN de fichero — '{name}' pisaría {detalle}. Es una elección: \
|
|
pasá --force-slots para sobreescribir el/los fichero(s) del otro paquete."
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// H4e — vía OBSERVADA de los requisitos: por cada dep de runtime, la versión (expected_hash)
|
|
// que hay en el repo (contra la que el paquete se construyó). Si la tengo instalada a OTRO
|
|
// hash, la diverguí ⇒ rechazo duro (el caso wayland, derivado — no lo salva --force-slots).
|
|
{
|
|
let idb = hammer_core::InstalledDb::load(db_path)?;
|
|
let req = hammer_core::compat::observed_requires(&entrant_swm, &index);
|
|
let conflicts = hammer_core::compat::version_conflicts(&idb, &req);
|
|
if !conflicts.is_empty() {
|
|
let d = conflicts
|
|
.iter()
|
|
.map(|c| format!("{} necesita {} pero tenés {}", c.dep, c.requerido, c.instalado))
|
|
.collect::<Vec<_>>()
|
|
.join("; ");
|
|
anyhow::bail!(
|
|
"compat: INCOMPATIBLE — '{name}' se construyó contra deps que divergiste: {d}. \
|
|
No encaja con tu sistema (rechazo duro, no lo fuerza --force-slots)."
|
|
);
|
|
}
|
|
}
|
|
|
|
// H4b — vía DECLARADA: las superficies (`slots`) que el autor declara reclamar/requerir.
|
|
let entrant_slots = source_patch_slots(&entrant_swm);
|
|
if !entrant_slots.is_empty() {
|
|
entrant_slots
|
|
.validate()
|
|
.map_err(|e| anyhow::anyhow!("slots del paquete inválidos: {e}"))?;
|
|
let idb = hammer_core::InstalledDb::load(db_path)?;
|
|
let estado = idb.system_state();
|
|
match hammer_core::compat::evaluar(&estado, &entrant_slots) {
|
|
hammer_core::CompatVeredicto::Compatible => {
|
|
eprintln!(
|
|
"compat: compatible ({} reclamo(s), {} requisito(s) resuelto(s))",
|
|
entrant_slots.claims.len(),
|
|
entrant_slots.requires.len()
|
|
);
|
|
}
|
|
// Caso wayland: una dependencia sobre una superficie que el usuario divergió. Rechazo duro.
|
|
hammer_core::CompatVeredicto::Incompatible { slot, requerido, presente } => {
|
|
anyhow::bail!(
|
|
"compat: INCOMPATIBLE — '{name}' requiere la superficie '{slot}' @ {requerido}, \
|
|
pero el sistema tiene {}. Divergiste esa superficie: no se instala sobre ella.",
|
|
presente.as_deref().unwrap_or("(ausente)")
|
|
);
|
|
}
|
|
// Caso logo: dos configs escriben la misma superficie. Es una ELECCIÓN, no un error.
|
|
hammer_core::CompatVeredicto::Colision { slot, instalado, entrante } => {
|
|
if force_slots {
|
|
eprintln!(
|
|
"compat: colisión en '{slot}' ({instalado} → {entrante}); --force-slots ⇒ me quedo con la nueva"
|
|
);
|
|
} else {
|
|
anyhow::bail!(
|
|
"compat: COLISIÓN en la superficie '{slot}' — ya la ocupa {instalado} y '{name}' \
|
|
quiere {entrante}. Es una elección: pasá --force-slots para adoptar la nueva."
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Dependencias: resolvemos el cierre transitivo de build-deps desde el índice (deps antes
|
|
// que el dependiente) y poblamos el catálogo de recetas que el lab consultará al reproducir
|
|
// el `source_patch`. Sin esto, un paquete con build-deps (openssh→zlib,openssl) no podría
|
|
// reconstruirse: el sandbox no tendría headers/libs de las deps.
|
|
if !entry.deps.is_empty() && !skip_source_patch {
|
|
let store = hammer_core::Store::open(store_path)?;
|
|
let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
|
|
let catalog_dir = hammer_build::swm_bridge::catalog_dir_for(&cfg, None);
|
|
std::fs::create_dir_all(&catalog_dir)?;
|
|
|
|
// El cierre incluye al propio target al final; el catálogo sólo necesita las DEPS (las
|
|
// que el target y sus deps resolverán por nombre). Escribimos un `{dep}.toml` por cada una.
|
|
let closure = index.resolve_closure(name)?;
|
|
let dep_pkgs: Vec<&hammer_core::PackageEntry> =
|
|
closure.into_iter().filter(|p| p.name != name).collect();
|
|
if !dep_pkgs.is_empty() {
|
|
eprintln!(
|
|
"deps: resolviendo catálogo para {} dep(s): {}",
|
|
dep_pkgs.len(),
|
|
dep_pkgs.iter().map(|p| p.name.as_str()).collect::<Vec<_>>().join(", ")
|
|
);
|
|
}
|
|
for dep in dep_pkgs {
|
|
let dep_swm_path = hammer_core::RepoIndex::swm_path(repo_dir, dep);
|
|
let dep_swm = load_swm(
|
|
dep_swm_path
|
|
.to_str()
|
|
.ok_or_else(|| anyhow::anyhow!("ruta no UTF-8: {}", dep_swm_path.display()))?,
|
|
)?;
|
|
let mutation = source_patch_of(&dep_swm).ok_or_else(|| {
|
|
anyhow::anyhow!("la dep '{}' no es un paquete source_patch", dep.name)
|
|
})?;
|
|
// El nombre de la receta DEBE ser el nombre del paquete: así el dependiente la
|
|
// resuelve como `{dep}.toml`.
|
|
let dep_recipe = hammer_build::swm_bridge::recipe_from_source_patch(
|
|
mutation,
|
|
Some(&dep.name),
|
|
&catalog_dir,
|
|
)?;
|
|
let toml = dep_recipe.to_toml()?;
|
|
std::fs::write(catalog_dir.join(format!("{}.toml", dep.name)), toml)?;
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
let created = run_apply(
|
|
store_path,
|
|
swm_path_str,
|
|
prefix,
|
|
skip_source_patch,
|
|
base_ref,
|
|
state_root,
|
|
Some(&pkg_name), // reproduce con el nombre del paquete ⇒ cache-hit del corpus, sin duplicar
|
|
)?;
|
|
|
|
// 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,
|
|
// Las superficies reclamadas quedan en la DB ⇒ pasan a formar el `system_state` que el
|
|
// gate de la PRÓXIMA instalación consultará (H4b). Así una colisión se detecta después.
|
|
claims: entrant_slots.claims.clone(),
|
|
});
|
|
idb.save(db_path)?;
|
|
eprintln!("registrado {pkg_name} ({nfiles} fichero(s)) en {}", db_path.display());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Importa una receta desde el JSON normalizado de nixpkgs (Etapa G).
|
|
fn run_import_nix(file: &str, out: Option<&std::path::Path>) -> anyhow::Result<()> {
|
|
let json = if file == "-" {
|
|
use std::io::Read;
|
|
let mut s = String::new();
|
|
std::io::stdin().read_to_string(&mut s)?;
|
|
s
|
|
} else {
|
|
std::fs::read_to_string(file)
|
|
.map_err(|e| anyhow::anyhow!("leyendo {file}: {e}"))?
|
|
};
|
|
let pkg: nix_import::NixPkg = serde_json::from_str(&json)
|
|
.map_err(|e| anyhow::anyhow!("JSON normalizado de nix inválido: {e}"))?;
|
|
let toml = nix_import::to_recipe_toml(&pkg).map_err(|e| anyhow::anyhow!(e))?;
|
|
// Sanity: la receta emitida debe parsear como Recipe de hammer.
|
|
hammer_core::Recipe::from_toml(&toml)
|
|
.map_err(|e| anyhow::anyhow!("la receta importada no parsea (bug del importador): {e}"))?;
|
|
match out {
|
|
Some(dest) => {
|
|
std::fs::write(dest, &toml)?;
|
|
eprintln!("importada {} → {}", pkg.pname, dest.display());
|
|
}
|
|
None => print!("{toml}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// `true` si `s` ya es un SHA de git (40 hex sha1 o 64 hex sha256) ⇒ ya está anclado.
|
|
fn is_git_sha(s: &str) -> bool {
|
|
(s.len() == 40 || s.len() == 64) && s.bytes().all(|b| b.is_ascii_hexdigit())
|
|
}
|
|
|
|
/// Resuelve un tag/ref a su SHA de commit inmutable vía `git ls-remote` (cualquier host git, sin
|
|
/// API ni tokens). Para tags ANOTADOS prefiere el commit dereferenciado (`^{}`); para ligeros, el
|
|
/// ref directo. Devuelve error si el tag no existe en el remoto.
|
|
fn resolve_git_sha(repo: &str, tag: &str) -> anyhow::Result<String> {
|
|
let out = std::process::Command::new("git")
|
|
.args([
|
|
"ls-remote",
|
|
repo,
|
|
&format!("refs/tags/{tag}"),
|
|
&format!("refs/tags/{tag}^{{}}"),
|
|
tag,
|
|
])
|
|
.output()
|
|
.map_err(|e| anyhow::anyhow!("git ls-remote: {e}"))?;
|
|
if !out.status.success() {
|
|
anyhow::bail!("git ls-remote {repo} {tag}: {}", String::from_utf8_lossy(&out.stderr).trim());
|
|
}
|
|
let text = String::from_utf8_lossy(&out.stdout);
|
|
// Líneas `<sha>\t<ref>`. Preferimos la dereferenciada `^{}` (commit real del tag anotado).
|
|
let mut plain: Option<String> = None;
|
|
for line in text.lines() {
|
|
let mut it = line.split('\t');
|
|
let (Some(sha), Some(rf)) = (it.next(), it.next()) else { continue };
|
|
if rf.ends_with("^{}") {
|
|
return Ok(sha.to_string());
|
|
}
|
|
plain.get_or_insert_with(|| sha.to_string());
|
|
}
|
|
plain.ok_or_else(|| anyhow::anyhow!("'{tag}' no existe como tag/ref en {repo}"))
|
|
}
|
|
|
|
/// Ancla el `commit` de una receta git: tag flotante → SHA inmutable (Etapa G Fase 2).
|
|
fn run_pin(recipe_path: &std::path::Path, out: Option<&std::path::Path>) -> anyhow::Result<()> {
|
|
let text = std::fs::read_to_string(recipe_path)
|
|
.map_err(|e| anyhow::anyhow!("leyendo {}: {e}", recipe_path.display()))?;
|
|
let recipe = hammer_core::Recipe::from_toml(&text)
|
|
.map_err(|e| anyhow::anyhow!("{}: {e}", recipe_path.display()))?;
|
|
|
|
let (repo, commit) = match recipe.source.kind() {
|
|
Ok(hammer_core::SourceKind::Git { repo, commit }) => (repo.to_string(), commit.to_string()),
|
|
Ok(hammer_core::SourceKind::Tarball { .. }) => {
|
|
eprintln!("{}: tarball (ya anclado por sha256) — nada que pinear", recipe.name);
|
|
return Ok(());
|
|
}
|
|
Err(e) => anyhow::bail!("{}: {e}", recipe.name),
|
|
};
|
|
if is_git_sha(&commit) {
|
|
eprintln!("{}: commit ya es SHA ({}) — ya anclado", recipe.name, &commit[..commit.len().min(12)]);
|
|
return Ok(());
|
|
}
|
|
|
|
let sha = resolve_git_sha(&repo, &commit)?;
|
|
// Reescritura dirigida: la línea `commit = "<tag>"` (no tocamos version u otras que casen).
|
|
let mut replaced = false;
|
|
let new_text: String = text
|
|
.lines()
|
|
.map(|l| {
|
|
if !replaced && l.trim_start().starts_with("commit") && l.contains(&format!("\"{commit}\"")) {
|
|
replaced = true;
|
|
l.replace(&format!("\"{commit}\""), &format!("\"{sha}\""))
|
|
} else {
|
|
l.to_string()
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
let new_text = if text.ends_with('\n') { format!("{new_text}\n") } else { new_text };
|
|
if !replaced {
|
|
anyhow::bail!("no encontré la línea `commit = \"{commit}\"` para reescribir");
|
|
}
|
|
let dest = out.unwrap_or(recipe_path);
|
|
std::fs::write(dest, new_text)?;
|
|
eprintln!("{}: {commit} → {sha} (anclado en {})", recipe.name, dest.display());
|
|
Ok(())
|
|
}
|
|
|
|
/// Importa una receta desde un APKBUILD de Alpine (Etapa G).
|
|
fn run_import_alpine(file: &str, out: Option<&std::path::Path>) -> anyhow::Result<()> {
|
|
let text = if file == "-" {
|
|
use std::io::Read;
|
|
let mut s = String::new();
|
|
std::io::stdin().read_to_string(&mut s)?;
|
|
s
|
|
} else {
|
|
std::fs::read_to_string(file).map_err(|e| anyhow::anyhow!("leyendo {file}: {e}"))?
|
|
};
|
|
let toml = alpine_import::recipe_from_apkbuild(&text).map_err(|e| anyhow::anyhow!(e))?;
|
|
hammer_core::Recipe::from_toml(&toml)
|
|
.map_err(|e| anyhow::anyhow!("la receta importada no parsea (bug del importador): {e}"))?;
|
|
match out {
|
|
Some(dest) => {
|
|
std::fs::write(dest, &toml)?;
|
|
eprintln!("importada de Alpine → {}", dest.display());
|
|
}
|
|
None => print!("{toml}"),
|
|
}
|
|
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(())
|
|
}
|
|
|
|
/// [H4d] La BÚSQUEDA de compatibilidad: evalúa cada paquete del repo contra el estado instalado
|
|
/// y lo clasifica. Es `compat::filtrar` del prototipo `wawa-memo`, ahora sobre el repo real —
|
|
/// read-only, no construye ni toca nada. Combina la vía DECLARADA (slots, H4b) con la OBSERVADA
|
|
/// (paths que el paquete escribe, H4c): incompatible (requisito sin resolver) domina; si no, una
|
|
/// colisión (de slot o de fichero) ⇒ requiere elección; si no, compatible.
|
|
fn run_compat(repo: &str, db_path: &std::path::Path) -> anyhow::Result<()> {
|
|
let source = RepoSource::parse(repo);
|
|
let index = source.read_index()?;
|
|
let idb = hammer_core::InstalledDb::load(db_path)?;
|
|
let estado = idb.system_state();
|
|
|
|
// Materializamos TODOS los `.swm` (para HTTP los baja; para local es el propio dir).
|
|
let all_files: Vec<&str> = index.packages.iter().map(|p| p.file.as_str()).collect();
|
|
let mut _tmp: Option<tempfile::TempDir> = None;
|
|
let repo_dir = source.materialize(&index, &all_files, &mut _tmp)?;
|
|
|
|
let (mut compatibles, mut elegibles, mut incompatibles) = (Vec::new(), Vec::new(), Vec::new());
|
|
for entry in &index.packages {
|
|
let swm_path = hammer_core::RepoIndex::swm_path(&repo_dir, entry);
|
|
let swm = match swm_path.to_str().map(load_swm) {
|
|
Some(Ok(s)) => s,
|
|
_ => {
|
|
eprintln!("aviso: no pude leer {} — omito", entry.name);
|
|
continue;
|
|
}
|
|
};
|
|
// 1a) Vía OBSERVADA de requisitos (H4e): una dep de runtime divergida ⇒ incompatible.
|
|
let req = hammer_core::compat::observed_requires(&swm, &index);
|
|
let vconf = hammer_core::compat::version_conflicts(&idb, &req);
|
|
if !vconf.is_empty() {
|
|
let d = vconf.iter().map(|c| format!("{}@{} (tenés {})", c.dep, c.requerido, c.instalado)).collect::<Vec<_>>().join(", ");
|
|
incompatibles.push(format!("{:<16} deps divergidas: {d}", entry.name));
|
|
continue;
|
|
}
|
|
// 1b) Vía DECLARADA (slots): una incompatibilidad domina todo.
|
|
let slots = source_patch_slots(&swm);
|
|
let veredicto = hammer_core::compat::evaluar(&estado, &slots);
|
|
if let hammer_core::CompatVeredicto::Incompatible { slot, requerido, presente } = &veredicto {
|
|
incompatibles.push(format!(
|
|
"{:<16} requiere '{slot}' @ {requerido}, hay {}",
|
|
entry.name,
|
|
presente.as_deref().unwrap_or("(ausente)")
|
|
));
|
|
continue;
|
|
}
|
|
// 2) Colisión: de slot declarado (H4b) o de fichero observado (H4c) ⇒ requiere elección.
|
|
let paths = hammer_core::compat::output_paths(&swm);
|
|
let pcols = hammer_core::compat::path_collisions(&idb, &entry.name, &paths);
|
|
if let hammer_core::CompatVeredicto::Colision { slot, entrante, .. } = &veredicto {
|
|
elegibles.push(format!("{:<16} colisión de slot '{slot}' (quiere {entrante})", entry.name));
|
|
} else if !pcols.is_empty() {
|
|
let d = pcols.iter().map(|c| format!("{} (de '{}')", c.path, c.propietario)).collect::<Vec<_>>().join(", ");
|
|
elegibles.push(format!("{:<16} colisión de fichero: {d}", entry.name));
|
|
} else {
|
|
compatibles.push(entry.name.clone());
|
|
}
|
|
}
|
|
|
|
println!(
|
|
"compat: {} paquete(s) en {} contra {} superficie(s) instalada(s)",
|
|
index.packages.len(),
|
|
source.label(),
|
|
estado.len()
|
|
);
|
|
println!("\ncompatibles ({}):", compatibles.len());
|
|
for n in &compatibles {
|
|
println!(" ✓ {n}");
|
|
}
|
|
println!("\nrequieren elección ({}) — pasá `install --force-slots`:", elegibles.len());
|
|
for n in &elegibles {
|
|
println!(" ~ {n}");
|
|
}
|
|
println!("\nincompatibles ({}) — descartar:", incompatibles.len());
|
|
for n in &incompatibles {
|
|
println!(" ✗ {n}");
|
|
}
|
|
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);
|
|
let mut touched_dirs: Vec<PathBuf> = Vec::new();
|
|
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;
|
|
if let Some(parent) = std::path::Path::new(f).parent() {
|
|
touched_dirs.push(parent.to_path_buf());
|
|
}
|
|
}
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} // ya no estaba
|
|
Err(e) => eprintln!(" aviso: no pude borrar {f}: {e}"),
|
|
}
|
|
}
|
|
// Recoge los directorios que quedaron vacíos por el borrado (y sus padres, hacia arriba):
|
|
// `remove_dir` sólo borra un dir VACÍO ⇒ se detiene solo al toparse con uno que aún tiene
|
|
// contenido de otros paquetes. Procesamos de más profundo a menos para vaciar de abajo arriba.
|
|
let mut pruned = 0usize;
|
|
touched_dirs.sort_by_key(|d| std::cmp::Reverse(d.components().count()));
|
|
touched_dirs.dedup();
|
|
for dir in touched_dirs {
|
|
let mut cur = dir;
|
|
while std::fs::remove_dir(&cur).is_ok() {
|
|
pruned += 1;
|
|
match cur.parent() {
|
|
Some(p) if !p.as_os_str().is_empty() => cur = p.to_path_buf(),
|
|
_ => break,
|
|
}
|
|
}
|
|
}
|
|
|
|
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() },
|
|
if pruned > 0 { format!(", {pruned} dir(s) vacío(s) retirado(s)") } else { String::new() },
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Devuelve la primera mutación `source_patch` de un `.swm` empaquetado (el caso de `pack`).
|
|
fn source_patch_of(swm: &hammer_core::Swm) -> Option<&hammer_core::Mutation> {
|
|
swm.mutations
|
|
.iter()
|
|
.find(|m| matches!(m, hammer_core::Mutation::SourcePatch { .. }))
|
|
}
|
|
|
|
/// Las superficies del sistema (H4b) que declara el `source_patch` de un `.swm`, o `Slots` vacío
|
|
/// si el paquete no declara topología. Lo consume el gate de compatibilidad de `install`.
|
|
fn source_patch_slots(swm: &hammer_core::Swm) -> hammer_core::Slots {
|
|
match source_patch_of(swm) {
|
|
Some(hammer_core::Mutation::SourcePatch { slots, .. }) => slots.clone(),
|
|
_ => hammer_core::Slots::default(),
|
|
}
|
|
}
|
|
|
|
/// Firma el índice de un repo (release). Re-firmá tras publicar.
|
|
fn run_repo_sign(
|
|
repo_dir: &std::path::Path,
|
|
key: &std::path::Path,
|
|
by: Option<&str>,
|
|
) -> anyhow::Result<()> {
|
|
let mut index = hammer_core::RepoIndex::load(repo_dir)?;
|
|
if index.packages.is_empty() {
|
|
anyhow::bail!("repo {} vacío: nada que firmar", repo_dir.display());
|
|
}
|
|
let priv_b64 = std::fs::read_to_string(key)
|
|
.map_err(|e| anyhow::anyhow!("no pude leer la clave {}: {e}", key.display()))?;
|
|
let kp = hammer_core::KeyPair::from_private_b64(&priv_b64)?;
|
|
let by = by.map(|s| s.to_string()).unwrap_or_else(|| {
|
|
key.file_name()
|
|
.map(|f| f.to_string_lossy().trim_end_matches(".ed25519").to_string())
|
|
.unwrap_or_else(|| "anon".into())
|
|
});
|
|
index.sign(&kp, &by);
|
|
index.save(repo_dir)?;
|
|
println!(
|
|
"release firmado por {by} — {} paquete(s) en {}",
|
|
index.packages.len(),
|
|
repo_dir.display()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Verifica la firma del release de un repo contra el TrustStore.
|
|
fn run_repo_verify(
|
|
repo_dir: &std::path::Path,
|
|
trust: Option<&std::path::Path>,
|
|
) -> anyhow::Result<()> {
|
|
let index = hammer_core::RepoIndex::load(repo_dir)?;
|
|
let trust_dir = trust.unwrap_or_else(|| std::path::Path::new(DEFAULT_TRUST_DIR));
|
|
let store = hammer_core::TrustStore::load(trust_dir)?;
|
|
match index.verify_signature(&store) {
|
|
hammer_core::SigStatus::Trusted { by } => {
|
|
println!("release: trusted (by {by}) — {} paquete(s)", index.packages.len());
|
|
Ok(())
|
|
}
|
|
hammer_core::SigStatus::UnknownKey { by } => {
|
|
println!(
|
|
"release: unknown-key (by {by}) — añadí su clave a {} para confiar",
|
|
trust_dir.display()
|
|
);
|
|
Ok(())
|
|
}
|
|
hammer_core::SigStatus::BadSig { by, reason } => {
|
|
anyhow::bail!("release: BAD-SIG (by {by}): {reason} — el índice fue manipulado");
|
|
}
|
|
hammer_core::SigStatus::Unsigned => {
|
|
println!("release: sin firmar (firmá con `hammer repo sign`)");
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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(());
|
|
}
|
|
let rel = match &index.signature {
|
|
Some(s) => format!(" [release firmado por {}]", s.by),
|
|
None => String::new(),
|
|
};
|
|
println!("repo {} — {} paquete(s){}:", repo_dir.display(), index.packages.len(), rel);
|
|
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(())
|
|
}
|
|
|
|
fn run_swm_sign(
|
|
file: &str,
|
|
key: &std::path::Path,
|
|
by: Option<&str>,
|
|
out: Option<&std::path::Path>,
|
|
) -> anyhow::Result<()> {
|
|
let mut swm = load_swm(file)?;
|
|
swm.verify_schema()
|
|
.map_err(|e| anyhow::anyhow!("no firmo un .swm con schema inválido: {e}"))?;
|
|
let priv_b64 = std::fs::read_to_string(key)
|
|
.map_err(|e| anyhow::anyhow!("no pude leer la clave {}: {e}", key.display()))?;
|
|
let kp = hammer_core::KeyPair::from_private_b64(&priv_b64)?;
|
|
|
|
// `by` por defecto: basename de la clave sin el sufijo `.ed25519`.
|
|
let by = by.map(|s| s.to_string()).unwrap_or_else(|| {
|
|
key.file_name()
|
|
.map(|f| f.to_string_lossy().trim_end_matches(".ed25519").to_string())
|
|
.unwrap_or_else(|| "anon".into())
|
|
});
|
|
|
|
swm.signature = Some(kp.sign(&swm, &by));
|
|
let yaml = swm.to_yaml()?;
|
|
let dest = out.unwrap_or_else(|| std::path::Path::new(file));
|
|
std::fs::write(dest, yaml)?;
|
|
println!("firmado por {by} → {}", dest.display());
|
|
Ok(())
|
|
}
|
|
|
|
/// Aplica un `.swm`. Dos modos:
|
|
/// - **prefix** (`--prefix DIR`): re-rootea cada path al DIR; no abre overlay. Para tests y
|
|
/// staging.
|
|
/// - **overlay** (default): abre un overlay sobre los targets por defecto y aplica las
|
|
/// mutaciones sobre las rutas reales (que el kernel redirige al upper del overlay).
|
|
fn run_apply(
|
|
store_path: &str,
|
|
file: &str,
|
|
prefix: Option<&std::path::Path>,
|
|
skip_source_patch: bool,
|
|
base_ref: Option<&std::path::Path>,
|
|
state_root: Option<&std::path::Path>,
|
|
// Nombre del paquete (sólo lo sabe `install`): si está, la reproducción del source_patch usa
|
|
// ESE nombre para la receta sintetizada ⇒ cache-hitea el artefacto del corpus (`<hash>-name`)
|
|
// en vez de derivar el nombre del target_bin y sellar un duplicado (ripgrep→rg). `apply` pasa None.
|
|
pkg_name: Option<&str>,
|
|
) -> 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) {
|
|
hammer_core::BaseCompat::Ok => {}
|
|
other => {
|
|
print_base_compat(&other);
|
|
anyhow::bail!("base local no compatible con la base del .swm");
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut overlay_id: Option<hammer_overlay::OverlayId> = None;
|
|
if prefix.is_none() {
|
|
let root = state_root
|
|
.map(|p| p.to_path_buf())
|
|
.unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
|
|
std::fs::create_dir_all(&root)?;
|
|
let id = hammer_overlay::try_overlay(&[], &root)?;
|
|
eprintln!("overlay {id} abierto");
|
|
overlay_id = Some(id);
|
|
}
|
|
|
|
let mut counts = (0usize, 0usize, 0usize, 0usize); // src_patch, cfg, init, drop
|
|
for (i, m) in swm.mutations.iter().enumerate() {
|
|
match m {
|
|
hammer_core::Mutation::SourcePatch { target_bin, .. } => {
|
|
counts.0 += 1;
|
|
if skip_source_patch {
|
|
eprintln!(" [skip] source_patch #{} → {target_bin}", i + 1);
|
|
continue;
|
|
}
|
|
let store = hammer_core::Store::open(store_path)?;
|
|
let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
|
|
let hash = hammer_build::build_source_patch(m, &cfg, &store, None, pkg_name)?;
|
|
let artifact = store.find_by_hash(hash.as_str())?;
|
|
let into = hammer_core::apply::rebase_path("/", prefix);
|
|
let report = hammer_build::run_hydrate(
|
|
&artifact,
|
|
&into,
|
|
hammer_core::LinkMode::Static,
|
|
None,
|
|
)?;
|
|
eprintln!(
|
|
" source_patch #{}: {} → hidratados {} archivo(s) en {}",
|
|
i + 1,
|
|
hash,
|
|
report.files.len(),
|
|
into.display()
|
|
);
|
|
// Sanity: el target_bin declarado debe existir tras la hidratación.
|
|
let abs = hammer_core::apply::rebase_path(target_bin, prefix);
|
|
if !abs.exists() {
|
|
anyhow::bail!(
|
|
"source_patch declara target_bin={target_bin} pero no quedó en {}",
|
|
abs.display()
|
|
);
|
|
}
|
|
created.extend(report.files.iter().map(|f| f.dst.clone()));
|
|
}
|
|
hammer_core::Mutation::ConfigEdit { file: f, inline_diff } => {
|
|
counts.1 += 1;
|
|
let target = hammer_core::apply::rebase_path(f, prefix);
|
|
hammer_core::apply::apply_config_edit(&target, inline_diff)?;
|
|
eprintln!(" config_edit #{} → {}", i + 1, target.display());
|
|
}
|
|
hammer_core::Mutation::InitRule { action, service, command } => {
|
|
counts.2 += 1;
|
|
let rules_dir =
|
|
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;
|
|
let target = hammer_core::apply::rebase_path(path, prefix);
|
|
match (content_b64, content_url) {
|
|
(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
|
|
// ANTES de escribir nada: nunca tocamos disco con bytes sin verificar.
|
|
let bytes = hammer_build::download::fetch_url_bytes(url)?;
|
|
hammer_core::apply::write_filedrop_bytes(&target, &bytes, content_hash)?;
|
|
eprintln!(
|
|
" file_drop #{} ← {url} ({} bytes) → {}",
|
|
i + 1,
|
|
bytes.len(),
|
|
target.display()
|
|
);
|
|
created.push(target.clone());
|
|
}
|
|
_ => unreachable!("verify_schema lo descarta"),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
eprintln!(
|
|
"apply OK — source_patch={} config_edit={} init_rule={} file_drop={}",
|
|
counts.0, counts.1, counts.2, counts.3
|
|
);
|
|
if let Some(id) = &overlay_id {
|
|
println!("{id}");
|
|
eprintln!("overlay listo: usa `hammer commit {id}` para promocionar al FHS real.");
|
|
}
|
|
Ok(created)
|
|
}
|
|
|
|
/// Exporta el diario actual como `.swm`. Provenance-aware:
|
|
///
|
|
/// - Eventos cuyo `Source` apunta a un `artifact` con receta sidecar en el store
|
|
/// (`.hammer/recipe.toml`) se agrupan por `artifact_hash` y emiten **un único**
|
|
/// `source_patch` por grupo, con repo/commit/build de la receta original. El receptor
|
|
/// reconstruye el binario desde fuente — provenance real, no un binario opaco.
|
|
/// - Eventos sin provenance trazable (`Source::External`, builds viejos sin sidecar,
|
|
/// `HammerCommit` con `artifact: None`) caen al fallback `file_drop` con `content_b64`
|
|
/// + hash, reproduciendo byte-a-byte pero perdiendo la receta. Se cuenta como warning.
|
|
/// - Eventos `Delete` invalidan estados previos del mismo path y luego se ignoran (un
|
|
/// `.swm` describe el estado final por adición/edición).
|
|
fn run_export(
|
|
base_ref_path: Option<&std::path::Path>,
|
|
journal_dir: &std::path::Path,
|
|
since: Option<&str>,
|
|
store_root: &str,
|
|
) -> anyhow::Result<()> {
|
|
use std::collections::BTreeMap;
|
|
|
|
let j = hammer_journal::Journal::open(journal_dir)?;
|
|
let events = j.read_all()?;
|
|
let filtered: Vec<_> = match since {
|
|
Some(t) => events.into_iter().filter(|e| e.ts.as_str() > t).collect(),
|
|
None => events,
|
|
};
|
|
|
|
let base = match load_local_base(base_ref_path)? {
|
|
Some(local) => hammer_core::Base {
|
|
distro_version: local.distro_version,
|
|
pins: local.pins,
|
|
},
|
|
None => {
|
|
eprintln!("warning: sin --base-ref; emitiendo base con distro_version='unknown'");
|
|
hammer_core::Base {
|
|
distro_version: "unknown".into(),
|
|
pins: BTreeMap::new(),
|
|
}
|
|
}
|
|
};
|
|
|
|
// El store es opcional: si el directorio no existe, seguimos en modo "todo file_drop"
|
|
// (igual que el comportamiento pre-provenance). El receptor no nota la diferencia.
|
|
let store_opt = match hammer_core::Store::open(store_root) {
|
|
Ok(s) => Some(s),
|
|
Err(e) => {
|
|
eprintln!(
|
|
"warning: store {store_root} no accesible ({e}); export sin provenance"
|
|
);
|
|
None
|
|
}
|
|
};
|
|
|
|
let (mutations, stats) = build_export_mutations(&filtered, store_opt.as_ref());
|
|
|
|
let swm = hammer_core::Swm {
|
|
swm_version: 1,
|
|
base,
|
|
mutations,
|
|
signature: None,
|
|
};
|
|
let yaml = swm.to_yaml()?;
|
|
print!("{yaml}");
|
|
eprintln!(
|
|
"export: {} source_patch(es), {} file_drop(s), {} warning(s){}",
|
|
stats.source_patches,
|
|
stats.file_drops,
|
|
stats.warnings,
|
|
if stats.missing_recipe > 0 {
|
|
format!(" ({} artefacto(s) sin sidecar de receta)", stats.missing_recipe)
|
|
} else {
|
|
String::new()
|
|
},
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct ExportStats {
|
|
source_patches: usize,
|
|
file_drops: usize,
|
|
warnings: usize,
|
|
/// Cuántos artefactos referenciados por eventos no traían `.hammer/recipe.toml`
|
|
/// y por tanto cayeron a file_drop. Útil para que el humano sepa que parte del
|
|
/// export perdió provenance (típicamente builds anteriores a Fase 4).
|
|
missing_recipe: usize,
|
|
}
|
|
|
|
/// Construye la lista de mutaciones del `.swm` exportado. Función pura sobre `events` +
|
|
/// el store: testeable sin abrir el journal real ni la CLI.
|
|
///
|
|
/// Estrategia:
|
|
/// 1. Aplicamos las semánticas de Delete (invalida estados anteriores del mismo path) y
|
|
/// dedupeamos por path quedándonos con el último evento.
|
|
/// 2. Particionamos los eventos sobrevivientes en dos: los que tienen un `artifact_hash`
|
|
/// cuyo store contiene la receta sidecar (→ candidates para `source_patch` agrupado),
|
|
/// y el resto (→ `file_drop`).
|
|
/// 3. Por cada grupo `artifact_hash` con receta: emitimos UN `source_patch`. El
|
|
/// `target_bin` es el primer path del grupo en orden alfabético; el resto de paths
|
|
/// queda implícito (el receptor hidrata el artefacto completo).
|
|
/// 4. Por cada evento de fallback: leemos el contenido actual del disco, codificamos
|
|
/// base64 y emitimos `file_drop`. Si la lectura falla, contamos warning y omitimos.
|
|
fn build_export_mutations(
|
|
events: &[hammer_journal::MutationEvent],
|
|
store: Option<&hammer_core::Store>,
|
|
) -> (Vec<hammer_core::Mutation>, ExportStats) {
|
|
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
|
use std::collections::BTreeMap;
|
|
|
|
// Paso 1: dedup respetando Delete.
|
|
let mut last_by_path: BTreeMap<PathBuf, &hammer_journal::MutationEvent> = BTreeMap::new();
|
|
for ev in events {
|
|
if matches!(ev.op, hammer_journal::MutationOp::Delete) {
|
|
last_by_path.remove(&ev.path);
|
|
continue;
|
|
}
|
|
last_by_path.insert(ev.path.clone(), ev);
|
|
}
|
|
|
|
let mut stats = ExportStats::default();
|
|
|
|
// Paso 2: particionamos. Para que la asignación a `source_patch` requiera la receta,
|
|
// resolvemos el sidecar acá; si no está, el evento cae al fallback.
|
|
//
|
|
// Estructura: artifact_hash → (Recipe, Vec<path>). Los paths se ordenarán antes de
|
|
// emitir, para que el target_bin sea determinista (importante para tests).
|
|
let mut groups: BTreeMap<String, (hammer_core::Recipe, Vec<PathBuf>)> = BTreeMap::new();
|
|
let mut fallback: Vec<(PathBuf, &hammer_journal::MutationEvent)> = Vec::new();
|
|
|
|
let mut artifacts_seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
|
|
let mut artifacts_without_recipe: std::collections::BTreeSet<String> =
|
|
std::collections::BTreeSet::new();
|
|
|
|
for (path, ev) in &last_by_path {
|
|
let artifact = match &ev.by.source {
|
|
hammer_journal::Source::HammerHydrate { artifact } => Some(artifact.clone()),
|
|
hammer_journal::Source::HammerCommit { artifact: Some(a), .. } => Some(a.clone()),
|
|
_ => None,
|
|
};
|
|
let Some(art) = artifact else {
|
|
fallback.push((path.clone(), *ev));
|
|
continue;
|
|
};
|
|
artifacts_seen.insert(art.clone());
|
|
|
|
// Si ya conseguimos la receta para este artifact_hash en una pasada anterior,
|
|
// reusamos sin tocar disco otra vez.
|
|
if let Some((_, paths)) = groups.get_mut(&art) {
|
|
paths.push(path.clone());
|
|
continue;
|
|
}
|
|
if artifacts_without_recipe.contains(&art) {
|
|
fallback.push((path.clone(), *ev));
|
|
continue;
|
|
}
|
|
|
|
let recipe_opt = store.and_then(|s| s.recipe_for_hash(&art).ok().flatten());
|
|
match recipe_opt {
|
|
Some(r) => {
|
|
groups.insert(art, (r, vec![path.clone()]));
|
|
}
|
|
None => {
|
|
artifacts_without_recipe.insert(art);
|
|
fallback.push((path.clone(), *ev));
|
|
}
|
|
}
|
|
}
|
|
|
|
stats.missing_recipe = artifacts_without_recipe.len();
|
|
|
|
let mut mutations: Vec<hammer_core::Mutation> = Vec::new();
|
|
|
|
// Paso 3: source_patches (ordenados por artifact_hash para estabilidad del output).
|
|
for (art_hash, (recipe, mut paths)) in groups {
|
|
paths.sort();
|
|
let target_bin = paths
|
|
.first()
|
|
.map(|p| p.display().to_string())
|
|
.unwrap_or_else(|| "/".into());
|
|
|
|
// Convertimos la Recipe a los campos de origen que SourcePatch necesita
|
|
// (git xor tarball — ambos se modelan ya, sin caer a file_drop).
|
|
let (repo, commit, tarball, sha256) = match recipe.source.kind() {
|
|
Ok(hammer_core::SourceKind::Git { repo, commit }) => {
|
|
(Some(repo.to_string()), Some(commit.to_string()), None, None)
|
|
}
|
|
Ok(hammer_core::SourceKind::Tarball { url, sha256 }) => {
|
|
(None, None, Some(url.to_string()), Some(sha256.to_string()))
|
|
}
|
|
Err(_) => {
|
|
stats.warnings += 1;
|
|
eprintln!(
|
|
"warning: receta de {art_hash} tiene source inválido; cayendo a file_drop"
|
|
);
|
|
for p in &paths {
|
|
if let Some(ev) = last_by_path.get(p) {
|
|
fallback.push((p.clone(), *ev));
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
};
|
|
|
|
// Patches inline: si la receta tenía patches, los leemos y concatenamos. El
|
|
// hash del artefacto incluye su contenido (ver `Recipe::hash_inputs`), así que
|
|
// el receptor reconstruye byte-a-byte si y sólo si el patch coincide.
|
|
let patch_inline = collect_patches_inline(&recipe);
|
|
|
|
if paths.len() > 1 {
|
|
eprintln!(
|
|
" source_patch {} cubre {} archivo(s); target_bin={} (resto se hidrata al aplicar)",
|
|
art_hash,
|
|
paths.len(),
|
|
target_bin
|
|
);
|
|
}
|
|
|
|
mutations.push(hammer_core::Mutation::SourcePatch {
|
|
repo,
|
|
commit,
|
|
tarball,
|
|
sha256,
|
|
strip_components: (recipe.source.strip_components != 1)
|
|
.then_some(recipe.source.strip_components),
|
|
patch: patch_inline,
|
|
patch_url: None,
|
|
build: hammer_core::SwmBuild {
|
|
compiler: recipe.build.compiler.as_str().to_string(),
|
|
target: recipe.build.target.clone(),
|
|
link: recipe.build.link.as_str().to_string(),
|
|
flags: recipe.build.flags.clone(),
|
|
phases: recipe.build.phases.clone(),
|
|
zig_version: recipe.build.zig_version.clone(),
|
|
},
|
|
target_bin,
|
|
expected_hash: Some(art_hash),
|
|
deps: recipe.deps.clone(),
|
|
evidence: recipe.evidence.clone(),
|
|
slots: recipe.slots.clone(),
|
|
});
|
|
stats.source_patches += 1;
|
|
}
|
|
|
|
// Paso 4: file_drops. Dedupeamos por path porque algunos paths pueden haber rebotado
|
|
// del grupo de tarball-fallback al fallback general además del path original.
|
|
let mut seen_paths: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
|
|
fallback.sort_by(|(a, _), (b, _)| a.cmp(b));
|
|
for (path, ev) in fallback {
|
|
if !seen_paths.insert(path.clone()) {
|
|
continue;
|
|
}
|
|
let bytes = match std::fs::read(&path) {
|
|
Ok(b) => b,
|
|
Err(e) => {
|
|
stats.warnings += 1;
|
|
eprintln!(
|
|
"warning: no pude leer {} para exportar (ev ts={}): {e}",
|
|
path.display(),
|
|
ev.ts
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
let h = hammer_core::ArtifactHash::of_inputs(&[bytes.as_slice()]);
|
|
let b64 = STANDARD.encode(&bytes);
|
|
mutations.push(hammer_core::Mutation::FileDrop {
|
|
path: path.display().to_string(),
|
|
content_hash: h.as_str().to_string(),
|
|
content_b64: Some(b64),
|
|
content_url: None,
|
|
});
|
|
stats.file_drops += 1;
|
|
}
|
|
|
|
(mutations, stats)
|
|
}
|
|
|
|
/// Concatena los contenidos de todos los patches declarados por la receta en un único
|
|
/// blob (separado por una línea con `--- /dev/null` falsa). Si la receta no tiene
|
|
/// patches, devuelve `None`. Si algún patch no se puede leer, devuelve `None` y deja un
|
|
/// warning en stderr — el receptor reconstruirá sin patches y verificará con
|
|
/// `expected_hash` que el binario coincide, así que el error es detectable.
|
|
fn collect_patches_inline(recipe: &hammer_core::Recipe) -> Option<String> {
|
|
if recipe.source.patches.is_empty() {
|
|
return None;
|
|
}
|
|
let mut all = String::new();
|
|
for p in &recipe.source.patches {
|
|
let path = if std::path::Path::new(p).is_absolute() {
|
|
std::path::PathBuf::from(p)
|
|
} else {
|
|
recipe.base_dir.join(p)
|
|
};
|
|
match std::fs::read_to_string(&path) {
|
|
Ok(text) => all.push_str(&text),
|
|
Err(e) => {
|
|
eprintln!(
|
|
"warning: no pude leer patch {} para inline: {e}",
|
|
path.display()
|
|
);
|
|
return None;
|
|
}
|
|
}
|
|
}
|
|
Some(all)
|
|
}
|
|
|
|
/// Ejecuta el bucle agéntico de Fase 6 vía `hammer-agent`. Acepta una intención NL +
|
|
/// catálogo YAML que mapea intentos exactos a `.swm`s pre-armados. La salida es un
|
|
/// `Proposal` legible en stdout + el siguiente paso recomendado al humano.
|
|
fn run_ai(
|
|
intent: &str,
|
|
catalog: Option<&std::path::Path>,
|
|
llm: bool,
|
|
prefix: Option<&std::path::Path>,
|
|
base_ref: Option<&std::path::Path>,
|
|
bus: Option<&std::path::Path>,
|
|
state_root: Option<&std::path::Path>,
|
|
store_root: &str,
|
|
repair_max_attempts: Option<u32>,
|
|
repair_window_ms: u64,
|
|
) -> anyhow::Result<()> {
|
|
use hammer_agent::orchestrator::{ApplyTarget, CompileMode, RepairPolicy};
|
|
|
|
if llm && catalog.is_some() {
|
|
anyhow::bail!("--llm y --catalog son mutuamente excluyentes");
|
|
}
|
|
if !llm && catalog.is_none() {
|
|
anyhow::bail!("hace falta --catalog (modo mock) o --llm (modo LLM)");
|
|
}
|
|
|
|
// Calcular BaseRef. En modo catálogo se puede inferir del .swm; en modo LLM hace falta
|
|
// --base-ref explícito porque el modelo no ha producido nada aún.
|
|
let base = if llm {
|
|
match load_local_base(base_ref)? {
|
|
Some(b) => b,
|
|
None => anyhow::bail!(
|
|
"modo --llm requiere --base-ref para que el traductor reciba un SystemContext válido"
|
|
),
|
|
}
|
|
} else {
|
|
infer_base_for_catalog(intent, catalog.unwrap(), base_ref)?
|
|
};
|
|
|
|
let apply = match prefix {
|
|
Some(p) => ApplyTarget::Prefix { prefix: p.to_path_buf() },
|
|
None => ApplyTarget::Overlay {
|
|
state_root: state_root
|
|
.map(|p| p.to_path_buf())
|
|
.unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT)),
|
|
},
|
|
};
|
|
|
|
let compile = match bus {
|
|
Some(s) => CompileMode::ViaBus {
|
|
sock: s.to_path_buf(),
|
|
store: PathBuf::from(store_root),
|
|
timeout: std::time::Duration::from_secs(600),
|
|
},
|
|
None => CompileMode::Skip,
|
|
};
|
|
|
|
let policy_opt = repair_max_attempts.map(|max| RepairPolicy {
|
|
max_attempts: max,
|
|
crash_window: std::time::Duration::from_millis(repair_window_ms),
|
|
event_sock: bus.map(|p| p.to_path_buf()),
|
|
..RepairPolicy::default()
|
|
});
|
|
|
|
let proposal = if llm {
|
|
run_with_llm_translator(intent, base, apply, compile, policy_opt.as_ref())?
|
|
} else {
|
|
run_with_mock_translator(
|
|
intent,
|
|
catalog.unwrap(),
|
|
base,
|
|
apply,
|
|
compile,
|
|
policy_opt.as_ref(),
|
|
)?
|
|
};
|
|
|
|
println!("--- proposal ---");
|
|
println!("intent: {}", proposal.intent);
|
|
println!(
|
|
"applied: source_patch={} config_edit={} init_rule={} file_drop={}",
|
|
proposal.applied.source_patch,
|
|
proposal.applied.config_edit,
|
|
proposal.applied.init_rule,
|
|
proposal.applied.file_drop,
|
|
);
|
|
if proposal.skipped_source_patches > 0 {
|
|
println!(
|
|
"skipped_source_patches: {} (sin --bus)",
|
|
proposal.skipped_source_patches
|
|
);
|
|
}
|
|
println!("checks:");
|
|
for c in &proposal.checks {
|
|
println!(" [{:?}] {}", c.level, c.msg);
|
|
}
|
|
if !proposal.repair_chain.is_empty() {
|
|
println!("\nrepair_chain ({} intento(s)):", proposal.repair_chain.len());
|
|
for r in &proposal.repair_chain {
|
|
match &r.triggered_crash {
|
|
Some(c) => println!(
|
|
" #{:>2} intent={:?} overlay={:?} → crash service={} code={}",
|
|
r.attempt, r.intent, r.overlay_id, c.service, c.code
|
|
),
|
|
None => println!(
|
|
" #{:>2} intent={:?} overlay={:?} → estabilizado",
|
|
r.attempt, r.intent, r.overlay_id
|
|
),
|
|
}
|
|
}
|
|
}
|
|
match (&proposal.overlay_id, &proposal.prefix) {
|
|
(Some(id), _) => {
|
|
println!("\noverlay {id} listo.");
|
|
println!(" Revisa el cambio en caliente y luego:");
|
|
println!(" hammer commit {id} # promueve + diario");
|
|
println!(" hammer discard {id} # descarta");
|
|
}
|
|
(None, Some(p)) => {
|
|
println!("\nprefix {} actualizado (modo test, sin overlay).", p.display());
|
|
}
|
|
_ => {}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// En modo catálogo, intenta deducir un BaseRef si el humano no pasó `--base-ref`:
|
|
/// usa la `base` del propio `.swm` mockeado. Esto facilita el smoke-test offline.
|
|
fn infer_base_for_catalog(
|
|
intent: &str,
|
|
catalog: &std::path::Path,
|
|
base_ref: Option<&std::path::Path>,
|
|
) -> anyhow::Result<hammer_core::BaseRef> {
|
|
if let Some(b) = load_local_base(base_ref)? {
|
|
return Ok(b);
|
|
}
|
|
let cat = hammer_agent::IntentCatalog::load_from_path(catalog)
|
|
.map_err(|e| anyhow::anyhow!("catálogo {}: {e}", catalog.display()))?;
|
|
let base_dir = catalog.parent().unwrap_or_else(|| std::path::Path::new("."));
|
|
let ctx_swm = cat
|
|
.intents
|
|
.iter()
|
|
.find(|e| e.intent == intent)
|
|
.ok_or_else(|| anyhow::anyhow!("intent '{intent}' no está en el catálogo"))?
|
|
.swm_inline
|
|
.clone()
|
|
.or_else(|| {
|
|
cat.intents
|
|
.iter()
|
|
.find(|e| e.intent == intent)
|
|
.and_then(|e| e.swm_path.as_ref())
|
|
.and_then(|p| std::fs::read_to_string(base_dir.join(p)).ok())
|
|
.and_then(|t| hammer_core::Swm::from_yaml(&t).ok())
|
|
});
|
|
match ctx_swm {
|
|
Some(s) => Ok(hammer_core::BaseRef::from(&s.base)),
|
|
None => anyhow::bail!("no pude inferir BaseRef sin --base-ref"),
|
|
}
|
|
}
|
|
|
|
fn run_with_mock_translator(
|
|
intent: &str,
|
|
catalog: &std::path::Path,
|
|
base: hammer_core::BaseRef,
|
|
apply: hammer_agent::orchestrator::ApplyTarget,
|
|
compile: hammer_agent::orchestrator::CompileMode,
|
|
policy: Option<&hammer_agent::orchestrator::RepairPolicy>,
|
|
) -> anyhow::Result<hammer_agent::Proposal> {
|
|
use hammer_agent::{IntentCatalog, MockTranslator, Orchestrator};
|
|
let cat = IntentCatalog::load_from_path(catalog)
|
|
.map_err(|e| anyhow::anyhow!("catálogo {}: {e}", catalog.display()))?;
|
|
let base_dir = catalog.parent().unwrap_or_else(|| std::path::Path::new("."));
|
|
let translator = MockTranslator::from_catalog(&cat, base_dir)
|
|
.map_err(|e| anyhow::anyhow!("catalog: {e}"))?;
|
|
let orch = Orchestrator::new(translator, base, apply, compile);
|
|
match policy {
|
|
Some(p) => orch
|
|
.run_with_repair(intent, p)
|
|
.map_err(|e| anyhow::anyhow!("orchestrator (auto-repair): {e}")),
|
|
None => orch
|
|
.run(intent)
|
|
.map_err(|e| anyhow::anyhow!("orchestrator: {e}")),
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "llm-claude")]
|
|
fn run_with_llm_translator(
|
|
intent: &str,
|
|
base: hammer_core::BaseRef,
|
|
apply: hammer_agent::orchestrator::ApplyTarget,
|
|
compile: hammer_agent::orchestrator::CompileMode,
|
|
policy: Option<&hammer_agent::orchestrator::RepairPolicy>,
|
|
) -> anyhow::Result<hammer_agent::Proposal> {
|
|
use hammer_agent::{ClaudeConfig, ClaudeTranslator, Orchestrator};
|
|
let cfg = ClaudeConfig::from_env()
|
|
.map_err(|e| anyhow::anyhow!("ClaudeConfig::from_env: {e}"))?;
|
|
let translator = ClaudeTranslator::new(cfg);
|
|
let orch = Orchestrator::new(translator, base, apply, compile);
|
|
match policy {
|
|
Some(p) => orch
|
|
.run_with_repair(intent, p)
|
|
.map_err(|e| anyhow::anyhow!("orchestrator (auto-repair): {e}")),
|
|
None => orch
|
|
.run(intent)
|
|
.map_err(|e| anyhow::anyhow!("orchestrator: {e}")),
|
|
}
|
|
}
|
|
|
|
#[cfg(not(feature = "llm-claude"))]
|
|
fn run_with_llm_translator(
|
|
_intent: &str,
|
|
_base: hammer_core::BaseRef,
|
|
_apply: hammer_agent::orchestrator::ApplyTarget,
|
|
_compile: hammer_agent::orchestrator::CompileMode,
|
|
_policy: Option<&hammer_agent::orchestrator::RepairPolicy>,
|
|
) -> anyhow::Result<hammer_agent::Proposal> {
|
|
anyhow::bail!(
|
|
"este binario se compiló sin el feature `llm-claude`. Recompila: \
|
|
`cargo build -p hammer-cli --features llm-claude` (o desactiva --llm)."
|
|
)
|
|
}
|
|
|
|
/// Evalúa una expresión del mini-lenguaje localmente e imprime el JSON resultante.
|
|
/// Espejo en proceso del path remoto vía bus (`AgentClient::query_expr`).
|
|
fn run_query(
|
|
expr: &str,
|
|
base_ref: Option<&std::path::Path>,
|
|
fs_root: Option<&std::path::Path>,
|
|
) -> anyhow::Result<()> {
|
|
let mut ctx = hammer_core::query::EvalContext::new();
|
|
if let Some(b) = load_local_base(base_ref)? {
|
|
ctx = ctx.with_base(b);
|
|
}
|
|
if let Some(r) = fs_root {
|
|
ctx = ctx.with_fs_root(r.to_path_buf());
|
|
}
|
|
let value = hammer_core::query::eval_str(expr, &ctx);
|
|
let json = serde_json::to_string_pretty(&value)?;
|
|
println!("{json}");
|
|
Ok(())
|
|
}
|
|
|
|
/// Envía una línea al FIFO de control humano. No abrimos un proceso intermedio (sería
|
|
/// `sh -c "echo ... > FIFO"` con riesgo de quoting); usamos un `OpenOptions::write` y un
|
|
/// `write_all`. El FIFO bloquea si no hay reader — eso es la garantía de "ack" implícito.
|
|
fn run_ctl(line: &str, fifo: &std::path::Path) -> anyhow::Result<()> {
|
|
use std::io::Write;
|
|
use std::os::unix::fs::FileTypeExt;
|
|
let meta = std::fs::symlink_metadata(fifo).map_err(|e| {
|
|
anyhow::anyhow!(
|
|
"no encuentro el FIFO {} ({e}); ¿está hammerd arrancado?",
|
|
fifo.display()
|
|
)
|
|
})?;
|
|
if !meta.file_type().is_fifo() {
|
|
anyhow::bail!("{} existe pero no es un FIFO", fifo.display());
|
|
}
|
|
let mut f = std::fs::OpenOptions::new().write(true).open(fifo)?;
|
|
f.write_all(line.as_bytes())?;
|
|
if !line.ends_with('\n') {
|
|
f.write_all(b"\n")?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Parsea un `--swap name=hash[:rel_path]` (variante b del builder, SDD 11 §7.2b). El `rel_path`
|
|
/// por defecto es `usr/bin/<name>`. Quitamos el prefijo `b3:` del hash ANTES de buscar el `:` del
|
|
/// rel_path opcional — si no, `make=b3:abc` se parsearía como hash=`b3`, rel_path=`abc`.
|
|
fn parse_swap(s: &str) -> anyhow::Result<hammer_bootstrap::ToolchainSwap> {
|
|
let (name, rest) = s
|
|
.split_once('=')
|
|
.ok_or_else(|| anyhow::anyhow!("--swap '{s}': falta '=' (name=hash[:rel_path])"))?;
|
|
let rest = rest.trim_start_matches("b3:");
|
|
let (hash, rel_path) = match rest.split_once(':') {
|
|
Some((h, p)) => (h, p.to_string()),
|
|
None => (rest, format!("usr/bin/{name}")),
|
|
};
|
|
Ok(hammer_bootstrap::ToolchainSwap {
|
|
name: name.to_string(),
|
|
artifact: hammer_core::ArtifactHash::from_hex(hash),
|
|
rel_path,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod pin_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn is_git_sha_detecta_sha_vs_tag() {
|
|
assert!(is_git_sha("4a7b216552d64134c0fa17a59b9d557d89019f0f")); // sha1 40 hex
|
|
assert!(is_git_sha(&"a".repeat(64))); // sha256 64 hex
|
|
assert!(!is_git_sha("v1.1.0")); // tag
|
|
assert!(!is_git_sha("main")); // branch
|
|
assert!(!is_git_sha("4a7b21")); // corto
|
|
assert!(!is_git_sha(&"z".repeat(40))); // 40 chars no-hex
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod swap_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn swap_with_b3_prefix_keeps_full_hash() {
|
|
// Regresión: el `:` del prefijo `b3:` no debe confundirse con el separador del rel_path.
|
|
let s = parse_swap("make=b3:fbad44ac").unwrap();
|
|
assert_eq!(s.name, "make");
|
|
assert_eq!(s.artifact.as_str(), "b3:fbad44ac");
|
|
assert_eq!(s.rel_path, "usr/bin/make", "rel_path por defecto");
|
|
}
|
|
|
|
#[test]
|
|
fn swap_bare_hash_and_explicit_rel_path() {
|
|
let s = parse_swap("bwrap=abcd:usr/bin/bwrap").unwrap();
|
|
assert_eq!(s.name, "bwrap");
|
|
assert_eq!(s.artifact.as_str(), "b3:abcd");
|
|
assert_eq!(s.rel_path, "usr/bin/bwrap");
|
|
}
|
|
|
|
#[test]
|
|
fn swap_b3_prefix_with_explicit_rel_path() {
|
|
let s = parse_swap("make=b3:fbad:usr/local/bin/make").unwrap();
|
|
assert_eq!(s.artifact.as_str(), "b3:fbad");
|
|
assert_eq!(s.rel_path, "usr/local/bin/make");
|
|
}
|
|
|
|
#[test]
|
|
fn swap_missing_equals_errors() {
|
|
let err = parse_swap("makeb3:fbad").unwrap_err().to_string();
|
|
assert!(err.contains("falta '='"), "{err}");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod export_tests {
|
|
use super::*;
|
|
use hammer_journal::{Actor, MutationEvent, MutationOp, Source};
|
|
|
|
fn write_recipe_sidecar(art_dir: &std::path::Path, name: &str, commit: &str) {
|
|
std::fs::create_dir_all(art_dir.join(".hammer")).unwrap();
|
|
let toml = format!(
|
|
r#"
|
|
name = "{name}"
|
|
version = "0.1"
|
|
[source]
|
|
repo = "git://example/{name}.git"
|
|
commit = "{commit}"
|
|
[build]
|
|
compiler = "zig-cc"
|
|
target = "x86_64-linux-musl"
|
|
link = "static"
|
|
flags = ["--enable-foo"]
|
|
"#
|
|
);
|
|
std::fs::write(art_dir.join(hammer_core::store::RECIPE_SIDECAR_REL), toml).unwrap();
|
|
}
|
|
|
|
fn ev_hydrate(path: &str, artifact: &str) -> MutationEvent {
|
|
MutationEvent {
|
|
ts: "2026-06-10T00:00:00Z".into(),
|
|
op: MutationOp::Create,
|
|
path: PathBuf::from(path),
|
|
by: Actor {
|
|
source: Source::HammerHydrate { artifact: artifact.into() },
|
|
pid: None,
|
|
uid: None,
|
|
},
|
|
content_hash: None,
|
|
note: None,
|
|
}
|
|
}
|
|
|
|
fn ev_external(path: &str) -> MutationEvent {
|
|
MutationEvent {
|
|
ts: "2026-06-10T00:00:01Z".into(),
|
|
op: MutationOp::Edit,
|
|
path: PathBuf::from(path),
|
|
by: Actor { source: Source::External, pid: Some(42), uid: Some(1000) },
|
|
content_hash: None,
|
|
note: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn export_without_store_falls_back_to_file_drops() {
|
|
let d = tempfile::tempdir().unwrap();
|
|
let target = d.path().join("etc/hosts");
|
|
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
|
|
std::fs::write(&target, b"127.0.0.1 lo\n").unwrap();
|
|
|
|
let events = vec![ev_external(target.to_str().unwrap())];
|
|
let (mutations, stats) = build_export_mutations(&events, None);
|
|
assert_eq!(stats.source_patches, 0);
|
|
assert_eq!(stats.file_drops, 1);
|
|
assert!(matches!(mutations[0], hammer_core::Mutation::FileDrop { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn delete_event_invalidates_previous_state() {
|
|
let d = tempfile::tempdir().unwrap();
|
|
let target = d.path().join("a");
|
|
std::fs::write(&target, b"x").unwrap();
|
|
|
|
let mut create = ev_external(target.to_str().unwrap());
|
|
create.op = MutationOp::Create;
|
|
let mut delete = ev_external(target.to_str().unwrap());
|
|
delete.op = MutationOp::Delete;
|
|
let events = vec![create, delete];
|
|
let (mutations, stats) = build_export_mutations(&events, None);
|
|
assert!(mutations.is_empty());
|
|
assert_eq!(stats.file_drops, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn hydrate_with_recipe_sidecar_emits_source_patch() {
|
|
let d = tempfile::tempdir().unwrap();
|
|
let store_root = d.path().join("store");
|
|
let store = hammer_core::Store::open(&store_root).unwrap();
|
|
let art_dir = store_root.join("cafe1234-grep");
|
|
write_recipe_sidecar(&art_dir, "grep", "deadbeef");
|
|
|
|
// Dos archivos del MISMO artifact_hash ⇒ deben colapsar en UN source_patch.
|
|
let file_a = d.path().join("usr/bin/grep");
|
|
let file_b = d.path().join("usr/lib/grep-helper.so");
|
|
std::fs::create_dir_all(file_a.parent().unwrap()).unwrap();
|
|
std::fs::create_dir_all(file_b.parent().unwrap()).unwrap();
|
|
std::fs::write(&file_a, b"x").unwrap();
|
|
std::fs::write(&file_b, b"y").unwrap();
|
|
let events = vec![
|
|
ev_hydrate(file_a.to_str().unwrap(), "b3:cafe1234"),
|
|
ev_hydrate(file_b.to_str().unwrap(), "b3:cafe1234"),
|
|
];
|
|
|
|
let (mutations, stats) = build_export_mutations(&events, Some(&store));
|
|
assert_eq!(stats.source_patches, 1, "{:?}", stats);
|
|
assert_eq!(stats.file_drops, 0);
|
|
assert_eq!(stats.missing_recipe, 0);
|
|
match &mutations[0] {
|
|
hammer_core::Mutation::SourcePatch {
|
|
repo,
|
|
commit,
|
|
build,
|
|
target_bin,
|
|
expected_hash,
|
|
..
|
|
} => {
|
|
assert_eq!(repo.as_deref(), Some("git://example/grep.git"));
|
|
assert_eq!(commit.as_deref(), Some("deadbeef"));
|
|
assert_eq!(build.compiler, "zig-cc");
|
|
assert_eq!(build.link, "static");
|
|
assert_eq!(build.flags, vec!["--enable-foo".to_string()]);
|
|
// target_bin es el primer path alfabético del grupo.
|
|
let tb = std::path::PathBuf::from(target_bin);
|
|
assert!(tb.ends_with("usr/bin/grep"), "{target_bin}");
|
|
assert_eq!(expected_hash.as_deref(), Some("b3:cafe1234"));
|
|
}
|
|
other => panic!("esperaba SourcePatch, llegó {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn hydrate_without_recipe_falls_back_to_file_drop() {
|
|
let d = tempfile::tempdir().unwrap();
|
|
let store_root = d.path().join("store");
|
|
let store = hammer_core::Store::open(&store_root).unwrap();
|
|
// Creamos el artefacto SIN sidecar.
|
|
std::fs::create_dir_all(store_root.join("badbeef-grep")).unwrap();
|
|
|
|
let target = d.path().join("usr/bin/grep");
|
|
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
|
|
std::fs::write(&target, b"binary").unwrap();
|
|
let events = vec![ev_hydrate(target.to_str().unwrap(), "b3:badbeef")];
|
|
|
|
let (mutations, stats) = build_export_mutations(&events, Some(&store));
|
|
assert_eq!(stats.source_patches, 0);
|
|
assert_eq!(stats.file_drops, 1);
|
|
assert_eq!(stats.missing_recipe, 1, "debe contar el artefacto sin sidecar");
|
|
assert!(matches!(mutations[0], hammer_core::Mutation::FileDrop { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn mixed_traceable_and_external_emit_both_kinds() {
|
|
let d = tempfile::tempdir().unwrap();
|
|
let store_root = d.path().join("store");
|
|
let store = hammer_core::Store::open(&store_root).unwrap();
|
|
let art_dir = store_root.join("aaaa1111-grep");
|
|
write_recipe_sidecar(&art_dir, "grep", "feedface");
|
|
|
|
let traced = d.path().join("usr/bin/grep");
|
|
std::fs::create_dir_all(traced.parent().unwrap()).unwrap();
|
|
std::fs::write(&traced, b"x").unwrap();
|
|
let edited = d.path().join("etc/grep.conf");
|
|
std::fs::create_dir_all(edited.parent().unwrap()).unwrap();
|
|
std::fs::write(&edited, b"OPTS=\n").unwrap();
|
|
|
|
let events = vec![
|
|
ev_hydrate(traced.to_str().unwrap(), "b3:aaaa1111"),
|
|
ev_external(edited.to_str().unwrap()),
|
|
];
|
|
let (mutations, stats) = build_export_mutations(&events, Some(&store));
|
|
assert_eq!(stats.source_patches, 1);
|
|
assert_eq!(stats.file_drops, 1);
|
|
// Orden: source_patches primero (BTreeMap por hash), luego file_drops.
|
|
assert!(matches!(mutations[0], hammer_core::Mutation::SourcePatch { .. }));
|
|
assert!(matches!(mutations[1], hammer_core::Mutation::FileDrop { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn unreadable_path_in_fallback_only_warns() {
|
|
// ev_external apuntando a un path inexistente: el evento se cuenta como warning
|
|
// y queda fuera del .swm. La función no falla.
|
|
let events = vec![ev_external("/no/existe/seguramente/x")];
|
|
let (mutations, stats) = build_export_mutations(&events, None);
|
|
assert_eq!(stats.warnings, 1);
|
|
assert_eq!(stats.file_drops, 0);
|
|
assert!(mutations.is_empty());
|
|
}
|
|
|
|
}
|