Files
takana/crates/takana-cli/src/main.rs
T
Sergio 1c3e185167 takana: las variables de entorno leen las dos formas, gana la nueva
TAKANA_X con caída a HAMMER_X (ADR 0016). No es un sed: estas variables son
contrato de usuario —knobs del instalador, el entorno del worker, mirror-env.sh—
y viven en perfiles de shell y units FUERA del repo, así que un renombre duro no
falla ruidosamente: la variable no aparece, se toma el default y el build se
comporta distinto sin que nada lo diga.

Rust: takana_core::env::{var,var_os} toma el nombre canónico y deriva el viejo
cambiando el prefijo — se le pasa TAKANA_* para que un grep del nombre nuevo
encuentre todas las lecturas. 5 tests, con DOS controles negativos: sin ninguna
de las dos no hay valor, y un nombre sin prefijo no inventa una caída.
Migrados los 17 sitios directos y los indirectos que el grep no mostraba
(bases_de_mirror, las constantes de kernel_cmd, ROOT_ENV de qorpa, env_path de
recover). takana-recover lleva la caída inline: es un mini-binario que se copia
a /usr/sbin y no vale arrastrarle una dep entera por dos líneas.

Scripts: 30 lecturas pasan a default, manteniendo el
nombre INTERNO de la variable para no tocar sus 190 usos.

Y donde el script EXPORTA en vez de leer, se ponen LAS DOS (mirror-env.sh y el
fragmento in-VM de bootstrap): ahí el lector puede ser un binario viejo —worker
sin recompilar, el /usr/bin/hammer pinado del baseline— que sólo conoce HAMMER_*.
La caída sirve para lectores nuevos; los viejos necesitan que la vieja siga puesta.

Verificado de punta a punta con el binario, no sólo con unit tests: HAMMER_LAB
sigue surtiendo efecto, TAKANA_LAB hace lo mismo, y con las dos gana TAKANA_LAB.
605 tests en verde y el hash de zlib sigue en b3:dc363f26… , intacto.

Cambio de comportamiento que va aparte y hay que decir: el hostname por defecto
de una instalación nueva pasa de 'hammer' a 'takana' (sólo si no se fija ninguna
de las dos variables).

NO se tocan: las rutas /var/lib/hammer de sistemas instalados, el volid
HAMMER_LIVE del ISO, ni el namespace HARKAQ_*, que es de otro subsistema.
2026-09-09 19:14:49 +00:00

