//! `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}; const DEFAULT_STORE: &str = "/store"; #[derive(Parser)] #[command( name = "hammer", version, about = "Laboratorio funcional en el sótano, terminal mutable arriba.", long_about = "hammer: distro AI-nativa. Ver docs/ para el diseño completo." )] struct Cli { /// Ruta del content-addressed store. #[arg(long, default_value = DEFAULT_STORE, global = true)] store: String, #[command(subcommand)] cmd: Cmd, } #[derive(Subcommand)] enum Cmd { /// [Fase 0] Compila una receta y la sella en el store. Build { /// Ruta a la receta TOML. recipe: String, }, /// [Fase 1] Proyecta un artefacto del store al FHS (real o de overlay). Hydrate { /// Hash del artefacto (acepta prefijos cortos; con o sin `b3:`). hash: String, #[arg(long, default_value = "/")] into: String, /// Modo de enlazado: `static` (hardlink directo) | `dynamic` (requiere patchelf). #[arg(long, default_value = "static")] link: String, }, /// [Fase 2] Monta un overlay de experimentación sobre los directorios del sistema. /// Sin targets → cubre el FHS clásico (/usr/bin, /bin, /usr/lib, /lib, /etc, …). Try { /// Directorios a poner bajo overlay. Si vacío, usa la lista por defecto. targets: Vec, /// Raíz donde viven los manifiestos y los upper/work. Default `/var/lib/hammer/overlays`. #[arg(long)] state_root: Option, }, /// [Fase 2+3] Fusiona un overlay al FHS real y desmonta. Por defecto registra cada /// archivo promocionado en el diario; usa `--no-journal` para saltarse el registro. Commit { id: String, #[arg(long)] state_root: Option, /// Directorio del diario donde anotar la promoción. #[arg(long, default_value = "/var/lib/hammer/journal")] journal: PathBuf, /// Saltarse el registro en el diario (útil para experimentos efímeros). #[arg(long)] no_journal: bool, }, /// [Fase 2] Desmonta un overlay y borra su upper. El sistema vuelve al estado base. Discard { id: String, #[arg(long)] state_root: Option, }, /// [Fase 2] Lista los overlays activos según los manifiestos persistidos. Status { #[arg(long)] state_root: Option, }, /// [Fase 3] Ver/seguir el diario de mutaciones. Journal { /// Directorio del diario (mismo que `hammerd --journal`). #[arg(long, default_value = "/var/lib/hammer/journal")] dir: PathBuf, /// Muestra los últimos N eventos. Default: 20. #[arg(long, short, default_value = "20")] tail: usize, /// Sigue el archivo: imprime nuevos eventos según aparecen. #[arg(long, short)] follow: bool, /// Formato. `pretty` (humano, default) o `json` (raw passthrough). #[arg(long, default_value = "pretty")] format: String, }, /// [Fase 4] Aplica un manifiesto .swm. Por defecto abre un overlay sobre el FHS y aplica /// las mutaciones ahí; con `--prefix DIR` opera directamente en `DIR` sin overlay /// (útil para tests offline o staging en otro filesystem). Apply { file: String, /// Si está, re-rootea todas las rutas absolutas del .swm bajo este prefix y NO abre /// overlay. `--prefix /mnt/foo` ⇒ `/etc/network.conf` → `/mnt/foo/etc/network.conf`. #[arg(long)] prefix: Option, /// Si está, salta source_patch silenciosamente. Útil para probar el flujo de /// config_edit/file_drop sin lab disponible. #[arg(long)] skip_source_patch: bool, /// Tras aplicar, intenta verificar pin contra base local de `--base-ref`. #[arg(long)] base_ref: Option, /// Raíz del overlay (sólo en modo overlay). #[arg(long)] state_root: Option, }, /// [Fase 4] Verifica un .swm: schema, base (si se da `--base-ref`), y opcionalmente el /// hash de cada `file_drop` inline. SwmVerify { file: String, #[arg(long)] base_ref: Option, }, /// [Fase 4] Exporta el diario actual como manifiesto .swm a stdout. /// Mutaciones trazables ⇒ file_drop con content_b64 y hash. Mutaciones opacas ⇒ /// mismas pero con `note` indicando el origen (warning legible para el receptor). Export { /// Archivo JSON con la base local; se inserta como `base` en el .swm. Si no se da, /// se emite una base con `distro_version: "unknown"` y se imprime un warning. #[arg(long)] base_ref: Option, /// Directorio del diario. #[arg(long, default_value = "/var/lib/hammer/journal")] journal: PathBuf, /// Sólo eventos a partir de este timestamp (RFC 3339). Si no se da, exporta todo. #[arg(long)] since: Option, }, /// [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 (string exacta, indexada en el catálogo). intent: String, /// Catálogo YAML de intent → swm. Cuando exista un traductor LLM real, este flag /// será opcional y se documentará el path por defecto. #[arg(long)] catalog: PathBuf, /// Re-rootea las mutaciones bajo este prefix en vez de abrir overlay. Útil para /// dev/CI sin root ni overlayfs. #[arg(long)] prefix: Option, /// Base local (JSON). Si está, el orquestador verifica `verify_base` contra ella. #[arg(long)] base_ref: Option, /// Socket de hammerd. Si está + el .swm trae source_patch, el orquestador llama /// Compile por el bus. Si no, source_patch se omite con warning. #[arg(long)] bus: Option, /// Raíz de overlays para `try`. Default coincide con `hammer try`. #[arg(long)] state_root: Option, }, /// [Fase 5] Envía un comando al FIFO de control humano del init. /// Por defecto: /run/init.control. Equivalente a `echo "" > /run/init.control`, /// pero con un mensaje de error claro si el FIFO no existe. Ctl { /// La línea a enviar (p. ej. "start web", "restart network"). line: String, #[arg(long, default_value = "/run/init.control")] fifo: PathBuf, }, } fn print_event(ev: &hammer_journal::MutationEvent, format: &str) { match format { "json" => { // Re-serializa. No imprimimos la línea original (no la tenemos a mano), pero el // formato es estable porque MutationEvent es la fuente de verdad del schema. match serde_json::to_string(ev) { Ok(s) => println!("{s}"), Err(e) => eprintln!("journal: error serializando: {e}"), } } _ => { let actor = match &ev.by.source { hammer_journal::Source::HammerHydrate { artifact } => format!("hydrate {artifact}"), hammer_journal::Source::HammerCommit { overlay, artifact } => match artifact { Some(a) => format!("commit {overlay} ({a})"), None => format!("commit {overlay}"), }, hammer_journal::Source::External => { let pid = ev.by.pid.map(|p| format!("pid={p}")).unwrap_or_default(); let uid = ev.by.uid.map(|u| format!("uid={u}")).unwrap_or_default(); format!("external {pid} {uid}").trim().to_string() } }; println!( "{ts} {op:<7} {path} ← {actor}", ts = ev.ts, op = ev.op.as_str(), path = ev.path.display(), ); } } } fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() .with_writer(std::io::stderr) .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| "info".into()), ) .init(); let cli = Cli::parse(); // Sólo las cmds que de verdad tocan artefactos abren (y posiblemente crean) el store. // Las cmds de overlay/diario/apply pueden correr en sistemas sin store local todavía. match cli.cmd { Cmd::Build { recipe } => { let store = hammer_core::Store::open(&cli.store)?; let recipe = hammer_core::Recipe::load_from_path(&recipe)?; let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root()); let hash = hammer_build::build(&recipe, &cfg, &store)?; println!("{hash}"); } Cmd::Hydrate { hash, into, link } => { let store = hammer_core::Store::open(&cli.store)?; let mode = match link.as_str() { "static" => hammer_core::LinkMode::Static, "dynamic" => hammer_core::LinkMode::Dynamic, other => anyhow::bail!("--link debe ser 'static' o 'dynamic', no '{other}'"), }; let artifact_dir = store.find_by_hash(&hash)?; let report = hammer_build::run_hydrate( &artifact_dir, std::path::Path::new(&into), mode, )?; println!( "hydrated {} archivo(s) ({} dir(s), {} symlink(s)) → {}", report.files.len(), report.dirs_created, report.symlinks, into ); for f in &report.files { println!(" {}", f.dst.display()); } } Cmd::Try { targets, state_root } => { let root = state_root .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT)); std::fs::create_dir_all(&root)?; let id = hammer_overlay::try_overlay(&targets, &root)?; println!("{id}"); } Cmd::Commit { id, state_root, journal, no_journal } => { let root = state_root .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT)); let id = hammer_overlay::OverlayId(id); let report = if no_journal { hammer_overlay::commit(&id, &root)? } else { let j = hammer_journal::Journal::open(&journal)?; hammer_overlay::commit_with_journal(&id, &root, &j)? }; println!( "commit OK — {} archivo(s) promocionados, {} eliminado(s){}", report.copied.len(), report.removed.len(), if no_journal { "" } else { " (anotado en el diario)" } ); } Cmd::Discard { id, state_root } => { let root = state_root .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT)); hammer_overlay::discard(&hammer_overlay::OverlayId(id), &root)?; println!("discard OK"); } Cmd::Status { state_root } => { let root = state_root .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT)); let overlays = hammer_overlay::status(&root)?; if overlays.is_empty() { println!("(sin overlays activos)"); } else { for o in overlays { println!( "{} created={} mounts={}", o.id, o.created_at, o.mounts.len() ); for m in &o.mounts { println!(" └─ {} upper={}", m.target.display(), m.upper.display()); } } } } Cmd::Journal { dir, tail, follow, format } => { let j = hammer_journal::Journal::open(&dir)?; let initial = j.tail(tail)?; for ev in &initial { print_event(ev, &format); } if follow { use std::io::Write; let _ = std::io::stdout().flush(); let mut cursor = j.end_offset()?; loop { std::thread::sleep(std::time::Duration::from_millis(500)); let (news, new_cursor) = j.read_from_offset(cursor)?; cursor = new_cursor; for ev in &news { print_event(ev, &format); } let _ = std::io::stdout().flush(); } } } Cmd::Apply { file, prefix, skip_source_patch, base_ref, state_root } => { run_apply( &cli.store, &file, prefix.as_deref(), skip_source_patch, base_ref.as_deref(), state_root.as_deref(), )?; } Cmd::SwmVerify { file, base_ref } => { run_swm_verify(&file, base_ref.as_deref())?; } Cmd::Export { base_ref, journal, since } => { run_export(base_ref.as_deref(), &journal, since.as_deref())?; } Cmd::Ai { intent, catalog, prefix, base_ref, bus, state_root } => { run_ai( &intent, &catalog, prefix.as_deref(), base_ref.as_deref(), bus.as_deref(), state_root.as_deref(), &cli.store, )?; } Cmd::Ctl { line, fifo } => { run_ctl(&line, &fifo)?; } } Ok(()) } /// Lee y deserializa un `.swm` desde disco; falla con mensaje útil si no parsea. fn load_swm(file: &str) -> anyhow::Result { let yaml = std::fs::read_to_string(file) .map_err(|e| anyhow::anyhow!("no pude leer {file}: {e}"))?; hammer_core::Swm::from_yaml(&yaml) .map_err(|e| anyhow::anyhow!("no pude parsear {file}: {e}")) } /// Lee una `BaseRef` local desde JSON; usada por `apply` y `swm verify` para verificar la /// compatibilidad de pins. Si la ruta no existe, devuelve `Ok(None)` para que el caller /// pueda decidir entre fallar o seguir con un warning. fn load_local_base(path: Option<&std::path::Path>) -> anyhow::Result> { let Some(p) = path else { return Ok(None) }; if !p.is_file() { return Err(anyhow::anyhow!("base-ref {} no existe", p.display())); } let bytes = std::fs::read(p)?; let base: hammer_core::BaseRef = serde_json::from_slice(&bytes).map_err(|e| anyhow::anyhow!("base-ref inválido: {e}"))?; Ok(Some(base)) } fn print_base_compat(c: &hammer_core::BaseCompat) { use hammer_core::BaseCompat::*; match c { Ok => eprintln!("verify_base: OK"), DistroMismatch { local, swm } => eprintln!( "verify_base: distro_version no coincide — local={local} swm={swm}" ), PinMismatch(diffs) => { eprintln!("verify_base: {} pin(s) divergente(s):", diffs.len()); for d in diffs { let local = d.local.as_deref().unwrap_or("(ausente)"); eprintln!(" {}: local={local} swm={}", d.name, d.swm); } } } } fn run_swm_verify(file: &str, base_ref: Option<&std::path::Path>) -> anyhow::Result<()> { let swm = load_swm(file)?; swm.verify_schema() .map_err(|e| anyhow::anyhow!("schema inválido: {e}"))?; println!("schema: OK ({} mutación(es))", swm.mutations.len()); // Verificación de hash de los file_drop inline (no toca disco). for (i, m) in swm.mutations.iter().enumerate() { if let hammer_core::Mutation::FileDrop { content_b64: Some(b64), content_hash, path, .. } = m { hammer_core::apply::verify_content_hash(b64, content_hash).map_err(|e| { anyhow::anyhow!("file_drop #{} en {path}: {e}", i + 1) })?; } } println!("file_drop inline: OK"); if let Some(local) = load_local_base(base_ref)? { print_base_compat(&swm.verify_base(&local)); } else { eprintln!("verify_base: omitido (sin --base-ref)"); } 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>, ) -> anyhow::Result<()> { let swm = load_swm(file)?; swm.verify_schema()?; if let Some(local) = load_local_base(base_ref)? { match swm.verify_base(&local) { hammer_core::BaseCompat::Ok => {} other => { print_base_compat(&other); anyhow::bail!("base local no compatible con la base del .swm"); } } } let mut overlay_id: Option = None; if prefix.is_none() { let root = state_root .map(|p| p.to_path_buf()) .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT)); std::fs::create_dir_all(&root)?; let id = hammer_overlay::try_overlay(&[], &root)?; eprintln!("overlay {id} abierto"); overlay_id = Some(id); } let mut counts = (0usize, 0usize, 0usize, 0usize); // src_patch, cfg, init, drop for (i, m) in swm.mutations.iter().enumerate() { match m { hammer_core::Mutation::SourcePatch { target_bin, .. } => { counts.0 += 1; if skip_source_patch { eprintln!(" [skip] source_patch #{} → {target_bin}", i + 1); continue; } let store = hammer_core::Store::open(store_path)?; let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root()); let hash = hammer_build::build_source_patch(m, &cfg, &store, None)?; let artifact = store.find_by_hash(hash.as_str())?; let into = hammer_core::apply::rebase_path("/", prefix); let report = hammer_build::run_hydrate( &artifact, &into, hammer_core::LinkMode::Static, )?; eprintln!( " source_patch #{}: {} → hidratados {} archivo(s) en {}", i + 1, hash, report.files.len(), into.display() ); // Sanity: el target_bin declarado debe existir tras la hidratación. let abs = hammer_core::apply::rebase_path(target_bin, prefix); if !abs.exists() { anyhow::bail!( "source_patch declara target_bin={target_bin} pero no quedó en {}", abs.display() ); } } hammer_core::Mutation::ConfigEdit { file: f, inline_diff } => { counts.1 += 1; let target = hammer_core::apply::rebase_path(f, prefix); hammer_core::apply::apply_config_edit(&target, inline_diff)?; eprintln!(" config_edit #{} → {}", i + 1, target.display()); } hammer_core::Mutation::InitRule { action, service, .. } => { counts.2 += 1; eprintln!( " [pendiente fase 5] init_rule #{}: {action} {service} — ignorando", i + 1 ); } hammer_core::Mutation::FileDrop { path, content_hash, content_b64, content_url } => { counts.3 += 1; let target = hammer_core::apply::rebase_path(path, prefix); match (content_b64, content_url) { (Some(b64), None) => { hammer_core::apply::apply_file_drop(&target, b64, content_hash)?; eprintln!(" file_drop #{} → {}", i + 1, target.display()); } (None, Some(url)) => { anyhow::bail!( "file_drop #{} usa content_url={url}: fetch remoto pendiente", i + 1 ); } _ => 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(()) } /// Exporta el diario actual como `.swm`. Estrategia honesta para Fase 4: /// /// - Cada evento se traduce a un `file_drop` con `content_b64` (lo que hay en disco *ahora*) /// y `content_hash`. Esto garantiza reproducción byte-a-byte sin depender de tener el /// recipe original. La pérdida es que perdemos provenance — el receptor obtiene el binario /// pero no la receta que lo produjo. Para `source_patch` real necesitaremos un mapa /// artefacto→receta que la Fase 4 no construye todavía. /// - Mutaciones opacas (`Source::External`) llevan un `note` indicando "origen externo" /// para que el receptor sepa que no hay garantía de provenance. /// - Mutaciones con `op = Delete` se omiten: un `.swm` describe el estado final por /// adición/edición, no por borrado. Es un compromiso consciente (un futuro `FileRemove` /// resolvería esto). fn run_export( base_ref_path: Option<&std::path::Path>, journal_dir: &std::path::Path, since: Option<&str>, ) -> anyhow::Result<()> { use base64::{engine::general_purpose::STANDARD, Engine as _}; use std::collections::BTreeMap; let j = hammer_journal::Journal::open(journal_dir)?; let events = j.read_all()?; let filtered: Vec<_> = match since { Some(t) => events.into_iter().filter(|e| e.ts.as_str() > t).collect(), None => events, }; let base = match load_local_base(base_ref_path)? { Some(local) => hammer_core::Base { distro_version: local.distro_version, pins: local.pins, }, None => { eprintln!("warning: sin --base-ref; emitiendo base con distro_version='unknown'"); hammer_core::Base { distro_version: "unknown".into(), pins: BTreeMap::new(), } } }; // Dedup por path: dentro de un export queremos un único estado final por archivo. // Procesamos en orden y nos quedamos con la última ocurrencia. let mut last_by_path: std::collections::BTreeMap = std::collections::BTreeMap::new(); for ev in &filtered { if matches!(ev.op, hammer_journal::MutationOp::Delete) { last_by_path.remove(&ev.path); // borrado anula cualquier estado previo continue; } last_by_path.insert(ev.path.clone(), ev); } let mut mutations = Vec::new(); let mut warnings = 0usize; for (path, ev) in &last_by_path { let bytes = match std::fs::read(path) { Ok(b) => b, Err(e) => { warnings += 1; eprintln!( "warning: no pude leer {} para exportar (ev ts={}): {e}", path.display(), ev.ts ); continue; } }; let h = hammer_core::ArtifactHash::of_inputs(&[bytes.as_slice()]); let b64 = STANDARD.encode(&bytes); let note = match &ev.by.source { hammer_journal::Source::External => Some(format!( "origen externo (pid={:?}, uid={:?}) — sin provenance", ev.by.pid, ev.by.uid )), hammer_journal::Source::HammerHydrate { artifact } => { Some(format!("hydrate {artifact}")) } hammer_journal::Source::HammerCommit { overlay, artifact } => match artifact { Some(a) => Some(format!("commit {overlay} ({a})")), None => Some(format!("commit {overlay}")), }, }; // El note vive en el journal, no en el schema del file_drop — lo omitimos en el // YAML emitido por ahora; está en stderr (warnings) si era external. let _ = note; mutations.push(hammer_core::Mutation::FileDrop { path: path.display().to_string(), content_hash: h.as_str().to_string(), content_b64: Some(b64), content_url: None, }); } let swm = hammer_core::Swm { swm_version: 1, base, mutations, signature: None, }; let yaml = swm.to_yaml()?; print!("{yaml}"); eprintln!( "export: {} mutación(es), {} warning(s)", swm.mutations.len(), warnings ); Ok(()) } /// 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: &std::path::Path, 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, ) -> anyhow::Result<()> { use hammer_agent::{ orchestrator::{ApplyTarget, CompileMode}, 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}"))?; // BaseRef: si se da, leerlo; si no, fabricar uno permisivo desde la base del catálogo // (la usaremos sólo para verify_base, que tolera pins extra en local). let base = match load_local_base(base_ref)? { Some(b) => b, None => { // Tomamos la base del swm del intent (si existe) como referencia local. let ctx_swm = cat .intents .iter() .find(|e| e.intent == intent) .ok_or_else(|| anyhow::anyhow!("intent '{intent}' no está en el catálogo"))? .swm_inline .clone() .or_else(|| { cat.intents .iter() .find(|e| e.intent == intent) .and_then(|e| e.swm_path.as_ref()) .and_then(|p| std::fs::read_to_string(base_dir.join(p)).ok()) .and_then(|t| hammer_core::Swm::from_yaml(&t).ok()) }); match ctx_swm { Some(s) => hammer_core::BaseRef::from(&s.base), None => anyhow::bail!("no pude inferir BaseRef sin --base-ref"), } } }; let apply = match prefix { Some(p) => ApplyTarget::Prefix { prefix: p.to_path_buf() }, None => ApplyTarget::Overlay { state_root: state_root .map(|p| p.to_path_buf()) .unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT)), }, }; let compile = match bus { Some(s) => CompileMode::ViaBus { sock: s.to_path_buf(), store: PathBuf::from(store_root), timeout: std::time::Duration::from_secs(600), }, None => CompileMode::Skip, }; let orch = Orchestrator::new(translator, base, apply, compile); let proposal = orch .run(intent) .map_err(|e| anyhow::anyhow!("orchestrator: {e}"))?; 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); } 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(()) } /// 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(()) }