Files
hammer/crates/hammer-cli/src/main.rs
T
sergioandClaude Opus 4.8 3c343bee9a arranque-grafo: paso 3 del ADR 0010 — grafo de estados + hammer boot
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>
2026-07-06 20:36:04 -04:00

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());
}
}