3610 lines
157 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `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 qorpa;
mod qorpa_wl;
mod nix_import;
const DEFAULT_STORE: &str = "/store";
const DEFAULT_REPO: &str = "/var/lib/hammer/repo";
#[derive(Parser)]
#[command(
name = "takana",
version,
about = "Laboratorio funcional en el sótano, terminal mutable arriba.",
long_about = "takana: la forja determinista de tawasuyu — 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.
///
/// `forja` es alias de marca (ADR 0016): el verbo CANÓNICO sigue siendo `build` —
/// es el que usan los 124 llamadores, el cron y los runbooks. El alias existe para
/// que la invocación de la hoja de marca (`takana forja …`) funcione, no para
/// reemplazarlo.
#[command(alias = "forja")]
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/<hash>-<nombre>`) 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<PathBuf>,
/// Raíz donde viven los manifiestos y los upper/work. Default `/var/lib/hammer/overlays`.
#[arg(long)]
state_root: Option<PathBuf>,
},
/// [Fase 2+3] Fusiona un overlay al FHS real y desmonta. Por defecto registra cada
/// archivo promocionado en el diario; usa `--no-journal` para saltarse el registro.
Commit {
id: String,
#[arg(long)]
state_root: Option<PathBuf>,
/// Directorio del diario donde anotar la promoción.
#[arg(long, default_value = "/var/lib/hammer/journal")]
journal: PathBuf,
/// Saltarse el registro en el diario (útil para experimentos efímeros).
#[arg(long)]
no_journal: bool,
},
/// [Fase 2] Desmonta un overlay y borra su upper. El sistema vuelve al estado base.
Discard {
id: String,
#[arg(long)]
state_root: Option<PathBuf>,
},
/// [Fase 2] Lista los overlays activos según los manifiestos persistidos.
Status {
#[arg(long)]
state_root: Option<PathBuf>,
},
/// [Fase 3] Ver/seguir el diario de mutaciones.
Journal {
/// Directorio del diario (mismo que `hammerd --journal`).
#[arg(long, default_value = "/var/lib/hammer/journal")]
dir: PathBuf,
/// Muestra los últimos N eventos. Default: 20.
#[arg(long, short, default_value = "20")]
tail: usize,
/// Sigue el archivo: imprime nuevos eventos según aparecen.
#[arg(long, short)]
follow: bool,
/// Formato. `pretty` (humano, default) o `json` (raw passthrough).
#[arg(long, default_value = "pretty")]
format: String,
},
/// [Fase 4] Aplica un manifiesto .swm. Por defecto abre un overlay sobre el FHS y aplica
/// las mutaciones ahí; con `--prefix DIR` opera directamente en `DIR` sin overlay
/// (útil para tests offline o staging en otro filesystem).
Apply {
file: String,
/// Si está, re-rootea todas las rutas absolutas del .swm bajo este prefix y NO abre
/// overlay. `--prefix /mnt/foo` ⇒ `/etc/network.conf` → `/mnt/foo/etc/network.conf`.
#[arg(long)]
prefix: Option<PathBuf>,
/// Si está, salta source_patch silenciosamente. Útil para probar el flujo de
/// config_edit/file_drop sin lab disponible.
#[arg(long)]
skip_source_patch: bool,
/// Tras aplicar, intenta verificar pin contra base local de `--base-ref`.
#[arg(long)]
base_ref: Option<PathBuf>,
/// Raíz del overlay (sólo en modo overlay).
#[arg(long)]
state_root: Option<PathBuf>,
},
/// [Fase 4] Verifica un .swm: schema, base (si se da `--base-ref`), el hash de cada
/// `file_drop` inline y, con `--trust DIR`, la firma Ed25519 contra el TrustStore local.
SwmVerify {
file: String,
#[arg(long)]
base_ref: Option<PathBuf>,
/// Directorio de claves de confianza (`*.ed25519.pub`). Default:
/// /var/lib/hammer/trust. La firma reporta estado, NO autoriza promover (ver SDD 09).
#[arg(long)]
trust: Option<PathBuf>,
/// [H1] Además del schema/firma, REPRODUCE cada paquete source_patch y corre su bloque
/// `evidence` (proof-carrying recipes) en el sandbox. Falla ⇒ exit != 0 (no proponer).
#[arg(long)]
evidence: bool,
},
/// [Fase 4] Genera un par de claves Ed25519 para firmar `.swm`. Escribe `<name>.ed25519`
/// (privada, 0600) y `<name>.ed25519.pub` en `--out` (default: /var/lib/hammer/trust).
Keygen {
/// Nombre de la identidad (autoría). Será el `by` de las firmas.
name: String,
#[arg(long)]
out: Option<PathBuf>,
},
/// [Fase 4] Firma un `.swm` con una clave privada y escribe el manifiesto firmado.
SwmSign {
file: String,
/// Ruta a la clave privada `<name>.ed25519`.
#[arg(long)]
key: PathBuf,
/// Autoría declarada (`signature.by`). Default: el basename de la clave sin sufijo.
#[arg(long)]
by: Option<String>,
/// Destino. Si se omite, sobrescribe el propio `file` in-place.
#[arg(long, short)]
out: Option<PathBuf>,
},
/// [Fase 4] Exporta el diario actual como manifiesto .swm a stdout.
/// Mutaciones trazables ⇒ file_drop con content_b64 y hash. Mutaciones opacas ⇒
/// mismas pero con `note` indicando el origen (warning legible para el receptor).
Export {
/// Archivo JSON con la base local; se inserta como `base` en el .swm. Si no se da,
/// se emite una base con `distro_version: "unknown"` y se imprime un warning.
#[arg(long)]
base_ref: Option<PathBuf>,
/// Directorio del diario.
#[arg(long, default_value = "/var/lib/hammer/journal")]
journal: PathBuf,
/// Sólo eventos a partir de este timestamp (RFC 3339). Si no se da, exporta todo.
#[arg(long)]
since: Option<String>,
},
/// [Etapa F] Empaqueta una receta del corpus (`recipes/foo.toml`) como un `.swm` de un
/// único `source_patch`: la dirección **forward** que vuelve una receta que el sistema ya
/// sabe construir un paquete distribuible y reproducible-desde-fuente. Inversa de `apply`.
Pack {
/// Ruta a la receta a empaquetar (p. ej. `recipes/ripgrep.toml`).
recipe: PathBuf,
/// Ancla de sanity: ruta absoluta del binario que debe existir tras hidratar el
/// artefacto. Default `/usr/bin/<name>`. (apply hidrata el artefacto ENTERO; esto sólo
/// verifica que algo esperado quedó en su sitio.)
#[arg(long)]
target_bin: Option<String>,
/// Archivo `.swm` de salida. Sin esto (ni `--repo`), se imprime a stdout.
#[arg(long, short)]
out: Option<PathBuf>,
/// Publica el paquete en este repositorio: escribe `<repo>/<name>-<version>.swm` y
/// actualiza `<repo>/index.json` (upsert por nombre). Compatible con `--out`.
#[arg(long)]
repo: Option<PathBuf>,
/// `distro_version` de la base del paquete (la base local del receptor debe coincidir).
#[arg(long, default_value = "dev")]
distro_version: String,
/// `expected_hash` declarado (`b3:…`) — el ancla de "verificar, no confiar". Si se omite
/// y no se pasa `--build`, el paquete sale sin ancla (el receptor reproduce igual, pero
/// no hay hash autor contra el que comparar).
#[arg(long)]
expected: Option<String>,
/// Construir la receta AHORA en el lab para sellar el `expected_hash` real (necesita
/// store + sandbox). Mutuamente informativo con `--expected` (este último gana si ambos).
#[arg(long)]
build: bool,
/// Firmar el `.swm` resultante con esta clave privada Ed25519 (como `swm-sign`).
#[arg(long)]
sign: Option<PathBuf>,
},
/// [Etapa F] Instala un paquete por nombre desde un repositorio: resuelve `nombre` en el
/// índice, verifica la firma (con `--trust`) y la base, reproduce el `source_patch` desde
/// fuente y lo hidrata. Es `apply` con resolución por nombre — nunca corre un binario ajeno.
Install {
/// Nombre del paquete a instalar (clave del índice del repo).
name: String,
/// Repositorio: un directorio local (con `index.json`) o una LISTA de orígenes HTTP(S)
/// separada por comas (`https://a,https://b`), que se prueban en orden. Los `.swm` se
/// verifican contra el `digest` del índice firmado, así que un origen no necesita confianza.
#[arg(long, default_value = DEFAULT_REPO)]
repo: String,
/// Re-rootea las mutaciones bajo este prefix (tests/staging) en vez de abrir overlay.
#[arg(long)]
prefix: Option<PathBuf>,
/// Verifica el `source_patch` y la base, pero NO construye ni hidrata (dry-run de schema).
#[arg(long)]
skip_source_patch: bool,
/// Base local (JSON) contra la que validar la base del paquete. Sin esto no se chequea.
#[arg(long)]
base_ref: Option<PathBuf>,
/// Directorio de claves de confianza para validar la firma del `.swm`. Sin esto, la
/// firma se informa pero no bloquea (igual que `swm-verify` sin `--trust`).
#[arg(long)]
trust: Option<PathBuf>,
/// Raíz de estado para el overlay (modo sin `--prefix`).
#[arg(long)]
state_root: Option<PathBuf>,
/// Base de datos de instalados donde registrar el paquete (para `uninstall`).
#[arg(long, default_value = takana_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 = takana_core::installed::DEFAULT_DB)]
db: PathBuf,
},
/// [Etapa F] Lista los paquetes instalados (de la DB).
Installed {
#[arg(long, default_value = takana_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 = takana_core::installed::DEFAULT_DB)]
db: PathBuf,
},
/// [Etapa G] Importa una receta DESDE nixpkgs: toma el JSON normalizado que produce
/// `scripts/nix-import.sh` (vía `nix eval`) y emite una receta hammer (`.toml`). Trae la
/// RECETA (source+hash+deps+flags), nunca el binario del cache de nix — hammer reconstruye.
/// Es un PUNTO DE PARTIDA: el build (zig/musl) suele necesitar adaptación por paquete.
ImportNix {
/// JSON normalizado del paquete nix. `-` o ausente ⇒ lee de stdin.
#[arg(default_value = "-")]
file: String,
/// Receta de salida (`.toml`). Sin esto, imprime a stdout.
#[arg(long, short)]
out: Option<PathBuf>,
},
/// [Etapa G] Importa una receta DESDE un APKBUILD de Alpine (aports). Crucial: carga los
/// `.patch` de musl de Alpine — lo que un import de nix pierde — así está MUCHO más cerca de
/// compilar en el lab estático de hammer. `scripts/alpine-import.sh <pkg>` baja el APKBUILD.
ImportAlpine {
/// El APKBUILD (texto). `-` o ausente ⇒ stdin.
#[arg(default_value = "-")]
file: String,
/// Receta de salida (`.toml`). Sin esto, imprime a stdout.
#[arg(long, short)]
out: Option<PathBuf>,
},
/// [Etapa G Fase 2] Ancla el `commit` de una receta git: si es un TAG flotante (v1.0.0),
/// lo resuelve al SHA inmutable vía `git ls-remote` (host-agnóstico, sin API ni tokens) y
/// reescribe la receta. El sello determinista al estilo Nix (ADR 0006). No-op si ya es un SHA.
Pin {
/// Receta a anclar (`recipes/foo.toml`).
recipe: PathBuf,
/// Salida. Sin esto, reescribe la receta IN-PLACE.
#[arg(long, short)]
out: Option<PathBuf>,
},
/// [Etapa F] Inspecciona un repositorio de paquetes.
Repo {
#[command(subcommand)]
sub: RepoCmd,
},
/// [Fase 6] Ejecuta el bucle agéntico: traduce una intención NL a un .swm vía catálogo
/// mock y la aplica a un overlay (o prefix) sin promover al FHS. Imprime el Proposal.
Ai {
/// La intención en lenguaje natural. En modo mock (`--catalog`), debe coincidir
/// exactamente con una entrada del catálogo; en modo `--llm`, es texto libre.
intent: String,
/// Catálogo YAML de intent → swm (MockTranslator). Mutuamente excluyente con `--llm`.
#[arg(long)]
catalog: Option<PathBuf>,
/// Activa el traductor LLM real (Claude API). Requiere `ANTHROPIC_API_KEY` en el env
/// y un binario compilado con `--features llm-claude`. Modelo por defecto:
/// `claude-opus-4-8` (sobreescribible con `TAKANA_LLM_MODEL`).
#[arg(long)]
llm: bool,
/// Re-rootea las mutaciones bajo este prefix en vez de abrir overlay. Útil para
/// dev/CI sin root ni overlayfs.
#[arg(long)]
prefix: Option<PathBuf>,
/// Base local (JSON). Si está, el orquestador verifica `verify_base` contra ella.
#[arg(long)]
base_ref: Option<PathBuf>,
/// Socket de hammerd. Si está + el .swm trae source_patch, el orquestador llama
/// Compile por el bus. Si no, source_patch se omite con warning.
#[arg(long)]
bus: Option<PathBuf>,
/// Raíz de overlays para `try`. Default coincide con `hammer try`.
#[arg(long)]
state_root: Option<PathBuf>,
/// Si está, activa el bucle de auto-reparación: tras aplicar, espera en el bus
/// de eventos (mismo socket que `--bus` si no se da otro) un evento `Crashed`
/// durante `--repair-window-ms`. Si llega, traduce `(service, code)` a una nueva
/// intención y reaplica, hasta este máximo de intentos. Sin `--repair-max-attempts`,
/// el bucle agéntico corre una sola vez (compatible hacia atrás).
#[arg(long)]
repair_max_attempts: Option<u32>,
/// Ventana de espera por un Crashed tras cada intento. Default: 5000 ms.
#[arg(long, default_value = "5000")]
repair_window_ms: u64,
},
/// [Fase 5] Envía un comando al FIFO de control humano del init.
/// Por defecto: /run/init.control. Equivalente a `echo "<line>" > /run/init.control`,
/// pero con un mensaje de error claro si el FIFO no existe.
Ctl {
/// La línea a enviar (p. ej. "start web", "restart network").
line: String,
#[arg(long, default_value = "/run/init.control")]
fifo: PathBuf,
},
/// [Fase 6] Evalúa una expresión del mini-lenguaje (SDD 08 §6) contra el sistema local.
/// Forma: `kind:value`. Kinds soportados: `bin`, `file`, `pin`, `service`, `depends`.
/// Ejemplos:
/// hammer query bin:grep
/// hammer query file:/etc/hosts
/// hammer query depends:/usr/bin/curl
/// hammer query pin:musl --base-ref base.json
Query {
/// La expresión a evaluar.
expr: String,
/// Base local (JSON) para resolver `pin:<name>`. Opcional.
#[arg(long)]
base_ref: Option<PathBuf>,
/// Re-rootea las rutas absolutas bajo este prefix (útil para probar contra un overlay
/// o un prefix de test sin tocar el FHS real).
#[arg(long)]
fs_root: Option<PathBuf>,
},
/// [Track posterior] Bootstrap from-scratch (SDD 11). Encadena el toolchain semilla y el
/// userland mínimo hacia el auto-alojamiento.
Bootstrap {
#[command(subcommand)]
sub: BootstrapCmd,
},
/// [Etapa E3] Mirror del store content-addressed: replica artefactos por hash entre máquinas,
/// verificando `of_tree` (BLAKE3) en recepción. `--store` es el store local.
Mirror {
#[command(subcommand)]
sub: MirrorCmd,
},
/// [Etapa E4] Upgrades atómicos con rollback: aplica un árbol Stage1/producto del store al root
/// vivo dejando una generación nueva; `rollback` revierte exactamente al árbol anterior.
Upgrade {
/// Root del FHS vivo sobre el que se proyecta el árbol (default `/`).
#[arg(long, default_value = "/", global = true)]
root: String,
/// Directorio de estado de generaciones (default `/var/lib/hammer/upgrades`).
#[arg(long, default_value = takana_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 = takana_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,
},
/// [ADR 0015] Imágenes ajenas: rootfs de otras distros, pineados por digest, que corren
/// enjaulados sobre el mismo kernel. **No entran al store** — no reproducen desde fuente, y
/// meterlos ahí haría que el store mienta.
Qorpa {
#[command(subcommand)]
sub: qorpa::QorpaCmd,
},
}
#[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 = takana_upgrade::boot_graph::BOOT_GRAPH_PATH)]
out: PathBuf,
/// En vez de escribir el fichero, imprime el grafo por stdout (para inspección/tests).
#[arg(long)]
stdout: bool,
},
/// Activa el nodo `<id>` del grafo (deja el sistema en ese estado). Con `--from-select` lee el id
/// que mirada dejó en `/run/hammer/boot-select` en vez de pasarlo por argumento.
///
/// `--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<String>,
/// 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 = takana_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 = takana_upgrade::boot_graph::BOOT_GRAPH_PATH)]
out: PathBuf,
/// Path del canal de vuelta donde el compositor deja el id elegido.
#[arg(long, default_value = takana_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<String>,
},
/// Verifica la firma del release del repositorio contra las claves de confianza.
Verify {
#[arg(long, default_value = DEFAULT_REPO)]
repo: PathBuf,
/// Directorio de claves de confianza. Default `/var/lib/hammer/trust`.
#[arg(long)]
trust: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum UpgradeCmd {
/// Aplica el árbol `<tree-dir>` (`<hash>-<name>` del store) al root vivo, dejando una generación.
Apply {
/// Directorio del artefacto en el store (p.ej. `b3hex…-product-rootfs`).
tree_dir: String,
/// `of_tree` esperado (`b3:…`) — de un índice de mirror o release firmada; si se da, se exige.
#[arg(long)]
expected: Option<String>,
},
/// Revierte la generación viva al árbol anterior (o al estado pre-upgrades).
Rollback,
/// Completa (o deshace con --rollback) un apply interrumpido por un corte/reinicio (intento pendiente).
Recover {
/// Deshace el apply a medias (vuelve al estado pre-apply) en vez de completarlo.
#[arg(long)]
rollback: bool,
},
/// Muestra la generación viva y el historial.
Status,
/// Borra generaciones que el rollback ya no alcanza (huérfanas tras un rollback). Recupera espacio.
Prune {
/// Recorta también la cadena viva a sus N generaciones más nuevas (limita la profundidad de
/// rollback). Sin flag, conserva toda la cadena y borra sólo las huérfanas.
#[arg(long)]
keep: Option<usize>,
},
}
#[derive(Subcommand)]
enum MirrorCmd {
/// Replica al store REMOTO los artefactos que le faltan (verificados antes de sellar).
Push {
/// Ruta del store remoto destino (puede ser un montaje de red/sshfs).
remote: String,
},
/// Trae del store REMOTO los artefactos que le faltan al local (verificados antes de sellar).
Pull {
/// Ruta del store remoto origen.
remote: String,
},
/// Muestra el diff entre el store local y uno remoto (qué falta en cada lado, conflictos). No copia.
Status {
/// Ruta del store remoto a comparar.
remote: String,
},
}
#[derive(Subcommand)]
enum BootstrapCmd {
/// [Stage 0] Ingiere un toolchain semilla pinned al store. Verifica el sha256 antes de
/// extraer y lo sella; idempotente si ya está. Imprime el hash del artefacto.
Stage0 {
/// URL del tarball del toolchain (cualquier esquema de curl; `file://` para offline).
#[arg(long)]
url: String,
/// sha256 hex del tarball, fijado. Se verifica antes de sellar.
#[arg(long)]
sha256: String,
/// Versión de la semilla (entra en la identidad/hash del artefacto).
#[arg(long)]
version: String,
/// Clase de semilla.
#[arg(long, default_value = "zig")]
seed: String,
},
/// [Stage 1] Cross-compila el userland mínimo (musl + busybox) con la semilla de Stage 0,
/// ensambla un rootfs FHS y lo sella. Imprime el hash del rootfs.
Stage1 {
/// Hash de la semilla ya sellada por Stage 0 (con o sin prefijo `b3:`).
#[arg(long)]
seed_hash: String,
/// Clase de semilla (debe coincidir con la de Stage 0).
#[arg(long, default_value = "zig")]
seed: String,
/// Directorio de recetas (`musl.toml`, `busybox.toml`).
#[arg(long, default_value = "recipes")]
recipes: String,
},
/// [Producto] Capa de SERVICIOS: toma el rootfs base verificado del 4/4 (`--rootfs`, el que
/// imprimió `stage1`) e inyecta por hidratación tardía los componentes de servicio (sshd/openssh
/// + netup) y sus configs, sellando un `product-rootfs` APARTE. El mecanismo base no se toca (su
/// `of_tree` sigue blindado para el selfhost-verify). Separación Mecanismo/Política.
Product {
/// Hash del rootfs base de Stage 1 (el que imprimió `stage1`; con o sin `b3:`).
#[arg(long)]
rootfs: String,
/// Directorio de recetas (`openssh.toml`, `netup.toml`, …).
#[arg(long, default_value = "recipes")]
recipes: String,
/// Produce además el producto ATESTADO (`product-attested-rootfs`): hidrata el init CON gate
/// (`arje-zero-attest`) y FIRMA la seed con `arje-packager`. Imprime el hash del atestado.
#[arg(long)]
attest: bool,
/// Política del gate de atestación: `halt` (default) | `degraded` | `warn`. Sólo con `--attest`.
#[arg(long, default_value = "halt")]
policy: String,
/// Fichero con la rootkey de 32 bytes raw para firmar. Sin él se usa la rootkey de desarrollo
/// determinista (firmas reproducibles). Sólo con `--attest`.
#[arg(long)]
rootkey: Option<String>,
/// [Dogfood] Arma el userland REPRODUCIÉNDOLO desde un REPO FIRMADO (dir local) en vez de las
/// recetas locales: verifica la firma del release y reconstruye cada paquete desde su `.swm`
/// comparando el `expected_hash`. El `product-rootfs` sale bit-idéntico al de la vía local (hash
/// por-contenido). Requiere `--trust`. La cadena de suministro de la propia imagen.
#[arg(long)]
from_repo: Option<String>,
/// Directorio de claves de confianza para verificar la firma del release del `--from-repo`.
#[arg(long)]
trust: Option<String>,
},
/// [Stage 2] Ancla el content-hash del rootfs de Stage 1 como referencia de reproducibilidad
/// (anota la línea del manifiesto). Con `--verify <hash>` compara contra un rebuild `stage1'`
/// (producido en la VM) y emite el veredicto de auto-alojamiento.
Stage2 {
/// Hash del rootfs de Stage 1 (el que imprimió `stage1`; con o sin `b3:`).
#[arg(long)]
rootfs: String,
/// Content-hash de un rebuild `stage1'` para comparar. Sin él, sólo ancla la referencia.
#[arg(long)]
verify: Option<String>,
},
/// [Stage 2 · builder] Ensambla el **builder rootfs** (Stage 1 + toolchain) que, booteado en la
/// VM, reconstruye `stage1'` con sus propias herramientas (auto-alojamiento, SDD 11 §7). Variante
/// pragmática: el toolchain entra desde `.dev-fs/alpine`.
Builder {
/// Hash del rootfs de Stage 1 (el que imprimió `stage1`; con o sin `b3:`).
#[arg(long)]
stage1: String,
/// Hash de la semilla sellada por Stage 0 (con o sin `b3:`).
#[arg(long)]
seed_hash: String,
/// Clase de semilla (debe coincidir con la de Stage 0).
#[arg(long, default_value = "zig")]
seed: String,
/// Directorio de recetas a embeber.
#[arg(long, default_value = "recipes")]
recipes: String,
/// Binario `hammer` estático a instalar en `/usr/bin/hammer`.
#[arg(long, default_value = "target/x86_64-unknown-linux-musl/release/hammer")]
hammer_bin: String,
/// Toolchain del sandbox de build → `/toolchain` (rootfs Alpine de `.dev-fs`).
#[arg(long, default_value = ".dev-fs/alpine")]
toolchain: String,
/// Etiqueta de identidad del toolchain (entra al hash lógico del builder).
#[arg(long, default_value = "alpine")]
toolchain_tag: String,
/// Content-hash `of_tree(stage1)` (referencia anclada por `stage2`) para que el rebuild se
/// auto-verifique en la VM. Sin él, el driver sólo imprime el content-hash de `stage1'`.
#[arg(long)]
ref_content: Option<String>,
/// Caché de fuentes (`work/`) a embeber en `/work` para un rebuild offline.
#[arg(long)]
work_cache: Option<String>,
/// Pieza del toolchain construida por hammer desde fuente (variante b, SDD 11 §7.2b) que pisa
/// la de Alpine en `/toolchain`. Formato `name=hash[:rel_path]`; `rel_path` por defecto
/// `usr/bin/<name>`. Repetible. Ej.: `--swap make=fbad44ac…`. Cada swap entra al hash lógico.
#[arg(long = "swap", value_name = "name=hash[:rel_path]")]
swaps: Vec<String>,
/// Dónde ensamblar el builder (se reensambla limpio en cada corrida).
#[arg(long, default_value = "work/builder-rootfs")]
out: String,
},
/// [Stage 0+1+2] Encadena las tres etapas (semilla → rootfs → referencia anclada) en una corrida
/// y deja el `bootstrap.json` poblado (SDD 11 §5). El veredicto `✓ REPRODUCIBLE` lo sella el
/// rebuild nativo dentro del rootfs (VM, `scripts/selfhost-verify.sh`), no esta corrida del host.
All {
/// URL del tarball del toolchain semilla (cualquier esquema de curl; `file://` para offline).
#[arg(long)]
url: String,
/// sha256 hex del tarball, fijado. Se verifica antes de sellar.
#[arg(long)]
sha256: String,
/// Versión de la semilla (entra en la identidad/hash del artefacto).
#[arg(long)]
version: String,
/// Clase de semilla.
#[arg(long, default_value = "zig")]
seed: String,
/// Directorio de recetas (`musl.toml`, `busybox.toml`, `hammerd.toml`, `arje-zero.toml`).
#[arg(long, default_value = "recipes")]
recipes: String,
},
/// Imprime el `bootstrap.json` del store: el log de transparencia (SDD 11 §4). Un tercero
/// reproduce el bootstrap y verifica que sus hashes coinciden con los de estas líneas.
Manifest,
}
/// Parsea la clase de semilla del flag `--seed`. Único punto de verdad para todas las subcomandos.
fn parse_seed_kind(s: &str) -> anyhow::Result<takana_bootstrap::SeedKind> {
match s {
"zig" => Ok(takana_bootstrap::SeedKind::Zig),
"musl-cross-make" => Ok(takana_bootstrap::SeedKind::MuslCrossMake),
other => anyhow::bail!("--seed debe ser 'zig' o 'musl-cross-make', no '{other}'"),
}
}
fn print_event(ev: &takana_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 {
takana_journal::Source::HammerHydrate { artifact } => format!("hydrate {artifact}"),
takana_journal::Source::HammerCommit { overlay, artifact } => match artifact {
Some(a) => format!("commit {overlay} ({a})"),
None => format!("commit {overlay}"),
},
takana_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 = takana_core::Store::open(&cli.store)?;
let recipe = takana_core::Recipe::load_from_path(&recipe)?;
let cfg = takana_build::BuildConfig::from_env_or_defaults(store.root());
let hash = takana_build::build(&recipe, &cfg, &store)?;
println!("{hash}");
}
Cmd::Hash { recipe, check } => {
let store = takana_core::Store::open(&cli.store)?;
let recipe = takana_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 = takana_build::BuildConfig::from_env_or_defaults(store.root());
let lab = takana_core::lab::LabFingerprint::from_rootfs(&cfg.rootfs)?;
let hash = takana_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/*-<n>` 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 = takana_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<String> = 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 = takana_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 = takana_core::Store::open(&cli.store)?;
let mode = match link.as_str() {
"static" => takana_core::LinkMode::Static,
"dynamic" => takana_core::LinkMode::Dynamic,
other => anyhow::bail!("--link debe ser 'static' o 'dynamic', no '{other}'"),
};
let artifact_dir = store.find_by_hash(&hash)?;
let report = takana_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(takana_overlay::DEFAULT_STATE_ROOT));
std::fs::create_dir_all(&root)?;
let id = takana_overlay::try_overlay(&targets, &root)?;
println!("{id}");
}
Cmd::Commit { id, state_root, journal, no_journal } => {
let root = state_root
.unwrap_or_else(|| PathBuf::from(takana_overlay::DEFAULT_STATE_ROOT));
let id = takana_overlay::OverlayId(id);
let report = if no_journal {
takana_overlay::commit(&id, &root)?
} else {
let j = takana_journal::Journal::open(&journal)?;
takana_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(takana_overlay::DEFAULT_STATE_ROOT));
takana_overlay::discard(&takana_overlay::OverlayId(id), &root)?;
println!("discard OK");
}
Cmd::Status { state_root } => {
let root = state_root
.unwrap_or_else(|| PathBuf::from(takana_overlay::DEFAULT_STATE_ROOT));
let overlays = takana_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 = takana_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 = takana_core::Store::open(&cli.store)?;
let spec = takana_bootstrap::SeedSpec { kind, version, url, sha256 };
let hash = takana_bootstrap::stage0(&spec, &store)?;
println!("{hash}");
}
BootstrapCmd::Stage1 { seed_hash, seed, recipes } => {
let seed_kind = parse_seed_kind(&seed)?;
let store = takana_core::Store::open(&cli.store)?;
let base_cfg = takana_build::BuildConfig::from_env_or_defaults(store.root());
let spec = takana_bootstrap::Stage1Spec {
seed_hash: takana_core::ArtifactHash::from_hex(seed_hash.trim_start_matches("b3:")),
seed_kind,
recipes_dir: std::path::PathBuf::from(recipes),
};
let hash = takana_bootstrap::stage1(&spec, &base_cfg, &store)?;
println!("{hash}");
}
BootstrapCmd::Product { rootfs, recipes, attest, policy, rootkey, from_repo, trust } => {
let store = takana_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 = takana_build::BuildConfig::from_env_or_defaults(store.root());
let base = takana_core::ArtifactHash::from_hex(rootfs.trim_start_matches("b3:"));
let recipes_dir = std::path::Path::new(&recipes);
let hash = if let Some(repo) = from_repo {
// [Dogfood] userland desde el repo FIRMADO (la imagen arma su userland por la cadena
// de suministro de paquetes, no por recetas locales). --attest se combina como capa
// posterior; acá producimos el product-rootfs base reproducido del repo.
if attest {
anyhow::bail!("--from-repo y --attest aún no se combinan; producí el base con --from-repo y atestá aparte");
}
let trust_dir = trust.ok_or_else(|| {
anyhow::anyhow!("--from-repo requiere --trust <dir> (verificar la firma del release)")
})?;
takana_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 = takana_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())
})?;
}
takana_bootstrap::product_attested(&base, &base_cfg, recipes_dir, &store, &cfg)?
} else {
takana_bootstrap::product(&base, &base_cfg, recipes_dir, &store)?
};
println!("{hash}");
}
BootstrapCmd::Stage2 { rootfs, verify } => {
let store = takana_core::Store::open(&cli.store)?;
let rootfs_hash =
takana_core::ArtifactHash::from_hex(rootfs.trim_start_matches("b3:"));
let report = takana_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 =
takana_core::ArtifactHash::from_hex(rebuilt.trim_start_matches("b3:"));
match takana_bootstrap::verify_against(&report, rb) {
takana_bootstrap::Reproducibility::Reproducible => {
println!("✓ REPRODUCIBLE: stage1' == stage1 (auto-alojado bit a bit)");
}
takana_bootstrap::Reproducibility::Divergent { stage1, rebuilt } => {
anyhow::bail!(
"✗ DIVERGENTE: stage1={stage1} rebuilt={rebuilt} — hay no-determinismo que cazar (SDD 09 §2)"
);
}
takana_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 = takana_core::Store::open(&cli.store)?;
let swaps = swaps.iter().map(|s| parse_swap(s)).collect::<anyhow::Result<Vec<_>>>()?;
let spec = takana_bootstrap::BuilderSpec {
stage1_rootfs: takana_core::ArtifactHash::from_hex(stage1.trim_start_matches("b3:")),
seed_hash: takana_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| takana_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 = takana_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 = takana_core::Store::open(&cli.store)?;
let base_cfg = takana_build::BuildConfig::from_env_or_defaults(store.root());
let spec = takana_bootstrap::SeedSpec { kind, version, url, sha256 };
let report = takana_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 = takana_core::Store::open(&cli.store)?;
let manifest = takana_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 = takana_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 = takana_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) = takana_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 = takana_journal::Journal::open(&journal)?;
let r = takana_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 = takana_journal::Journal::open(&journal)?;
let r = takana_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 = takana_journal::Journal::open(&journal)?;
let r = takana_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) = takana_upgrade::pending(&state_root)? {
println!("⚠ apply interrumpido pendiente (generación {}) — corré `hammer upgrade recover [--rollback]`", p.id);
}
let cur = takana_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 = takana_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 = takana_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 takana_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 <id> o usá --from-select para leerlo de {}",
select.display()),
};
let j = takana_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 = takana_journal::Journal::open(&journal)?;
activate_and_report(&root, &state_root, &j, &id, &out)?;
}
}
}
}
}
Cmd::Kernel { sub } => kernel_cmd::run(sub, &cli.store)?,
Cmd::Qorpa { sub } => qorpa::run(sub, std::path::Path::new(&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: &takana_journal::Journal,
id: &str,
graph_out: &std::path::Path,
) -> anyhow::Result<()> {
use takana_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: &takana_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<takana_core::Swm> {
let yaml = std::fs::read_to_string(file)
.map_err(|e| anyhow::anyhow!("no pude leer {file}: {e}"))?;
takana_core::Swm::from_yaml(&yaml)
.map_err(|e| anyhow::anyhow!("no pude parsear {file}: {e}"))
}
/// Lee una `BaseRef` local desde JSON; usada por `apply` y `swm verify` para verificar la
/// compatibilidad de pins. Si la ruta no existe, devuelve `Ok(None)` para que el caller
/// pueda decidir entre fallar o seguir con un warning.
fn load_local_base(path: Option<&std::path::Path>) -> anyhow::Result<Option<takana_core::BaseRef>> {
let Some(p) = path else { return Ok(None) };
if !p.is_file() {
return Err(anyhow::anyhow!("base-ref {} no existe", p.display()));
}
let bytes = std::fs::read(p)?;
let base: takana_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: &takana_core::BaseCompat) {
use takana_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 takana_core::Mutation::FileDrop { content_b64: Some(b64), content_hash, path, .. } = m {
takana_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: &takana_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, takana_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 = takana_core::Store::open(store_path)?;
let cfg = takana_build::BuildConfig::from_env_or_defaults(store.root());
let catalog_dir = takana_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 takana_core::Mutation::SourcePatch { evidence, target_bin, .. } = m else {
continue;
};
if evidence.is_empty() {
continue;
}
let recipe =
takana_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 = takana_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: &takana_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 = takana_core::TrustStore::load(trust_dir)?;
let status = swm.verify_signature(&store);
match &status {
takana_core::SigStatus::Trusted { by } => {
println!("firma: trusted (by {by})");
}
takana_core::SigStatus::UnknownKey { by } => {
println!(
"firma: unknown-key (by {by}) — añade su clave a {} para confiar en la autoría",
trust_dir.display()
);
}
takana_core::SigStatus::BadSig { by, reason } => {
eprintln!("firma: BAD-SIG (by {by}): {reason}");
anyhow::bail!("la firma del .swm no valida");
}
takana_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 = takana_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 = takana_core::Recipe::load_from_path(recipe_path)
.map_err(|e| anyhow::anyhow!("no pude cargar la receta {}: {e}", recipe_path.display()))?;
// Las deps por NOMBRE viajan en el `source_patch` y en el índice; `install` resuelve el cierre
// desde el repo. Avisamos sólo para recordar que esas deps deben ESTAR publicadas en el mismo
// repo (si no, `install` fallará al resolver el catálogo).
if !recipe.deps.build.is_empty() {
eprintln!(
"nota: este paquete depende de {:?} — publicá esas deps en el mismo repo para que \
`install` pueda reproducirlo",
recipe.deps.build
);
}
// Texto de los patches: cada uno se resuelve relativo al directorio de la receta (igual que
// el lab) y se concatena. El `source_patch` del .swm modela un único `patch` inline; un diff
// unificado multi-fichero concatenado se aplica hunk-a-hunk igual (git apply / patch -p1).
let patch_text = if recipe.source.patches.is_empty() {
None
} else {
let mut buf = String::new();
for p in &recipe.source.patches {
let path = std::path::Path::new(p);
let abs = if path.is_absolute() {
path.to_path_buf()
} else {
recipe.base_dir.join(path)
};
let txt = std::fs::read_to_string(&abs)
.map_err(|e| anyhow::anyhow!("leyendo patch {}: {e}", abs.display()))?;
if !buf.is_empty() && !buf.ends_with('\n') {
buf.push('\n');
}
buf.push_str(&txt);
}
Some(buf)
};
// target_bin: `--target-bin` manda. Si no, y la receta Cargo declara `--bin <X>`, el binario
// instalado es X (no el nombre del paquete: ripgrep→rg, repgrep→rgr) ⇒ derivamos de ahí. Sólo
// si no hay `--bin`, caemos a `/usr/bin/{name}` (correcto para C y para name==bin).
let target_bin = target_bin.map(|s| s.to_string()).unwrap_or_else(|| {
let bin = recipe
.build
.flags
.windows(2)
.find(|w| w[0] == "--bin")
.map(|w| w[1].clone())
.unwrap_or_else(|| recipe.name.clone());
format!("/usr/bin/{bin}")
});
// expected_hash: `--expected` manda; si no, `--build` lo sella construyendo de verdad en el
// lab; si ninguno, el paquete sale sin ancla de verificación (válido, sólo más débil).
let expected_hash: Option<String> = if let Some(h) = expected {
Some(h.to_string())
} else if do_build {
let store = takana_core::Store::open(store_path)?;
let cfg = takana_build::BuildConfig::from_env_or_defaults(store.root());
let hash = takana_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 = takana_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 = takana_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).
// ADR 0016: los paquetes nuevos se escriben `.tkn`. Los repos existentes NO se
// rompen — `PackageEntry.file` guarda el nombre real de cada fichero, así que un
// índice con entradas `.swm` sigue resolviendo y un repo mixto es válido.
let file_name = format!("{}-{}.tkn", recipe.name, recipe.version);
std::fs::create_dir_all(repo_dir)?;
std::fs::write(repo_dir.join(&file_name), &yaml)?;
let mut index = takana_core::RepoIndex::load(repo_dir)?;
let orphan = index.upsert(takana_core::PackageEntry {
name: recipe.name.clone(),
version: recipe.version.clone(),
file: file_name,
distro_version: distro_version.to_string(),
expected_hash: expected_hash.clone(),
// El digest se sella al publicar y lo cubre la firma del índice: es lo que permite
// servir los `.swm` desde espejos que no controlamos (ADR 0014).
digest: Some(takana_core::repo::digest_swm(yaml.as_bytes())),
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 **lista** de orígenes HTTP(S) (el repo son
/// ficheros estáticos — `index.json` + los `.swm` —, así que cualquier servidor estático lo sirve).
///
/// # Por qué una lista y no un origen (ADR 0014)
///
/// Un solo origen es un punto único de fallo para toda la distro: si esa máquina o esa cuenta se
/// cae, nadie instala nada. Y no hace falta que sea uno: el índice va **firmado** y ancla el
/// `digest` de cada `.swm`, así que un origen no aporta confianza — sólo bytes. Se separan con
/// comas y se prueban en orden hasta que uno responda.
enum RepoSource {
Local(PathBuf),
Http(Vec<String>), // bases sin barra final, en orden de preferencia
}
impl RepoSource {
fn parse(s: &str) -> RepoSource {
let bases: Vec<String> = s
.split(',')
.map(str::trim)
.filter(|b| b.starts_with("http://") || b.starts_with("https://"))
.map(|b| b.trim_end_matches('/').to_string())
.collect();
if bases.is_empty() {
RepoSource::Local(PathBuf::from(s))
} else {
RepoSource::Http(bases)
}
}
fn label(&self) -> String {
match self {
RepoSource::Local(d) => d.display().to_string(),
RepoSource::Http(bases) => bases.join(", "),
}
}
/// Baja `rel` probando cada origen en orden. Devuelve los bytes **y el origen que los sirvió**:
/// decir "bajado de \<la lista entera\>" cuando sólo uno respondió es una media verdad que
/// esconde justo lo que hace falta para depurar un espejo caído.
///
/// El error que se propaga es el del ÚLTIMO intento, con la lista completa delante: un «404»
/// suelto no dice cuántos sitios se probaron.
fn fetch_desde_algun_origen<'a>(
bases: &'a [String],
rel: &str,
) -> anyhow::Result<(Vec<u8>, &'a str)> {
let mut ultimo = None;
for base in bases {
match takana_build::download::fetch_url_bytes(&format!("{base}/{rel}")) {
Ok(bytes) => return Ok((bytes, base.as_str())),
Err(e) => ultimo = Some(e),
}
}
Err(anyhow::anyhow!(
"ningún origen sirvió '{rel}' ({} probado(s): {}). Último error: {}",
bases.len(),
bases.join(", "),
ultimo
.map(|e| e.to_string())
.unwrap_or_else(|| "sin orígenes".into()),
))
}
/// Lee el índice del repo (local: `index.json`; http: `GET <base>/index.json`).
fn read_index(&self) -> anyhow::Result<takana_core::RepoIndex> {
match self {
RepoSource::Local(d) => Ok(takana_core::RepoIndex::load(d)?),
RepoSource::Http(bases) => {
let (bytes, origen) =
Self::fetch_desde_algun_origen(bases, takana_core::repo::INDEX_FILE)?;
serde_json::from_slice(&bytes)
.map_err(|e| anyhow::anyhow!("índice remoto de {origen}: {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.
///
/// **Cada `.swm` se verifica contra el `digest` del índice firmado antes de tocar el disco.**
/// Sin eso, la lista de orígenes de arriba sería un agujero y no una mejora: cualquiera de los
/// N espejos podría servir otro manifiesto bajo el mismo nombre de fichero.
fn materialize(
&self,
index: &takana_core::RepoIndex,
files: &[&str],
tmp_holder: &mut Option<tempfile::TempDir>,
) -> anyhow::Result<PathBuf> {
match self {
RepoSource::Local(d) => Ok(d.clone()),
RepoSource::Http(bases) => {
let tmp = tempfile::tempdir()?;
let dir = tmp.path().to_path_buf();
index.save(&dir)?; // index.json (con firma) re-cargable idéntico
let mut sin_digest = 0usize;
let mut servidores: Vec<String> = Vec::new();
for f in files {
let (bytes, origen) = Self::fetch_desde_algun_origen(bases, f)?;
let entry = index
.packages
.iter()
.find(|p| p.file == *f)
.ok_or_else(|| anyhow::anyhow!("'{f}' no está en el índice"))?;
// Contenido distinto del firmado ⇒ ABORTA, no pasa al siguiente origen. Un
// fallback aquí convertiría una manipulación en silencio: el paquete acabaría
// instalándose desde el espejo bueno y nadie se enteraría de que uno miente.
// Ausencia (404, host caído) sí es motivo para seguir; contenido distinto no.
takana_core::repo::verify_digest(entry, &bytes)?;
if entry.digest.is_none() {
sin_digest += 1;
}
if !servidores.iter().any(|s| s == origen) {
servidores.push(origen.to_string());
}
std::fs::write(dir.join(f), bytes)?;
}
eprintln!(
"repo: bajados {} .swm de {}",
files.len(),
servidores.join(", ")
);
if sin_digest > 0 {
// Se informa y no se bloquea (como el resto de verificaciones de la CLI), pero
// se dice el número: un índice sin digests NO es seguro de servir desde un
// espejo ajeno, y el silencio lo haría parecer verificado.
eprintln!(
"repo: ⚠ {sin_digest} de {} paquete(s) sin `digest` en el índice — \
sus bytes NO se verificaron; republicá el repo para sellarlos",
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 = takana_core::TrustStore::load(trust_dir)?;
match index.verify_signature(&store) {
takana_core::SigStatus::Trusted { by } => eprintln!("release: trusted (by {by})"),
takana_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());
}
}
takana_core::SigStatus::BadSig { by, reason } => {
anyhow::bail!("release: BAD-SIG (by {by}): {reason} — el índice fue manipulado")
}
takana_core::SigStatus::Unsigned => {
eprintln!("release: sin firmar");
if require_signed {
anyhow::bail!("--require-signed: el repo no tiene firma de release (firmalo con `hammer repo sign`)");
}
}
}
}
let entry = index.find(name).ok_or_else(|| {
let avail: Vec<&str> = index.packages.iter().map(|p| p.name.as_str()).collect();
anyhow::anyhow!(
"paquete '{name}' no está en el repo {}. Disponibles: {}",
source.label(),
if avail.is_empty() { "(ninguno)".into() } else { avail.join(", ") }
)
})?;
// Materializamos un repo LOCAL con los `.swm` que vamos a tocar (el target + su cierre de
// deps). Para un repo local es el propio directorio; para HTTP, baja esos `.swm` a un temporal.
// De ahí en más el flujo es idéntico (lee ficheros de `repo_dir`).
let closure = index.resolve_closure(name)?;
let needed: Vec<&str> = closure.iter().map(|p| p.file.as_str()).collect();
let mut _tmp_repo: Option<tempfile::TempDir> = None;
let repo_dir = source.materialize(&index, &needed, &mut _tmp_repo)?;
let repo_dir = repo_dir.as_path();
let swm_path = takana_core::RepoIndex::swm_path(repo_dir, entry);
eprintln!(
"install {} {}{}",
entry.name,
entry.version,
swm_path.display()
);
let swm_path_str = swm_path
.to_str()
.ok_or_else(|| anyhow::anyhow!("ruta de .swm no es UTF-8: {}", swm_path.display()))?;
// Verificación de firma ANTES de reproducir nada (sólo con --trust; sin él, se informa
// que no se verificó, como el resto de la CLI).
if trust.is_some() {
let swm = load_swm(swm_path_str)?;
print_sig_status(&swm, trust)?;
} else {
eprintln!("firma: no verificada (pasá --trust <dir> para validar la autoría)");
}
// Gate de compatibilidad (H4b/H4c — configs compatibles, SDD 15 §H4). Antes de reconstruir o
// tocar nada, evaluamos las superficies que el paquete tocará contra el estado INSTALADO. Es el
// chequeo local y reproducible de "cuando instalo, ¿es compatible con lo que ya tengo?".
let entrant_swm = load_swm(swm_path_str)?;
// H4c — vía OBSERVADA: los paths que el paquete escribirá (declarados en el .swm, sin que el
// autor prometa `slots`). Si alguno lo posee OTRO paquete instalado, es una colisión de fichero
// (elección, no error) — salvo --force-slots. Se conoce antes de hidratar ⇒ aborta sin tocar nada.
{
let idb = takana_core::InstalledDb::load(db_path)?;
let paths = takana_core::compat::output_paths(&entrant_swm);
let cols = takana_core::compat::path_collisions(&idb, name, &paths);
if !cols.is_empty() {
let detalle = cols
.iter()
.map(|c| format!("{} (de '{}')", c.path, c.propietario))
.collect::<Vec<_>>()
.join(", ");
if force_slots {
eprintln!("compat: {} colisión(es) de fichero: {detalle}; --force-slots ⇒ sobreescribo", cols.len());
} else {
anyhow::bail!(
"compat: COLISIÓN de fichero — '{name}' pisaría {detalle}. Es una elección: \
pasá --force-slots para sobreescribir el/los fichero(s) del otro paquete."
);
}
}
}
// H4e — vía OBSERVADA de los requisitos: por cada dep de runtime, la versión (expected_hash)
// que hay en el repo (contra la que el paquete se construyó). Si la tengo instalada a OTRO
// hash, la diverguí ⇒ rechazo duro (el caso wayland, derivado — no lo salva --force-slots).
{
let idb = takana_core::InstalledDb::load(db_path)?;
let req = takana_core::compat::observed_requires(&entrant_swm, &index);
let conflicts = takana_core::compat::version_conflicts(&idb, &req);
if !conflicts.is_empty() {
let d = conflicts
.iter()
.map(|c| format!("{} necesita {} pero tenés {}", c.dep, c.requerido, c.instalado))
.collect::<Vec<_>>()
.join("; ");
anyhow::bail!(
"compat: INCOMPATIBLE — '{name}' se construyó contra deps que divergiste: {d}. \
No encaja con tu sistema (rechazo duro, no lo fuerza --force-slots)."
);
}
}
// H4b — vía DECLARADA: las superficies (`slots`) que el autor declara reclamar/requerir.
let entrant_slots = source_patch_slots(&entrant_swm);
if !entrant_slots.is_empty() {
entrant_slots
.validate()
.map_err(|e| anyhow::anyhow!("slots del paquete inválidos: {e}"))?;
let idb = takana_core::InstalledDb::load(db_path)?;
let estado = idb.system_state();
match takana_core::compat::evaluar(&estado, &entrant_slots) {
takana_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.
takana_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.
takana_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 = takana_core::Store::open(store_path)?;
let cfg = takana_build::BuildConfig::from_env_or_defaults(store.root());
let catalog_dir = takana_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<&takana_core::PackageEntry> =
closure.into_iter().filter(|p| p.name != name).collect();
if !dep_pkgs.is_empty() {
eprintln!(
"deps: resolviendo catálogo para {} dep(s): {}",
dep_pkgs.len(),
dep_pkgs.iter().map(|p| p.name.as_str()).collect::<Vec<_>>().join(", ")
);
}
for dep in dep_pkgs {
let dep_swm_path = takana_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 = takana_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 = takana_core::InstalledDb::load(db_path)?;
let files: Vec<String> = created.iter().map(|p| p.to_string_lossy().into_owned()).collect();
let nfiles = files.len();
idb.record(takana_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.
takana_core::Recipe::from_toml(&toml)
.map_err(|e| anyhow::anyhow!("la receta importada no parsea (bug del importador): {e}"))?;
match out {
Some(dest) => {
std::fs::write(dest, &toml)?;
eprintln!("importada {}{}", pkg.pname, dest.display());
}
None => print!("{toml}"),
}
Ok(())
}
/// `true` si `s` ya es un SHA de git (40 hex sha1 o 64 hex sha256) ⇒ ya está anclado.
fn is_git_sha(s: &str) -> bool {
(s.len() == 40 || s.len() == 64) && s.bytes().all(|b| b.is_ascii_hexdigit())
}
/// Resuelve un tag/ref a su SHA de commit inmutable vía `git ls-remote` (cualquier host git, sin
/// API ni tokens). Para tags ANOTADOS prefiere el commit dereferenciado (`^{}`); para ligeros, el
/// ref directo. Devuelve error si el tag no existe en el remoto.
fn resolve_git_sha(repo: &str, tag: &str) -> anyhow::Result<String> {
let out = std::process::Command::new("git")
.args([
"ls-remote",
repo,
&format!("refs/tags/{tag}"),
&format!("refs/tags/{tag}^{{}}"),
tag,
])
.output()
.map_err(|e| anyhow::anyhow!("git ls-remote: {e}"))?;
if !out.status.success() {
anyhow::bail!("git ls-remote {repo} {tag}: {}", String::from_utf8_lossy(&out.stderr).trim());
}
let text = String::from_utf8_lossy(&out.stdout);
// Líneas `<sha>\t<ref>`. Preferimos la dereferenciada `^{}` (commit real del tag anotado).
let mut plain: Option<String> = None;
for line in text.lines() {
let mut it = line.split('\t');
let (Some(sha), Some(rf)) = (it.next(), it.next()) else { continue };
if rf.ends_with("^{}") {
return Ok(sha.to_string());
}
plain.get_or_insert_with(|| sha.to_string());
}
plain.ok_or_else(|| anyhow::anyhow!("'{tag}' no existe como tag/ref en {repo}"))
}
/// Ancla el `commit` de una receta git: tag flotante → SHA inmutable (Etapa G Fase 2).
fn run_pin(recipe_path: &std::path::Path, out: Option<&std::path::Path>) -> anyhow::Result<()> {
let text = std::fs::read_to_string(recipe_path)
.map_err(|e| anyhow::anyhow!("leyendo {}: {e}", recipe_path.display()))?;
let recipe = takana_core::Recipe::from_toml(&text)
.map_err(|e| anyhow::anyhow!("{}: {e}", recipe_path.display()))?;
let (repo, commit) = match recipe.source.kind() {
Ok(takana_core::SourceKind::Git { repo, commit }) => (repo.to_string(), commit.to_string()),
Ok(takana_core::SourceKind::Tarball { .. }) => {
eprintln!("{}: tarball (ya anclado por sha256) — nada que pinear", recipe.name);
return Ok(());
}
// Una receta derivada ya está anclada por el CONTENIDO de su árbol (el hash entra en
// `hash_inputs` calculado, no declarado) ⇒ no hay ref flotante que fijar.
Ok(takana_core::SourceKind::Dir { dir }) => {
eprintln!("{}: source.dir ({dir}) — anclado por contenido, nada que pinear", recipe.name);
return Ok(());
}
Err(e) => anyhow::bail!("{}: {e}", recipe.name),
};
if is_git_sha(&commit) {
eprintln!("{}: commit ya es SHA ({}) — ya anclado", recipe.name, &commit[..commit.len().min(12)]);
return Ok(());
}
let sha = resolve_git_sha(&repo, &commit)?;
// Reescritura dirigida: la línea `commit = "<tag>"` (no tocamos version u otras que casen).
let mut replaced = false;
let new_text: String = text
.lines()
.map(|l| {
if !replaced && l.trim_start().starts_with("commit") && l.contains(&format!("\"{commit}\"")) {
replaced = true;
l.replace(&format!("\"{commit}\""), &format!("\"{sha}\""))
} else {
l.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
let new_text = if text.ends_with('\n') { format!("{new_text}\n") } else { new_text };
if !replaced {
anyhow::bail!("no encontré la línea `commit = \"{commit}\"` para reescribir");
}
let dest = out.unwrap_or(recipe_path);
std::fs::write(dest, new_text)?;
eprintln!("{}: {commit}{sha} (anclado en {})", recipe.name, dest.display());
Ok(())
}
/// Importa una receta desde un APKBUILD de Alpine (Etapa G).
fn run_import_alpine(file: &str, out: Option<&std::path::Path>) -> anyhow::Result<()> {
let text = if file == "-" {
use std::io::Read;
let mut s = String::new();
std::io::stdin().read_to_string(&mut s)?;
s
} else {
std::fs::read_to_string(file).map_err(|e| anyhow::anyhow!("leyendo {file}: {e}"))?
};
let toml = alpine_import::recipe_from_apkbuild(&text).map_err(|e| anyhow::anyhow!(e))?;
takana_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 = takana_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 = takana_core::InstalledDb::load(db_path)?;
let estado = idb.system_state();
// Materializamos TODOS los `.swm` (para HTTP los baja; para local es el propio dir).
let all_files: Vec<&str> = index.packages.iter().map(|p| p.file.as_str()).collect();
let mut _tmp: Option<tempfile::TempDir> = None;
let repo_dir = source.materialize(&index, &all_files, &mut _tmp)?;
let (mut compatibles, mut elegibles, mut incompatibles) = (Vec::new(), Vec::new(), Vec::new());
for entry in &index.packages {
let swm_path = takana_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 = takana_core::compat::observed_requires(&swm, &index);
let vconf = takana_core::compat::version_conflicts(&idb, &req);
if !vconf.is_empty() {
let d = vconf.iter().map(|c| format!("{}@{} (tenés {})", c.dep, c.requerido, c.instalado)).collect::<Vec<_>>().join(", ");
incompatibles.push(format!("{:<16} deps divergidas: {d}", entry.name));
continue;
}
// 1b) Vía DECLARADA (slots): una incompatibilidad domina todo.
let slots = source_patch_slots(&swm);
let veredicto = takana_core::compat::evaluar(&estado, &slots);
if let takana_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 = takana_core::compat::output_paths(&swm);
let pcols = takana_core::compat::path_collisions(&idb, &entry.name, &paths);
if let takana_core::CompatVeredicto::Colision { slot, entrante, .. } = &veredicto {
elegibles.push(format!("{:<16} colisión de slot '{slot}' (quiere {entrante})", entry.name));
} else if !pcols.is_empty() {
let d = pcols.iter().map(|c| format!("{} (de '{}')", c.path, c.propietario)).collect::<Vec<_>>().join(", ");
elegibles.push(format!("{:<16} colisión de fichero: {d}", entry.name));
} else {
compatibles.push(entry.name.clone());
}
}
println!(
"compat: {} paquete(s) en {} contra {} superficie(s) instalada(s)",
index.packages.len(),
source.label(),
estado.len()
);
println!("\ncompatibles ({}):", compatibles.len());
for n in &compatibles {
println!(" ✓ {n}");
}
println!("\nrequieren elección ({}) — pasá `install --force-slots`:", elegibles.len());
for n in &elegibles {
println!(" ~ {n}");
}
println!("\nincompatibles ({}) — descartar:", incompatibles.len());
for n in &incompatibles {
println!(" ✗ {n}");
}
Ok(())
}
/// Desinstala un paquete: borra los ficheros que registró (salvo los que otro paquete instalado
/// también aporta — refcount) y quita su entrada de la DB. NO deshace `config_edit` (modifican
/// ficheros ajenos, no creados por el paquete).
fn run_uninstall(name: &str, db_path: &std::path::Path) -> anyhow::Result<()> {
let mut idb = takana_core::InstalledDb::load(db_path)?;
let Some(pkg) = idb.find(name).cloned() else {
let avail: Vec<&str> = idb.packages.iter().map(|p| p.name.as_str()).collect();
anyhow::bail!(
"'{name}' no está instalado (DB {}). Instalados: {}",
db_path.display(),
if avail.is_empty() { "(ninguno)".into() } else { avail.join(", ") }
);
};
let (mut removed, mut kept) = (0usize, 0usize);
let mut touched_dirs: Vec<PathBuf> = Vec::new();
for f in &pkg.files {
// Refcount: no borramos un fichero que otro paquete instalado también aporta.
if idb.owned_by_others(name, f) {
kept += 1;
continue;
}
match std::fs::remove_file(f) {
Ok(()) => {
removed += 1;
if let Some(parent) = std::path::Path::new(f).parent() {
touched_dirs.push(parent.to_path_buf());
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} // ya no estaba
Err(e) => eprintln!(" aviso: no pude borrar {f}: {e}"),
}
}
// Recoge los directorios que quedaron vacíos por el borrado (y sus padres, hacia arriba):
// `remove_dir` sólo borra un dir VACÍO ⇒ se detiene solo al toparse con uno que aún tiene
// contenido de otros paquetes. Procesamos de más profundo a menos para vaciar de abajo arriba.
let mut pruned = 0usize;
touched_dirs.sort_by_key(|d| std::cmp::Reverse(d.components().count()));
touched_dirs.dedup();
for dir in touched_dirs {
let mut cur = dir;
while std::fs::remove_dir(&cur).is_ok() {
pruned += 1;
match cur.parent() {
Some(p) if !p.as_os_str().is_empty() => cur = p.to_path_buf(),
_ => break,
}
}
}
idb.remove(name);
idb.save(db_path)?;
eprintln!(
"desinstalado {name} {}{removed} fichero(s) borrado(s){}{}",
pkg.version,
if kept > 0 { format!(", {kept} conservado(s) (los aporta otro paquete)") } else { String::new() },
if pruned > 0 { format!(", {pruned} dir(s) vacío(s) retirado(s)") } else { String::new() },
);
Ok(())
}
/// Devuelve la primera mutación `source_patch` de un `.swm` empaquetado (el caso de `pack`).
fn source_patch_of(swm: &takana_core::Swm) -> Option<&takana_core::Mutation> {
swm.mutations
.iter()
.find(|m| matches!(m, takana_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: &takana_core::Swm) -> takana_core::Slots {
match source_patch_of(swm) {
Some(takana_core::Mutation::SourcePatch { slots, .. }) => slots.clone(),
_ => takana_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 = takana_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 = takana_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 = takana_core::RepoIndex::load(repo_dir)?;
let trust_dir = trust.unwrap_or_else(|| std::path::Path::new(DEFAULT_TRUST_DIR));
let store = takana_core::TrustStore::load(trust_dir)?;
match index.verify_signature(&store) {
takana_core::SigStatus::Trusted { by } => {
println!("release: trusted (by {by}) — {} paquete(s)", index.packages.len());
Ok(())
}
takana_core::SigStatus::UnknownKey { by } => {
println!(
"release: unknown-key (by {by}) — añadí su clave a {} para confiar",
trust_dir.display()
);
Ok(())
}
takana_core::SigStatus::BadSig { by, reason } => {
anyhow::bail!("release: BAD-SIG (by {by}): {reason} — el índice fue manipulado");
}
takana_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 = takana_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 = takana_core::KeyPair::from_private_b64(&priv_b64)?;
// `by` por defecto: basename de la clave sin el sufijo `.ed25519`.
let by = by.map(|s| s.to_string()).unwrap_or_else(|| {
key.file_name()
.map(|f| f.to_string_lossy().trim_end_matches(".ed25519").to_string())
.unwrap_or_else(|| "anon".into())
});
swm.signature = Some(kp.sign(&swm, &by));
let yaml = swm.to_yaml()?;
let dest = out.unwrap_or_else(|| std::path::Path::new(file));
std::fs::write(dest, yaml)?;
println!("firmado por {by}{}", dest.display());
Ok(())
}
/// Aplica un `.swm`. Dos modos:
/// - **prefix** (`--prefix DIR`): re-rootea cada path al DIR; no abre overlay. Para tests y
/// staging.
/// - **overlay** (default): abre un overlay sobre los targets por defecto y aplica las
/// mutaciones sobre las rutas reales (que el kernel redirige al upper del overlay).
fn run_apply(
store_path: &str,
file: &str,
prefix: Option<&std::path::Path>,
skip_source_patch: bool,
base_ref: Option<&std::path::Path>,
state_root: Option<&std::path::Path>,
// Nombre del paquete (sólo lo sabe `install`): si está, la reproducción del source_patch usa
// ESE nombre para la receta sintetizada ⇒ cache-hitea el artefacto del corpus (`<hash>-name`)
// en vez de derivar el nombre del target_bin y sellar un duplicado (ripgrep→rg). `apply` pasa None.
pkg_name: Option<&str>,
) -> anyhow::Result<Vec<PathBuf>> {
let swm = load_swm(file)?;
swm.verify_schema()?;
// Ficheros que este apply CREA (hidratados + file_drop + init_rule). Sirven para registrar el
// paquete en la DB de instalados (config_edit MODIFICA, no crea ⇒ no se registra).
let mut created: Vec<PathBuf> = Vec::new();
if let Some(local) = load_local_base(base_ref)? {
match swm.verify_base(&local) {
takana_core::BaseCompat::Ok => {}
other => {
print_base_compat(&other);
anyhow::bail!("base local no compatible con la base del .swm");
}
}
}
let mut overlay_id: Option<takana_overlay::OverlayId> = None;
if prefix.is_none() {
let root = state_root
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from(takana_overlay::DEFAULT_STATE_ROOT));
std::fs::create_dir_all(&root)?;
let id = takana_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 {
takana_core::Mutation::SourcePatch { target_bin, .. } => {
counts.0 += 1;
if skip_source_patch {
eprintln!(" [skip] source_patch #{}{target_bin}", i + 1);
continue;
}
let store = takana_core::Store::open(store_path)?;
let cfg = takana_build::BuildConfig::from_env_or_defaults(store.root());
let hash = takana_build::build_source_patch(m, &cfg, &store, None, pkg_name)?;
let artifact = store.find_by_hash(hash.as_str())?;
let into = takana_core::apply::rebase_path("/", prefix);
let report = takana_build::run_hydrate(
&artifact,
&into,
takana_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 = takana_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()));
}
takana_core::Mutation::ConfigEdit { file: f, inline_diff } => {
counts.1 += 1;
let target = takana_core::apply::rebase_path(f, prefix);
takana_core::apply::apply_config_edit(&target, inline_diff)?;
eprintln!(" config_edit #{}{}", i + 1, target.display());
}
takana_core::Mutation::InitRule { action, service, command } => {
counts.2 += 1;
let rules_dir =
takana_core::apply::rebase_path(takana_core::apply::INIT_RULES_DIR, prefix);
let p = takana_core::apply::apply_init_rule(&rules_dir, action, service, command)?;
eprintln!(" init_rule #{}: {action} {service}{}", i + 1, p.display());
created.push(p);
}
takana_core::Mutation::FileDrop { path, content_hash, content_b64, content_url } => {
counts.3 += 1;
let target = takana_core::apply::rebase_path(path, prefix);
match (content_b64, content_url) {
(Some(b64), None) => {
takana_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 = takana_build::download::fetch_url_bytes(url)?;
takana_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 = takana_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) => takana_core::Base {
distro_version: local.distro_version,
pins: local.pins,
},
None => {
eprintln!("warning: sin --base-ref; emitiendo base con distro_version='unknown'");
takana_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 takana_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 = takana_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: &[takana_journal::MutationEvent],
store: Option<&takana_core::Store>,
) -> (Vec<takana_core::Mutation>, ExportStats) {
use base64::{engine::general_purpose::STANDARD, Engine as _};
use std::collections::BTreeMap;
// Paso 1: dedup respetando Delete.
let mut last_by_path: BTreeMap<PathBuf, &takana_journal::MutationEvent> = BTreeMap::new();
for ev in events {
if matches!(ev.op, takana_journal::MutationOp::Delete) {
last_by_path.remove(&ev.path);
continue;
}
last_by_path.insert(ev.path.clone(), ev);
}
let mut stats = ExportStats::default();
// Paso 2: particionamos. Para que la asignación a `source_patch` requiera la receta,
// resolvemos el sidecar acá; si no está, el evento cae al fallback.
//
// Estructura: artifact_hash → (Recipe, Vec<path>). Los paths se ordenarán antes de
// emitir, para que el target_bin sea determinista (importante para tests).
let mut groups: BTreeMap<String, (takana_core::Recipe, Vec<PathBuf>)> = BTreeMap::new();
let mut fallback: Vec<(PathBuf, &takana_journal::MutationEvent)> = Vec::new();
let mut artifacts_seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
let mut artifacts_without_recipe: std::collections::BTreeSet<String> =
std::collections::BTreeSet::new();
for (path, ev) in &last_by_path {
let artifact = match &ev.by.source {
takana_journal::Source::HammerHydrate { artifact } => Some(artifact.clone()),
takana_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<takana_core::Mutation> = Vec::new();
// Paso 3: source_patches (ordenados por artifact_hash para estabilidad del output).
for (art_hash, (recipe, mut paths)) in groups {
paths.sort();
let target_bin = paths
.first()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "/".into());
// Convertimos la Recipe a los campos de origen que SourcePatch necesita
// (git xor tarball — ambos se modelan ya, sin caer a file_drop).
let (repo, commit, tarball, sha256) = match recipe.source.kind() {
Ok(takana_core::SourceKind::Git { repo, commit }) => {
(Some(repo.to_string()), Some(commit.to_string()), None, None)
}
Ok(takana_core::SourceKind::Tarball { url, sha256 }) => {
(None, None, Some(url.to_string()), Some(sha256.to_string()))
}
// El árbol de una receta derivada no se puede mandar en un `.swm` (no hay puntero
// resoluble del otro lado) ⇒ mismo tratamiento que un source inválido: file_drop.
Ok(takana_core::SourceKind::Dir { .. }) | 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(takana_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: takana_core::SwmBuild {
compiler: recipe.build.compiler.as_str().to_string(),
target: recipe.build.target.clone(),
link: recipe.build.link.as_str().to_string(),
flags: recipe.build.flags.clone(),
phases: recipe.build.phases.clone(),
zig_version: recipe.build.zig_version.clone(),
},
target_bin,
expected_hash: Some(art_hash),
deps: recipe.deps.clone(),
evidence: recipe.evidence.clone(),
slots: recipe.slots.clone(),
});
stats.source_patches += 1;
}
// Paso 4: file_drops. Dedupeamos por path porque algunos paths pueden haber rebotado
// del grupo de tarball-fallback al fallback general además del path original.
let mut seen_paths: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
fallback.sort_by(|(a, _), (b, _)| a.cmp(b));
for (path, ev) in fallback {
if !seen_paths.insert(path.clone()) {
continue;
}
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) => {
stats.warnings += 1;
eprintln!(
"warning: no pude leer {} para exportar (ev ts={}): {e}",
path.display(),
ev.ts
);
continue;
}
};
let h = takana_core::ArtifactHash::of_inputs(&[bytes.as_slice()]);
let b64 = STANDARD.encode(&bytes);
mutations.push(takana_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: &takana_core::Recipe) -> Option<String> {
if recipe.source.patches.is_empty() {
return None;
}
let mut all = String::new();
for p in &recipe.source.patches {
let path = if std::path::Path::new(p).is_absolute() {
std::path::PathBuf::from(p)
} else {
recipe.base_dir.join(p)
};
match std::fs::read_to_string(&path) {
Ok(text) => all.push_str(&text),
Err(e) => {
eprintln!(
"warning: no pude leer patch {} para inline: {e}",
path.display()
);
return None;
}
}
}
Some(all)
}
/// Ejecuta el bucle agéntico de Fase 6 vía `hammer-agent`. Acepta una intención NL +
/// catálogo YAML que mapea intentos exactos a `.swm`s pre-armados. La salida es un
/// `Proposal` legible en stdout + el siguiente paso recomendado al humano.
fn run_ai(
intent: &str,
catalog: Option<&std::path::Path>,
llm: bool,
prefix: Option<&std::path::Path>,
base_ref: Option<&std::path::Path>,
bus: Option<&std::path::Path>,
state_root: Option<&std::path::Path>,
store_root: &str,
repair_max_attempts: Option<u32>,
repair_window_ms: u64,
) -> anyhow::Result<()> {
use takana_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(takana_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<takana_core::BaseRef> {
if let Some(b) = load_local_base(base_ref)? {
return Ok(b);
}
let cat = takana_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| takana_core::Swm::from_yaml(&t).ok())
});
match ctx_swm {
Some(s) => Ok(takana_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: takana_core::BaseRef,
apply: takana_agent::orchestrator::ApplyTarget,
compile: takana_agent::orchestrator::CompileMode,
policy: Option<&takana_agent::orchestrator::RepairPolicy>,
) -> anyhow::Result<takana_agent::Proposal> {
use takana_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: takana_core::BaseRef,
apply: takana_agent::orchestrator::ApplyTarget,
compile: takana_agent::orchestrator::CompileMode,
policy: Option<&takana_agent::orchestrator::RepairPolicy>,
) -> anyhow::Result<takana_agent::Proposal> {
use takana_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: takana_core::BaseRef,
_apply: takana_agent::orchestrator::ApplyTarget,
_compile: takana_agent::orchestrator::CompileMode,
_policy: Option<&takana_agent::orchestrator::RepairPolicy>,
) -> anyhow::Result<takana_agent::Proposal> {
anyhow::bail!(
"este binario se compiló sin el feature `llm-claude`. Recompila: \
`cargo build -p hammer-cli --features llm-claude` (o desactiva --llm)."
)
}
/// Evalúa una expresión del mini-lenguaje localmente e imprime el JSON resultante.
/// Espejo en proceso del path remoto vía bus (`AgentClient::query_expr`).
fn run_query(
expr: &str,
base_ref: Option<&std::path::Path>,
fs_root: Option<&std::path::Path>,
) -> anyhow::Result<()> {
let mut ctx = takana_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 = takana_core::query::eval_str(expr, &ctx);
let json = serde_json::to_string_pretty(&value)?;
println!("{json}");
Ok(())
}
/// Envía una línea al FIFO de control humano. No abrimos un proceso intermedio (sería
/// `sh -c "echo ... > FIFO"` con riesgo de quoting); usamos un `OpenOptions::write` y un
/// `write_all`. El FIFO bloquea si no hay reader — eso es la garantía de "ack" implícito.
fn run_ctl(line: &str, fifo: &std::path::Path) -> anyhow::Result<()> {
use std::io::Write;
use std::os::unix::fs::FileTypeExt;
let meta = std::fs::symlink_metadata(fifo).map_err(|e| {
anyhow::anyhow!(
"no encuentro el FIFO {} ({e}); ¿está hammerd arrancado?",
fifo.display()
)
})?;
if !meta.file_type().is_fifo() {
anyhow::bail!("{} existe pero no es un FIFO", fifo.display());
}
let mut f = std::fs::OpenOptions::new().write(true).open(fifo)?;
f.write_all(line.as_bytes())?;
if !line.ends_with('\n') {
f.write_all(b"\n")?;
}
Ok(())
}
/// Parsea un `--swap name=hash[:rel_path]` (variante b del builder, SDD 11 §7.2b). El `rel_path`
/// por defecto es `usr/bin/<name>`. Quitamos el prefijo `b3:` del hash ANTES de buscar el `:` del
/// rel_path opcional — si no, `make=b3:abc` se parsearía como hash=`b3`, rel_path=`abc`.
fn parse_swap(s: &str) -> anyhow::Result<takana_bootstrap::ToolchainSwap> {
let (name, rest) = s
.split_once('=')
.ok_or_else(|| anyhow::anyhow!("--swap '{s}': falta '=' (name=hash[:rel_path])"))?;
let rest = rest.trim_start_matches("b3:");
let (hash, rel_path) = match rest.split_once(':') {
Some((h, p)) => (h, p.to_string()),
None => (rest, format!("usr/bin/{name}")),
};
Ok(takana_bootstrap::ToolchainSwap {
name: name.to_string(),
artifact: takana_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 takana_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(takana_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], takana_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 = takana_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] {
takana_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 = takana_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], takana_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 = takana_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], takana_core::Mutation::SourcePatch { .. }));
assert!(matches!(mutations[1], takana_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());
}
}