//! `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 5] Envía un comando al init (proxy a /run/init.control). Ctl { line: String }, } 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::Ctl { line } => { println!("[fase 5 pendiente] ctl {line:?} — ver docs/07-agent-bus.md"); } } 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(()) }