//! `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 kernel_cmd; 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, }, /// [Fase 0] Calcula el `ArtifactHash` de una receta SIN construir — puro sobre las recetas /// (source_id + compiler/target/link + patches + flags + fases + hashes de deps). No baja /// fuentes ni compila. Responde "¿cuál es el hash VIGENTE de esta receta?", que el store no /// puede dar sin sellar. Con `--check`: exit 0 si el artefacto vigente ya está en el store, /// 1 si no — el dry-run que le faltaba a scripts/static-audit.sh para no auditar sellados rancios. Hash { /// Ruta a la receta TOML. recipe: String, /// No imprime el hash; verifica si el artefacto vigente está sellado en el store (exit 0/1). #[arg(long)] check: bool, }, /// [SDD 17 §1.2] Explica POR QUÉ dos artefactos difieren — el diffoscope propio. Cuando un hash /// no reproduce, el store sólo sabe decir "no coincide"; esto nombra la CAUSA: un MTIME embebido /// en un gzip, la cabecera `ar` de un `.a`, qué sección ELF cambió (`.comment` ⇒ otro compilador), /// una ruta del árbol de build filtrada al binario. Desciende sólo donde los hashes difieren. /// Exit 0 si los dos árboles son idénticos, 1 si divergen. WhyDiffers { /// Artefacto A: un directorio del store (`store/-`) o cualquier árbol. a: String, /// Artefacto B, el que se compara contra A. b: String, /// Informe JSON — evidencia legible por máquina para el barrido de granja y el bucle agéntico. #[arg(long)] json: bool, }, /// [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, /// Raíz donde viven los manifiestos y los upper/work. Default `/var/lib/hammer/overlays`. #[arg(long)] state_root: Option, }, /// [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, /// 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, }, /// [Fase 2] Lista los overlays activos según los manifiestos persistidos. Status { #[arg(long)] state_root: Option, }, /// [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, /// 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, /// Raíz del overlay (sólo en modo overlay). #[arg(long)] state_root: Option, }, /// [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, /// 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, /// [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 `.ed25519` /// (privada, 0600) y `.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, }, /// [Fase 4] Firma un `.swm` con una clave privada y escribe el manifiesto firmado. SwmSign { file: String, /// Ruta a la clave privada `.ed25519`. #[arg(long)] key: PathBuf, /// Autoría declarada (`signature.by`). Default: el basename de la clave sin sufijo. #[arg(long)] by: Option, /// Destino. Si se omite, sobrescribe el propio `file` in-place. #[arg(long, short)] out: Option, }, /// [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, /// 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, }, /// [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/`. (apply hidrata el artefacto ENTERO; esto sólo /// verifica que algo esperado quedó en su sitio.) #[arg(long)] target_bin: Option, /// Archivo `.swm` de salida. Sin esto (ni `--repo`), se imprime a stdout. #[arg(long, short)] out: Option, /// Publica el paquete en este repositorio: escribe `/-.swm` y /// actualiza `/index.json` (upsert por nombre). Compatible con `--out`. #[arg(long)] repo: Option, /// `distro_version` de la base del paquete (la base local del receptor debe coincidir). #[arg(long, default_value = "dev")] distro_version: String, /// `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, /// 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, }, /// [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, /// Verifica el `source_patch` y la base, pero NO construye ni hidrata (dry-run de schema). #[arg(long)] skip_source_patch: bool, /// Base local (JSON) contra la que validar la base del paquete. Sin esto no se chequea. #[arg(long)] base_ref: Option, /// Directorio de claves de confianza para validar la firma del `.swm`. Sin esto, la /// firma se informa pero no bloquea (igual que `swm-verify` sin `--trust`). #[arg(long)] trust: Option, /// Raíz de estado para el overlay (modo sin `--prefix`). #[arg(long)] state_root: Option, /// 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, }, /// [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 ` 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, }, /// [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, }, /// [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, /// 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, /// Base local (JSON). Si está, el orquestador verifica `verify_base` contra ella. #[arg(long)] base_ref: Option, /// 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, /// Raíz de overlays para `try`. Default coincide con `hammer try`. #[arg(long)] state_root: Option, /// 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, /// 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 "" > /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:`. Opcional. #[arg(long)] base_ref: Option, /// 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, }, /// [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, }, /// [SDD 22] El armador de kernel: analiza el grafo de Kconfig, lee el kernel que ya corre y /// emite recetas derivadas. **Nunca escribe un `.config`**: eso lo hace el `olddefconfig` del /// propio kernel. Kernel { #[command(subcommand)] sub: kernel_cmd::KernelCmd, }, } #[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 `` 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. /// /// `--from-select` es IDEMPOTENTE: sin fichero (o vacío) es un no-op limpio (exit 0), y tras /// activar con éxito el fichero se consume. Contrato PLAN-KIKIN §9 (tawasuyu, 2026-07-16): /// mirada escribe la selección y dispara el reinicio SIN salir (sostiene el DRM master); /// arje-zero (PID1) invoca esto INCONDICIONAL en su secuencia de apagado, antes del reboot — /// `/run` es tmpfs, así que la selección se aplica en el apagado o no se aplica nunca. Activate { /// Id (BLAKE3, con o sin `b3:`) del nodo a activar. Omitir con `--from-select`. id: Option, /// Lee el id desde el canal de vuelta de mirada (no-op limpio si no existe o está vacío). #[arg(long)] from_select: bool, /// Path del canal de vuelta que deja mirada. #[arg(long, default_value = hammer_upgrade::boot_graph::BOOT_SELECT_PATH)] select: PathBuf, }, /// [ADR 0010 · HARNESS de dev/VM] Orquesta el menú de boot completo: emite el grafo, lanza el /// compositor que lo dibuja sobre KMS, ESPERA su salida y activa lo que dejó en boot-select. /// Sirve para validar el glue en OVMF/QEMU con un compositor stub que sí termina. /// /// EN PRODUCCIÓN NO SE USA (PLAN-KIKIN §4.1/§9, 2026-07-16): mirada real NUNCA sale — sostiene /// el DRM master del greeter al escritorio para no re-modesetear (parpadeo). El camino real es: /// arje-zero publica el grafo al arrancar (`hammer boot graph`), mirada escribe boot-select y /// dispara el reboot por el bus de arje, y arje-zero corre `hammer boot activate --from-select` /// en su secuencia de apagado. Menu { /// Comando del compositor a lanzar (se parte por espacios). El primer token es el binario. #[arg(long, default_value = "mirada-compositor --arranque")] compositor: String, /// Path donde publicar el grafo antes de lanzar el compositor. #[arg(long, default_value = hammer_upgrade::boot_graph::BOOT_GRAPH_PATH)] out: PathBuf, /// Path del canal de vuelta donde el compositor deja el id elegido. #[arg(long, default_value = hammer_upgrade::boot_graph::BOOT_SELECT_PATH)] select: PathBuf, }, } #[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, }, /// 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, }, } #[derive(Subcommand)] enum UpgradeCmd { /// Aplica el árbol `` (`-` 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, }, /// 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, }, } #[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, /// [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, /// Directorio de claves de confianza para verificar la firma del release del `--from-repo`. #[arg(long)] trust: Option, }, /// [Stage 2] Ancla el content-hash del rootfs de Stage 1 como referencia de reproducibilidad /// (anota la línea del manifiesto). Con `--verify ` 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, }, /// [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, /// Caché de fuentes (`work/`) a embeber en `/work` para un rebuild offline. #[arg(long)] work_cache: Option, /// 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/`. Repetible. Ej.: `--swap make=fbad44ac…`. Cada swap entra al hash lógico. #[arg(long = "swap", value_name = "name=hash[:rel_path]")] swaps: Vec, /// 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 { 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::Hash { recipe, check } => { let store = hammer_core::Store::open(&cli.store)?; let recipe = hammer_core::Recipe::load_from_path(&recipe)?; // Misma resolución de lab que `build`, o `hash` mentiría: el toolchain entra en el // ArtifactHash, así que un `hash` que no lo mire daría una dirección que `build` no usa. let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root()); let lab = hammer_core::lab::LabFingerprint::from_rootfs(&cfg.rootfs)?; let hash = hammer_build::artifact_hash(&recipe, &store, &lab)?; if check { // ¿el artefacto VIGENTE (el que corresponde a la receta de HOY) ya está sellado? // Es lo que static-audit.sh no podía saber: `ls -dt store/*-` da el más RECIENTE, // no el vigente. Exit 0 = sellado al día; 1 = haría falta construir. if store.has(&hash, &recipe.name) { println!("{hash}\tSELLADO"); } else { println!("{hash}\tNO-SELLADO"); std::process::exit(1); } } else { println!("{hash}"); } } Cmd::WhyDiffers { a, b, json } => { let informe = hammer_core::why_differs(std::path::Path::new(&a), std::path::Path::new(&b))?; if json { println!("{}", serde_json::to_string_pretty(&informe)?); } else { println!("== why-differs"); println!(" A: {}", informe.a); println!(" B: {}", informe.b); println!( " {} entradas idénticas · {} divergen", informe.identicos, informe.divergencias.len() ); for d in &informe.divergencias { println!("\n {}", d.ruta); println!(" causa: {}", d.explicacion); if let Some(r) = &d.remedio { println!(" → {r}"); } } if informe.reproduce() { println!("\n ✓ los dos árboles son idénticos: REPRODUCE"); } else { let resumen: Vec = informe .por_causa() .iter() .map(|(c, n)| format!("{c}×{n}")) .collect(); println!("\n resumen: {}", resumen.join(" ")); } } if !informe.reproduce() { std::process::exit(1); } } 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 (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::>>()?; 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)?; let vivo = if g.current.is_empty() { "(ninguno)" } else { &g.current }; println!("✓ grafo de arranque escrito en {}", out.display()); println!(" {} nodo(s); vivo: {vivo}", g.nodes.len()); } } BootCmd::Activate { id, from_select, select } => { let id = match (id, from_select) { (Some(id), _) => id, // Idempotente (contrato PLAN-KIKIN §9): arje-zero llama esto incondicional // en TODO apagado; sin selección no hay nada que aplicar y el apagado sigue. (None, true) => match std::fs::read_to_string(&select) { Ok(s) if s.trim().is_empty() => { println!( "hammer boot activate: {} vacío — sin selección, no-op", select.display() ); return Ok(()); } Ok(s) => s.trim().to_string(), Err(e) if e.kind() == std::io::ErrorKind::NotFound => { println!( "hammer boot activate: sin selección ({} no existe) — no-op", select.display() ); return Ok(()); } Err(e) => anyhow::bail!("no pude leer {}: {e}", select.display()), }, (None, false) => anyhow::bail!( "pasá un o usá --from-select para leerlo de {}", select.display()), }; let j = hammer_journal::Journal::open(&journal)?; let graph_out = std::path::PathBuf::from(boot_graph::BOOT_GRAPH_PATH); activate_and_report(&root, &state_root, &j, &id, &graph_out)?; // La selección ya se aplicó: consumir el canal para que una segunda corrida // (u otro apagado en la misma sesión) sea el no-op de arriba. Sólo en éxito — // si activate falló, el `?` ya salió y el fichero queda para diagnóstico. if from_select { let _ = std::fs::remove_file(&select); } } BootCmd::Menu { compositor, out, select } => { // 1) Publicar el grafo (siempre, aunque no haya compositor: otros lo leen). if let Some(parent) = out.parent() { std::fs::create_dir_all(parent).ok(); } boot_graph::emit(&state_root, &out)?; let g = boot_graph::build(&state_root)?; println!("hammer boot menu: grafo en {} ({} nodo/s)", out.display(), g.nodes.len()); // 2) Lanzar el compositor (primer token = binario; resto = args). Limpiamos un // boot-select viejo para no re-activar una elección anterior. let _ = std::fs::remove_file(&select); let parts: Vec<&str> = compositor.split_whitespace().collect(); let Some((bin, args)) = parts.split_first() else { anyhow::bail!("--compositor vacío"); }; match std::process::Command::new(bin).args(args).status() { Err(e) if e.kind() == std::io::ErrorKind::NotFound => { println!("hammer boot menu: sin compositor ({bin}) — arranque normal"); return Ok(()); } Err(e) => { eprintln!("hammer boot menu: no pude lanzar {bin}: {e} — arranque normal"); return Ok(()); } Ok(s) if !s.success() => { eprintln!("hammer boot menu: el compositor salió con {s} — sigo con lo que dejó en boot-select"); } Ok(_) => {} } // 3) ¿El usuario eligió un nodo? Si sí, activarlo; si no, arranque normal. let sel = std::fs::read_to_string(&select).ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); match sel { None => println!("hammer boot menu: sin selección — arranque normal"), Some(id) => { let j = hammer_journal::Journal::open(&journal)?; activate_and_report(&root, &state_root, &j, &id, &out)?; } } } } } Cmd::Kernel { sub } => kernel_cmd::run(sub, &cli.store)?, } Ok(()) } /// Activa un nodo del grafo de arranque, imprime el resultado y republica el grafo (la vista de mirada /// debe reflejar el nuevo nodo vivo). Compartido por `hammer boot activate` y `hammer boot menu`. fn activate_and_report( root: &std::path::Path, state_root: &std::path::Path, journal: &hammer_journal::Journal, id: &str, graph_out: &std::path::Path, ) -> anyhow::Result<()> { use hammer_upgrade::boot_graph; let r = boot_graph::activate(root, state_root, Some(journal), 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, graph_out).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 { 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> { 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 `, 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 = 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 /index.json`). fn read_index(&self) -> anyhow::Result { 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, ) -> anyhow::Result { 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 = 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 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::>() .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::>() .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::>().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 = 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 { 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 `\t`. Preferimos la dereferenciada `^{}` (commit real del tag anotado). let mut plain: Option = 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 = ""` (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::>() .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 = 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::>().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::>().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 = 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 (`-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> { 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 = 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 = 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, ExportStats) { use base64::{engine::general_purpose::STANDARD, Engine as _}; use std::collections::BTreeMap; // Paso 1: dedup respetando Delete. let mut last_by_path: BTreeMap = 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). Los paths se ordenarán antes de // emitir, para que el target_bin sea determinista (importante para tests). let mut groups: BTreeMap)> = BTreeMap::new(); let mut fallback: Vec<(PathBuf, &hammer_journal::MutationEvent)> = Vec::new(); let mut artifacts_seen: std::collections::BTreeSet = std::collections::BTreeSet::new(); let mut artifacts_without_recipe: std::collections::BTreeSet = 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 = 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 = 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 { 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, 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 { 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 { 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 { 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 { 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/`. 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 { 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()); } }