//! `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 (reproduce y lo deja en un overlay). Apply { file: String }, /// [Fase 4] Exporta el delta del sistema como manifiesto .swm. Export { #[arg(long)] base: 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 } => { println!("[fase 4 pendiente] apply {file} — ver docs/06-swm-format.md"); } Cmd::Export { base } => { println!("[fase 4 pendiente] export base={base:?} — ver docs/05-journal.md"); } Cmd::Ctl { line } => { println!("[fase 5 pendiente] ctl {line:?} — ver docs/07-agent-bus.md"); } } Ok(()) }