tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

⎇ f41022d81f87b38135f1faca5821e31ceaf7ee90 crates/hammer-cli/src/main.rs Rust · 626 siq'ikuna · 24.8 KB Ñawpa willay Pi imata llamkarqan ch'uya

Fase 5 — bus de agente (/run/agent.sock) + FIFO de control humano f41022d81 · Sergio ·

1//! `hammer` — el punto de entrada de todo flujo manual. Ver `docs/`.
2//!
3//! Los subcomandos están mapeados a las fases del roadmap (`docs/10-roadmap.md`). Los que aún
4//! no están implementados devuelven un error claro indicando su fase, para que el esqueleto
5//! sea navegable desde el día uno.
6
7use std::path::PathBuf;
8
9use clap::{Parser, Subcommand};
10
11const DEFAULT_STORE: &str = "/store";
12
13#[derive(Parser)]
14#[command(
15    name = "hammer",
16    version,
17    about = "Laboratorio funcional en el sótano, terminal mutable arriba.",
18    long_about = "hammer: distro AI-nativa. Ver docs/ para el diseño completo."
19)]
20struct Cli {
21    /// Ruta del content-addressed store.
22    #[arg(long, default_value = DEFAULT_STORE, global = true)]
23    store: String,
24
25    #[command(subcommand)]
26    cmd: Cmd,
27}
28
29#[derive(Subcommand)]
30enum Cmd {
31    /// [Fase 0] Compila una receta y la sella en el store.
32    Build {
33        /// Ruta a la receta TOML.
34        recipe: String,
35    },
36    /// [Fase 1] Proyecta un artefacto del store al FHS (real o de overlay).
37    Hydrate {
38        /// Hash del artefacto (acepta prefijos cortos; con o sin `b3:`).
39        hash: String,
40        #[arg(long, default_value = "/")]
41        into: String,
42        /// Modo de enlazado: `static` (hardlink directo) | `dynamic` (requiere patchelf).
43        #[arg(long, default_value = "static")]
44        link: String,
45    },
46    /// [Fase 2] Monta un overlay de experimentación sobre los directorios del sistema.
47    /// Sin targets → cubre el FHS clásico (/usr/bin, /bin, /usr/lib, /lib, /etc, …).
48    Try {
49        /// Directorios a poner bajo overlay. Si vacío, usa la lista por defecto.
50        targets: Vec<PathBuf>,
51        /// Raíz donde viven los manifiestos y los upper/work. Default `/var/lib/hammer/overlays`.
52        #[arg(long)]
53        state_root: Option<PathBuf>,
54    },
55    /// [Fase 2+3] Fusiona un overlay al FHS real y desmonta. Por defecto registra cada
56    /// archivo promocionado en el diario; usa `--no-journal` para saltarse el registro.
57    Commit {
58        id: String,
59        #[arg(long)]
60        state_root: Option<PathBuf>,
61        /// Directorio del diario donde anotar la promoción.
62        #[arg(long, default_value = "/var/lib/hammer/journal")]
63        journal: PathBuf,
64        /// Saltarse el registro en el diario (útil para experimentos efímeros).
65        #[arg(long)]
66        no_journal: bool,
67    },
68    /// [Fase 2] Desmonta un overlay y borra su upper. El sistema vuelve al estado base.
69    Discard {
70        id: String,
71        #[arg(long)]
72        state_root: Option<PathBuf>,
73    },
74    /// [Fase 2] Lista los overlays activos según los manifiestos persistidos.
75    Status {
76        #[arg(long)]
77        state_root: Option<PathBuf>,
78    },
79    /// [Fase 3] Ver/seguir el diario de mutaciones.
80    Journal {
81        /// Directorio del diario (mismo que `hammerd --journal`).
82        #[arg(long, default_value = "/var/lib/hammer/journal")]
83        dir: PathBuf,
84        /// Muestra los últimos N eventos. Default: 20.
85        #[arg(long, short, default_value = "20")]
86        tail: usize,
87        /// Sigue el archivo: imprime nuevos eventos según aparecen.
88        #[arg(long, short)]
89        follow: bool,
90        /// Formato. `pretty` (humano, default) o `json` (raw passthrough).
91        #[arg(long, default_value = "pretty")]
92        format: String,
93    },
94    /// [Fase 4] Aplica un manifiesto .swm. Por defecto abre un overlay sobre el FHS y aplica
95    /// las mutaciones ahí; con `--prefix DIR` opera directamente en `DIR` sin overlay
96    /// (útil para tests offline o staging en otro filesystem).
97    Apply {
98        file: String,
99        /// Si está, re-rootea todas las rutas absolutas del .swm bajo este prefix y NO abre
100        /// overlay. `--prefix /mnt/foo` ⇒ `/etc/network.conf` → `/mnt/foo/etc/network.conf`.
101        #[arg(long)]
102        prefix: Option<PathBuf>,
103        /// Si está, salta source_patch silenciosamente. Útil para probar el flujo de
104        /// config_edit/file_drop sin lab disponible.
105        #[arg(long)]
106        skip_source_patch: bool,
107        /// Tras aplicar, intenta verificar pin contra base local de `--base-ref`.
108        #[arg(long)]
109        base_ref: Option<PathBuf>,
110        /// Raíz del overlay (sólo en modo overlay).
111        #[arg(long)]
112        state_root: Option<PathBuf>,
113    },
114    /// [Fase 4] Verifica un .swm: schema, base (si se da `--base-ref`), y opcionalmente el
115    /// hash de cada `file_drop` inline.
116    SwmVerify {
117        file: String,
118        #[arg(long)]
119        base_ref: Option<PathBuf>,
120    },
121    /// [Fase 4] Exporta el diario actual como manifiesto .swm a stdout.
122    /// Mutaciones trazables ⇒ file_drop con content_b64 y hash. Mutaciones opacas ⇒
123    /// mismas pero con `note` indicando el origen (warning legible para el receptor).
124    Export {
125        /// Archivo JSON con la base local; se inserta como `base` en el .swm. Si no se da,
126        /// se emite una base con `distro_version: "unknown"` y se imprime un warning.
127        #[arg(long)]
128        base_ref: Option<PathBuf>,
129        /// Directorio del diario.
130        #[arg(long, default_value = "/var/lib/hammer/journal")]
131        journal: PathBuf,
132        /// Sólo eventos a partir de este timestamp (RFC 3339). Si no se da, exporta todo.
133        #[arg(long)]
134        since: Option<String>,
135    },
136    /// [Fase 5] Envía un comando al FIFO de control humano del init.
137    /// Por defecto: /run/init.control. Equivalente a `echo "<line>" > /run/init.control`,
138    /// pero con un mensaje de error claro si el FIFO no existe.
139    Ctl {
140        /// La línea a enviar (p. ej. "start web", "restart network").
141        line: String,
142        #[arg(long, default_value = "/run/init.control")]
143        fifo: PathBuf,
144    },
145}
146
147fn print_event(ev: &hammer_journal::MutationEvent, format: &str) {
148    match format {
149        "json" => {
150            // Re-serializa. No imprimimos la línea original (no la tenemos a mano), pero el
151            // formato es estable porque MutationEvent es la fuente de verdad del schema.
152            match serde_json::to_string(ev) {
153                Ok(s) => println!("{s}"),
154                Err(e) => eprintln!("journal: error serializando: {e}"),
155            }
156        }
157        _ => {
158            let actor = match &ev.by.source {
159                hammer_journal::Source::HammerHydrate { artifact } => format!("hydrate {artifact}"),
160                hammer_journal::Source::HammerCommit { overlay, artifact } => match artifact {
161                    Some(a) => format!("commit {overlay} ({a})"),
162                    None => format!("commit {overlay}"),
163                },
164                hammer_journal::Source::External => {
165                    let pid = ev.by.pid.map(|p| format!("pid={p}")).unwrap_or_default();
166                    let uid = ev.by.uid.map(|u| format!("uid={u}")).unwrap_or_default();
167                    format!("external {pid} {uid}").trim().to_string()
168                }
169            };
170            println!(
171                "{ts} {op:<7} {path}  ←  {actor}",
172                ts = ev.ts,
173                op = ev.op.as_str(),
174                path = ev.path.display(),
175            );
176        }
177    }
178}
179
180fn main() -> anyhow::Result<()> {
181    tracing_subscriber::fmt()
182        .with_writer(std::io::stderr)
183        .with_env_filter(
184            tracing_subscriber::EnvFilter::try_from_default_env()
185                .unwrap_or_else(|_| "info".into()),
186        )
187        .init();
188
189    let cli = Cli::parse();
190
191    // Sólo las cmds que de verdad tocan artefactos abren (y posiblemente crean) el store.
192    // Las cmds de overlay/diario/apply pueden correr en sistemas sin store local todavía.
193    match cli.cmd {
194        Cmd::Build { recipe } => {
195            let store = hammer_core::Store::open(&cli.store)?;
196            let recipe = hammer_core::Recipe::load_from_path(&recipe)?;
197            let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
198            let hash = hammer_build::build(&recipe, &cfg, &store)?;
199            println!("{hash}");
200        }
201        Cmd::Hydrate { hash, into, link } => {
202            let store = hammer_core::Store::open(&cli.store)?;
203            let mode = match link.as_str() {
204                "static" => hammer_core::LinkMode::Static,
205                "dynamic" => hammer_core::LinkMode::Dynamic,
206                other => anyhow::bail!("--link debe ser 'static' o 'dynamic', no '{other}'"),
207            };
208            let artifact_dir = store.find_by_hash(&hash)?;
209            let report = hammer_build::run_hydrate(
210                &artifact_dir,
211                std::path::Path::new(&into),
212                mode,
213            )?;
214            println!(
215                "hydrated {} archivo(s) ({} dir(s), {} symlink(s)) → {}",
216                report.files.len(),
217                report.dirs_created,
218                report.symlinks,
219                into
220            );
221            for f in &report.files {
222                println!("  {}", f.dst.display());
223            }
224        }
225        Cmd::Try { targets, state_root } => {
226            let root = state_root
227                .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
228            std::fs::create_dir_all(&root)?;
229            let id = hammer_overlay::try_overlay(&targets, &root)?;
230            println!("{id}");
231        }
232        Cmd::Commit { id, state_root, journal, no_journal } => {
233            let root = state_root
234                .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
235            let id = hammer_overlay::OverlayId(id);
236            let report = if no_journal {
237                hammer_overlay::commit(&id, &root)?
238            } else {
239                let j = hammer_journal::Journal::open(&journal)?;
240                hammer_overlay::commit_with_journal(&id, &root, &j)?
241            };
242            println!(
243                "commit OK — {} archivo(s) promocionados, {} eliminado(s){}",
244                report.copied.len(),
245                report.removed.len(),
246                if no_journal { "" } else { " (anotado en el diario)" }
247            );
248        }
249        Cmd::Discard { id, state_root } => {
250            let root = state_root
251                .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
252            hammer_overlay::discard(&hammer_overlay::OverlayId(id), &root)?;
253            println!("discard OK");
254        }
255        Cmd::Status { state_root } => {
256            let root = state_root
257                .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
258            let overlays = hammer_overlay::status(&root)?;
259            if overlays.is_empty() {
260                println!("(sin overlays activos)");
261            } else {
262                for o in overlays {
263                    println!(
264                        "{}  created={}  mounts={}",
265                        o.id,
266                        o.created_at,
267                        o.mounts.len()
268                    );
269                    for m in &o.mounts {
270                        println!("  └─ {}  upper={}", m.target.display(), m.upper.display());
271                    }
272                }
273            }
274        }
275        Cmd::Journal { dir, tail, follow, format } => {
276            let j = hammer_journal::Journal::open(&dir)?;
277            let initial = j.tail(tail)?;
278            for ev in &initial {
279                print_event(ev, &format);
280            }
281            if follow {
282                use std::io::Write;
283                let _ = std::io::stdout().flush();
284                let mut cursor = j.end_offset()?;
285                loop {
286                    std::thread::sleep(std::time::Duration::from_millis(500));
287                    let (news, new_cursor) = j.read_from_offset(cursor)?;
288                    cursor = new_cursor;
289                    for ev in &news {
290                        print_event(ev, &format);
291                    }
292                    let _ = std::io::stdout().flush();
293                }
294            }
295        }
296        Cmd::Apply { file, prefix, skip_source_patch, base_ref, state_root } => {
297            run_apply(
298                &cli.store,
299                &file,
300                prefix.as_deref(),
301                skip_source_patch,
302                base_ref.as_deref(),
303                state_root.as_deref(),
304            )?;
305        }
306        Cmd::SwmVerify { file, base_ref } => {
307            run_swm_verify(&file, base_ref.as_deref())?;
308        }
309        Cmd::Export { base_ref, journal, since } => {
310            run_export(base_ref.as_deref(), &journal, since.as_deref())?;
311        }
312        Cmd::Ctl { line, fifo } => {
313            run_ctl(&line, &fifo)?;
314        }
315    }
316    Ok(())
317}
318
319/// Lee y deserializa un `.swm` desde disco; falla con mensaje útil si no parsea.
320fn load_swm(file: &str) -> anyhow::Result<hammer_core::Swm> {
321    let yaml = std::fs::read_to_string(file)
322        .map_err(|e| anyhow::anyhow!("no pude leer {file}: {e}"))?;
323    hammer_core::Swm::from_yaml(&yaml)
324        .map_err(|e| anyhow::anyhow!("no pude parsear {file}: {e}"))
325}
326
327/// Lee una `BaseRef` local desde JSON; usada por `apply` y `swm verify` para verificar la
328/// compatibilidad de pins. Si la ruta no existe, devuelve `Ok(None)` para que el caller
329/// pueda decidir entre fallar o seguir con un warning.
330fn load_local_base(path: Option<&std::path::Path>) -> anyhow::Result<Option<hammer_core::BaseRef>> {
331    let Some(p) = path else { return Ok(None) };
332    if !p.is_file() {
333        return Err(anyhow::anyhow!("base-ref {} no existe", p.display()));
334    }
335    let bytes = std::fs::read(p)?;
336    let base: hammer_core::BaseRef =
337        serde_json::from_slice(&bytes).map_err(|e| anyhow::anyhow!("base-ref inválido: {e}"))?;
338    Ok(Some(base))
339}
340
341fn print_base_compat(c: &hammer_core::BaseCompat) {
342    use hammer_core::BaseCompat::*;
343    match c {
344        Ok => eprintln!("verify_base: OK"),
345        DistroMismatch { local, swm } => eprintln!(
346            "verify_base: distro_version no coincide — local={local} swm={swm}"
347        ),
348        PinMismatch(diffs) => {
349            eprintln!("verify_base: {} pin(s) divergente(s):", diffs.len());
350            for d in diffs {
351                let local = d.local.as_deref().unwrap_or("(ausente)");
352                eprintln!("  {}: local={local} swm={}", d.name, d.swm);
353            }
354        }
355    }
356}
357
358fn run_swm_verify(file: &str, base_ref: Option<&std::path::Path>) -> anyhow::Result<()> {
359    let swm = load_swm(file)?;
360    swm.verify_schema()
361        .map_err(|e| anyhow::anyhow!("schema inválido: {e}"))?;
362    println!("schema: OK  ({} mutación(es))", swm.mutations.len());
363    // Verificación de hash de los file_drop inline (no toca disco).
364    for (i, m) in swm.mutations.iter().enumerate() {
365        if let hammer_core::Mutation::FileDrop { content_b64: Some(b64), content_hash, path, .. } = m {
366            hammer_core::apply::verify_content_hash(b64, content_hash).map_err(|e| {
367                anyhow::anyhow!("file_drop #{} en {path}: {e}", i + 1)
368            })?;
369        }
370    }
371    println!("file_drop inline: OK");
372    if let Some(local) = load_local_base(base_ref)? {
373        print_base_compat(&swm.verify_base(&local));
374    } else {
375        eprintln!("verify_base: omitido (sin --base-ref)");
376    }
377    Ok(())
378}
379
380/// Aplica un `.swm`. Dos modos:
381/// - **prefix** (`--prefix DIR`): re-rootea cada path al DIR; no abre overlay. Para tests y
382///   staging.
383/// - **overlay** (default): abre un overlay sobre los targets por defecto y aplica las
384///   mutaciones sobre las rutas reales (que el kernel redirige al upper del overlay).
385fn run_apply(
386    store_path: &str,
387    file: &str,
388    prefix: Option<&std::path::Path>,
389    skip_source_patch: bool,
390    base_ref: Option<&std::path::Path>,
391    state_root: Option<&std::path::Path>,
392) -> anyhow::Result<()> {
393    let swm = load_swm(file)?;
394    swm.verify_schema()?;
395
396    if let Some(local) = load_local_base(base_ref)? {
397        match swm.verify_base(&local) {
398            hammer_core::BaseCompat::Ok => {}
399            other => {
400                print_base_compat(&other);
401                anyhow::bail!("base local no compatible con la base del .swm");
402            }
403        }
404    }
405
406    let mut overlay_id: Option<hammer_overlay::OverlayId> = None;
407    if prefix.is_none() {
408        let root = state_root
409            .map(|p| p.to_path_buf())
410            .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT));
411        std::fs::create_dir_all(&root)?;
412        let id = hammer_overlay::try_overlay(&[], &root)?;
413        eprintln!("overlay {id} abierto");
414        overlay_id = Some(id);
415    }
416
417    let mut counts = (0usize, 0usize, 0usize, 0usize); // src_patch, cfg, init, drop
418    for (i, m) in swm.mutations.iter().enumerate() {
419        match m {
420            hammer_core::Mutation::SourcePatch { target_bin, .. } => {
421                counts.0 += 1;
422                if skip_source_patch {
423                    eprintln!("  [skip] source_patch #{} → {target_bin}", i + 1);
424                    continue;
425                }
426                let store = hammer_core::Store::open(store_path)?;
427                let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
428                let hash = hammer_build::build_source_patch(m, &cfg, &store, None)?;
429                let artifact = store.find_by_hash(hash.as_str())?;
430                let into = hammer_core::apply::rebase_path("/", prefix);
431                let report = hammer_build::run_hydrate(
432                    &artifact,
433                    &into,
434                    hammer_core::LinkMode::Static,
435                )?;
436                eprintln!(
437                    "  source_patch #{}: {} → hidratados {} archivo(s) en {}",
438                    i + 1,
439                    hash,
440                    report.files.len(),
441                    into.display()
442                );
443                // Sanity: el target_bin declarado debe existir tras la hidratación.
444                let abs = hammer_core::apply::rebase_path(target_bin, prefix);
445                if !abs.exists() {
446                    anyhow::bail!(
447                        "source_patch declara target_bin={target_bin} pero no quedó en {}",
448                        abs.display()
449                    );
450                }
451            }
452            hammer_core::Mutation::ConfigEdit { file: f, inline_diff } => {
453                counts.1 += 1;
454                let target = hammer_core::apply::rebase_path(f, prefix);
455                hammer_core::apply::apply_config_edit(&target, inline_diff)?;
456                eprintln!("  config_edit #{} → {}", i + 1, target.display());
457            }
458            hammer_core::Mutation::InitRule { action, service, .. } => {
459                counts.2 += 1;
460                eprintln!(
461                    "  [pendiente fase 5] init_rule #{}: {action} {service} — ignorando",
462                    i + 1
463                );
464            }
465            hammer_core::Mutation::FileDrop { path, content_hash, content_b64, content_url } => {
466                counts.3 += 1;
467                let target = hammer_core::apply::rebase_path(path, prefix);
468                match (content_b64, content_url) {
469                    (Some(b64), None) => {
470                        hammer_core::apply::apply_file_drop(&target, b64, content_hash)?;
471                        eprintln!("  file_drop #{} → {}", i + 1, target.display());
472                    }
473                    (None, Some(url)) => {
474                        anyhow::bail!(
475                            "file_drop #{} usa content_url={url}: fetch remoto pendiente",
476                            i + 1
477                        );
478                    }
479                    _ => unreachable!("verify_schema lo descarta"),
480                }
481            }
482        }
483    }
484    eprintln!(
485        "apply OK — source_patch={} config_edit={} init_rule={} file_drop={}",
486        counts.0, counts.1, counts.2, counts.3
487    );
488    if let Some(id) = &overlay_id {
489        println!("{id}");
490        eprintln!("overlay listo: usa `hammer commit {id}` para promocionar al FHS real.");
491    }
492    Ok(())
493}
494
495/// Exporta el diario actual como `.swm`. Estrategia honesta para Fase 4:
496///
497/// - Cada evento se traduce a un `file_drop` con `content_b64` (lo que hay en disco *ahora*)
498///   y `content_hash`. Esto garantiza reproducción byte-a-byte sin depender de tener el
499///   recipe original. La pérdida es que perdemos provenance — el receptor obtiene el binario
500///   pero no la receta que lo produjo. Para `source_patch` real necesitaremos un mapa
501///   artefacto→receta que la Fase 4 no construye todavía.
502/// - Mutaciones opacas (`Source::External`) llevan un `note` indicando "origen externo"
503///   para que el receptor sepa que no hay garantía de provenance.
504/// - Mutaciones con `op = Delete` se omiten: un `.swm` describe el estado final por
505///   adición/edición, no por borrado. Es un compromiso consciente (un futuro `FileRemove`
506///   resolvería esto).
507fn run_export(
508    base_ref_path: Option<&std::path::Path>,
509    journal_dir: &std::path::Path,
510    since: Option<&str>,
511) -> anyhow::Result<()> {
512    use base64::{engine::general_purpose::STANDARD, Engine as _};
513    use std::collections::BTreeMap;
514
515    let j = hammer_journal::Journal::open(journal_dir)?;
516    let events = j.read_all()?;
517    let filtered: Vec<_> = match since {
518        Some(t) => events.into_iter().filter(|e| e.ts.as_str() > t).collect(),
519        None => events,
520    };
521
522    let base = match load_local_base(base_ref_path)? {
523        Some(local) => hammer_core::Base {
524            distro_version: local.distro_version,
525            pins: local.pins,
526        },
527        None => {
528            eprintln!("warning: sin --base-ref; emitiendo base con distro_version='unknown'");
529            hammer_core::Base {
530                distro_version: "unknown".into(),
531                pins: BTreeMap::new(),
532            }
533        }
534    };
535
536    // Dedup por path: dentro de un export queremos un único estado final por archivo.
537    // Procesamos en orden y nos quedamos con la última ocurrencia.
538    let mut last_by_path: std::collections::BTreeMap<PathBuf, &hammer_journal::MutationEvent> =
539        std::collections::BTreeMap::new();
540    for ev in &filtered {
541        if matches!(ev.op, hammer_journal::MutationOp::Delete) {
542            last_by_path.remove(&ev.path); // borrado anula cualquier estado previo
543            continue;
544        }
545        last_by_path.insert(ev.path.clone(), ev);
546    }
547
548    let mut mutations = Vec::new();
549    let mut warnings = 0usize;
550    for (path, ev) in &last_by_path {
551        let bytes = match std::fs::read(path) {
552            Ok(b) => b,
553            Err(e) => {
554                warnings += 1;
555                eprintln!(
556                    "warning: no pude leer {} para exportar (ev ts={}): {e}",
557                    path.display(),
558                    ev.ts
559                );
560                continue;
561            }
562        };
563        let h = hammer_core::ArtifactHash::of_inputs(&[bytes.as_slice()]);
564        let b64 = STANDARD.encode(&bytes);
565        let note = match &ev.by.source {
566            hammer_journal::Source::External => Some(format!(
567                "origen externo (pid={:?}, uid={:?}) — sin provenance",
568                ev.by.pid, ev.by.uid
569            )),
570            hammer_journal::Source::HammerHydrate { artifact } => {
571                Some(format!("hydrate {artifact}"))
572            }
573            hammer_journal::Source::HammerCommit { overlay, artifact } => match artifact {
574                Some(a) => Some(format!("commit {overlay} ({a})")),
575                None => Some(format!("commit {overlay}")),
576            },
577        };
578        // El note vive en el journal, no en el schema del file_drop — lo omitimos en el
579        // YAML emitido por ahora; está en stderr (warnings) si era external.
580        let _ = note;
581        mutations.push(hammer_core::Mutation::FileDrop {
582            path: path.display().to_string(),
583            content_hash: h.as_str().to_string(),
584            content_b64: Some(b64),
585            content_url: None,
586        });
587    }
588
589    let swm = hammer_core::Swm {
590        swm_version: 1,
591        base,
592        mutations,
593        signature: None,
594    };
595    let yaml = swm.to_yaml()?;
596    print!("{yaml}");
597    eprintln!(
598        "export: {} mutación(es), {} warning(s)",
599        swm.mutations.len(),
600        warnings
601    );
602    Ok(())
603}
604
605/// Envía una línea al FIFO de control humano. No abrimos un proceso intermedio (sería
606/// `sh -c "echo ... > FIFO"` con riesgo de quoting); usamos un `OpenOptions::write` y un
607/// `write_all`. El FIFO bloquea si no hay reader — eso es la garantía de "ack" implícito.
608fn run_ctl(line: &str, fifo: &std::path::Path) -> anyhow::Result<()> {
609    use std::io::Write;
610    use std::os::unix::fs::FileTypeExt;
611    let meta = std::fs::symlink_metadata(fifo).map_err(|e| {
612        anyhow::anyhow!(
613            "no encuentro el FIFO {} ({e}); ¿está hammerd arrancado?",
614            fifo.display()
615        )
616    })?;
617    if !meta.file_type().is_fifo() {
618        anyhow::bail!("{} existe pero no es un FIFO", fifo.display());
619    }
620    let mut f = std::fs::OpenOptions::new().write(true).open(fifo)?;
621    f.write_all(line.as_bytes())?;
622    if !line.ends_with('\n') {
623        f.write_all(b"\n")?;
624    }
625    Ok(())
626}