chore: monorepo inicial con arje + minga + yahweh absorbidos
Workspace en 4 ejes (core/modules/apps/shared):
- core/: 24 crates de arje (Init systemd-compatible: ente-card, ente-zero,
ente-kernel, ente-bus, ente-cas, ente-soma, ente-wasm, ente-snapshot,
ente-brain, ente-echo, ente-policy-provider, + 12 crates *-compat)
- modules/semantic_dht/: 5 crates de minga (minga-core con AST/CAS/MST,
minga-p2p con libp2p Kad, minga-store, minga-vfs, minga-cli)
- modules/ui_engine/: 11 crates de yahweh (libs/{core,theme,bus,providers},
widgets/{tree,splitter,tabs,tiled,container_core,text_input})
- apps/: 5 crates de yahweh (file_explorer, database_explorer, text_viewer,
image_viewer, yahweh-shell)
- shared_wit/protocol.wit: handshake/lifecycle inicial
Cargo.toml unificado: thiserror bumped a 2 (transparente para arje), tokio
"full", paths intra-workspace de yahweh redirigidos a su nueva ubicación.
cargo check --workspace: 0 errores, 17 warnings (dead code preexistente).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
//! Implementaciones de los subcomandos. Funciones puras que retornan
|
||||
//! datos estructurados — el binario las llama y formatea la salida.
|
||||
//!
|
||||
//! Layout en disco bajo `repo_path/`:
|
||||
//! - `keypair` — la `Keypair` del peer cifrada con passphrase.
|
||||
//! - `repo/` — directorio sled con `nodes`, `attestations`, `mst`.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use libp2p::{multiaddr::Protocol, Multiaddr, PeerId};
|
||||
use minga_core::{parse, Attestation, ContentHash, Did, Keypair};
|
||||
use minga_p2p::MingaPeer;
|
||||
use minga_store::{keypair_file, PersistentRepo};
|
||||
|
||||
use crate::error::CliError;
|
||||
|
||||
pub const KEYPAIR_FILENAME: &str = "keypair";
|
||||
pub const REPO_DIRNAME: &str = "repo";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RepoStatus {
|
||||
pub did: Did,
|
||||
pub mst_len: usize,
|
||||
pub nodes_len: usize,
|
||||
pub attestations_len: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IngestResult {
|
||||
pub hash: ContentHash,
|
||||
pub did: Did,
|
||||
}
|
||||
|
||||
/// `minga init`: genera un keypair fresco, crea el repo persistente,
|
||||
/// y guarda el keypair cifrado.
|
||||
pub fn cmd_init(repo_path: &Path, passphrase: &str) -> Result<Did, CliError> {
|
||||
if repo_path.exists() {
|
||||
// Si el directorio existe pero está vacío, lo aceptamos.
|
||||
// Si tiene cualquier cosa, abortamos para no pisar un repo.
|
||||
let mut entries = fs::read_dir(repo_path)?;
|
||||
if entries.next().is_some() {
|
||||
return Err(CliError::AlreadyExists(repo_path.to_path_buf()));
|
||||
}
|
||||
} else {
|
||||
fs::create_dir_all(repo_path)?;
|
||||
}
|
||||
|
||||
let keypair = Keypair::generate();
|
||||
keypair_file::save(&keypair, repo_path.join(KEYPAIR_FILENAME), passphrase)?;
|
||||
|
||||
// Crear el repo sled vacío. Se cierra al final del scope; el
|
||||
// siguiente comando lo reabre.
|
||||
let _ = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
|
||||
|
||||
Ok(keypair.did())
|
||||
}
|
||||
|
||||
/// `minga status`: descifra el keypair, abre el repo, devuelve
|
||||
/// estadísticas básicas.
|
||||
pub fn cmd_status(repo_path: &Path, passphrase: &str) -> Result<RepoStatus, CliError> {
|
||||
let keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
|
||||
let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
|
||||
|
||||
Ok(RepoStatus {
|
||||
did: keypair.did(),
|
||||
mst_len: repo.mst.len(),
|
||||
nodes_len: repo.nodes.len(),
|
||||
attestations_len: repo.attestations.len(),
|
||||
})
|
||||
}
|
||||
|
||||
/// `minga ingest <file>`: parsea un archivo Rust con tree-sitter,
|
||||
/// inserta el AST en el store, lo añade al MST, y crea una atestación
|
||||
/// firmada por el dueño del keypair (auto-firma de autoría).
|
||||
pub fn cmd_ingest(
|
||||
repo_path: &Path,
|
||||
passphrase: &str,
|
||||
file: &Path,
|
||||
) -> Result<IngestResult, CliError> {
|
||||
let keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
|
||||
let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
|
||||
|
||||
let source = fs::read_to_string(file)?;
|
||||
let node = parse::rust(&source)?;
|
||||
let hash = repo.nodes.put(&node)?;
|
||||
repo.mst.insert(hash)?;
|
||||
repo.attestations
|
||||
.add(Attestation::create(&keypair, hash))?;
|
||||
repo.flush()?;
|
||||
|
||||
Ok(IngestResult {
|
||||
hash,
|
||||
did: keypair.did(),
|
||||
})
|
||||
}
|
||||
|
||||
/// `minga listen <addr>`: arranca el peer, escucha en `addr`, y
|
||||
/// acepta sincronizaciones entrantes hasta que el proceso se cierre.
|
||||
pub async fn cmd_listen(
|
||||
repo_path: &Path,
|
||||
passphrase: &str,
|
||||
addr: &str,
|
||||
) -> Result<Multiaddr, CliError> {
|
||||
let keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
|
||||
let did = keypair.did();
|
||||
let peer = MingaPeer::open(keypair, repo_path.join(REPO_DIRNAME))?;
|
||||
let multi: Multiaddr = addr
|
||||
.parse()
|
||||
.map_err(|e: libp2p::multiaddr::Error| CliError::Multiaddr(e.to_string()))?;
|
||||
let actual = peer.listen(multi).await;
|
||||
let _accept = peer.run_passive_accept();
|
||||
|
||||
// Bloqueamos para siempre mientras la task de accept procesa
|
||||
// sincronizaciones. El usuario cierra con Ctrl+C.
|
||||
println!("Escuchando en: {}", actual);
|
||||
println!("DID Minga: {}", did);
|
||||
println!("PeerID libp2p: {}", peer.peer_id());
|
||||
futures::future::pending::<()>().await;
|
||||
|
||||
Ok(actual)
|
||||
}
|
||||
|
||||
/// `minga sync <multiaddr>`: dializa al peer y ejecuta una
|
||||
/// sincronización completa con él.
|
||||
pub async fn cmd_sync(
|
||||
repo_path: &Path,
|
||||
passphrase: &str,
|
||||
target: &str,
|
||||
) -> Result<(), CliError> {
|
||||
let keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
|
||||
let peer = MingaPeer::open(keypair, repo_path.join(REPO_DIRNAME))?;
|
||||
|
||||
let multi: Multiaddr = target
|
||||
.parse()
|
||||
.map_err(|e: libp2p::multiaddr::Error| CliError::Multiaddr(e.to_string()))?;
|
||||
let peer_id = extract_peer_id(&multi).ok_or(CliError::NoPeerIdInMultiaddr)?;
|
||||
|
||||
peer.dial(multi);
|
||||
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
if peer.sync_with(peer_id).await.is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return Err(CliError::SyncTimeout);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_peer_id(addr: &Multiaddr) -> Option<PeerId> {
|
||||
addr.iter().find_map(|p| match p {
|
||||
Protocol::P2p(peer_id) => Some(peer_id),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `minga watch <dir>`: vigila un directorio, re-parsea y re-ingesta
|
||||
/// cualquier archivo `.rs` que se cree o modifique. Convierte Minga en
|
||||
/// un VCS de fondo: el usuario escribe en su editor habitual y el
|
||||
/// código queda versionado y firmado en el repo automáticamente.
|
||||
pub async fn cmd_watch(
|
||||
repo_path: &Path,
|
||||
passphrase: &str,
|
||||
watch_dir: &Path,
|
||||
) -> Result<(), CliError> {
|
||||
let keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
|
||||
let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
|
||||
|
||||
// Pasada inicial: ingerimos todos los .rs ya presentes para que
|
||||
// el repo arranque sincronizado con el contenido actual del
|
||||
// directorio (no solo con cambios futuros).
|
||||
initial_scan(&repo, &keypair, watch_dir);
|
||||
|
||||
// Canal entre el callback síncrono de notify y el bucle async.
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut watcher = notify::recommended_watcher(
|
||||
move |res: Result<notify::Event, notify::Error>| {
|
||||
if let Ok(event) = res {
|
||||
let _ = tx.send(event);
|
||||
}
|
||||
},
|
||||
)?;
|
||||
notify::Watcher::watch(&mut watcher, watch_dir, notify::RecursiveMode::Recursive)?;
|
||||
|
||||
while let Some(event) = rx.recv().await {
|
||||
if !is_relevant_event(&event) {
|
||||
continue;
|
||||
}
|
||||
for path in &event.paths {
|
||||
if is_rs_file(path) {
|
||||
match ingest_into_repo(&repo, &keypair, path) {
|
||||
Ok(hash) => {
|
||||
eprintln!("ingerido: {} → {}", path.display(), hash);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("warning: {} no se pudo ingerir: {}", path.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn initial_scan(repo: &PersistentRepo, keypair: &Keypair, dir: &Path) {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let p = entry.path();
|
||||
if is_rs_file(&p) {
|
||||
let _ = ingest_into_repo(repo, keypair, &p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ingest_into_repo(
|
||||
repo: &PersistentRepo,
|
||||
keypair: &Keypair,
|
||||
file: &Path,
|
||||
) -> Result<ContentHash, CliError> {
|
||||
let source = fs::read_to_string(file)?;
|
||||
let node = parse::rust(&source)?;
|
||||
let hash = repo.nodes.put(&node)?;
|
||||
repo.mst.insert(hash)?;
|
||||
repo.attestations
|
||||
.add(Attestation::create(keypair, hash))?;
|
||||
repo.flush()?;
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
fn is_rs_file(path: &Path) -> bool {
|
||||
path.extension().and_then(|e| e.to_str()) == Some("rs") && path.is_file()
|
||||
}
|
||||
|
||||
fn is_relevant_event(event: ¬ify::Event) -> bool {
|
||||
matches!(
|
||||
event.kind,
|
||||
notify::EventKind::Create(_) | notify::EventKind::Modify(_)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CliError {
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("keypair file: {0}")]
|
||||
KeypairFile(#[from] minga_store::KeypairFileError),
|
||||
|
||||
#[error("store: {0}")]
|
||||
Store(#[from] minga_store::StoreError),
|
||||
|
||||
#[error("attestation: {0}")]
|
||||
Attestation(#[from] minga_core::AttestationError),
|
||||
|
||||
#[error("parse: {0}")]
|
||||
Parse(#[from] minga_core::parse::ParseError),
|
||||
|
||||
#[error("network: {0}")]
|
||||
Network(#[from] minga_p2p::NodeError),
|
||||
|
||||
#[error("peer open: {0}")]
|
||||
PeerOpen(#[from] minga_p2p::PeerOpenError),
|
||||
|
||||
#[error("peer sync: {0}")]
|
||||
PeerSync(#[from] minga_p2p::PeerSyncError),
|
||||
|
||||
#[error("multiaddr inválido: {0}")]
|
||||
Multiaddr(String),
|
||||
|
||||
#[error("el directorio del repo ya existe: {0}")]
|
||||
AlreadyExists(PathBuf),
|
||||
|
||||
#[error("el multiaddr no incluye `/p2p/<peer_id>`")]
|
||||
NoPeerIdInMultiaddr,
|
||||
|
||||
#[error("timeout esperando conexión")]
|
||||
SyncTimeout,
|
||||
|
||||
#[error("notify (file watcher): {0}")]
|
||||
Notify(#[from] notify::Error),
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! `minga-cli`: subcomandos del CLI de Minga.
|
||||
//!
|
||||
//! La CLI expone funciones puras (`commands`) que retornan `Result`
|
||||
//! con la información estructurada. El binario `minga` (en `main.rs`)
|
||||
//! solo parsea argumentos, prompts de passphrase, y formatea la
|
||||
//! salida. Esa separación hace los comandos directamente testeables
|
||||
//! sin spawn de subprocesos.
|
||||
|
||||
pub mod commands;
|
||||
pub mod error;
|
||||
|
||||
pub use commands::{
|
||||
cmd_ingest, cmd_init, cmd_listen, cmd_status, cmd_sync, cmd_watch, IngestResult, RepoStatus,
|
||||
};
|
||||
pub use error::CliError;
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Binario `minga`: argument parsing y formateo de salida.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use minga_cli::{
|
||||
cmd_ingest, cmd_init, cmd_listen, cmd_status, cmd_sync, cmd_watch, CliError,
|
||||
};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "minga",
|
||||
version,
|
||||
about = "Minga: VCS semántico P2P. Versiona AST, no líneas."
|
||||
)]
|
||||
struct Cli {
|
||||
/// Ruta del repositorio Minga. Por defecto: `.minga` en el cwd.
|
||||
#[arg(short, long, default_value = ".minga", global = true)]
|
||||
repo: PathBuf,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Inicializa un nuevo repo: genera keypair Ed25519, lo cifra con
|
||||
/// passphrase, y crea el almacén persistente vacío.
|
||||
Init,
|
||||
|
||||
/// Muestra DID, tamaño del MST, nodos almacenados y atestaciones.
|
||||
Status,
|
||||
|
||||
/// Parsea un archivo Rust, lo añade al MST y firma una atestación
|
||||
/// de autoría con la identidad del repo.
|
||||
Ingest {
|
||||
/// Ruta del archivo .rs a ingerir.
|
||||
file: PathBuf,
|
||||
},
|
||||
|
||||
/// Escucha conexiones de peers en una multiaddr libp2p y acepta
|
||||
/// sincronizaciones entrantes hasta Ctrl+C.
|
||||
Listen {
|
||||
/// Multiaddr libp2p, ej. `/ip4/0.0.0.0/tcp/4001`.
|
||||
addr: String,
|
||||
},
|
||||
|
||||
/// Sincroniza una vez con un peer remoto (multiaddr con `/p2p/<id>`).
|
||||
Sync {
|
||||
/// Multiaddr completo, ej. `/ip4/1.2.3.4/tcp/4001/p2p/12D3KooW...`.
|
||||
peer: String,
|
||||
},
|
||||
|
||||
/// Vigila un directorio y re-ingiere automáticamente cualquier
|
||||
/// archivo `.rs` que se cree o modifique. Minga como VCS de fondo:
|
||||
/// el usuario escribe en su editor y el código queda versionado.
|
||||
Watch {
|
||||
/// Directorio a vigilar.
|
||||
dir: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("error: {}", e);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<(), CliError> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Command::Init => {
|
||||
let pass = prompt_passphrase_with_confirm()?;
|
||||
let did = cmd_init(&cli.repo, &pass)?;
|
||||
println!("Repo inicializado en {}", cli.repo.display());
|
||||
println!("DID: {}", did);
|
||||
}
|
||||
Command::Status => {
|
||||
let pass = prompt_passphrase()?;
|
||||
let s = cmd_status(&cli.repo, &pass)?;
|
||||
println!("DID: {}", s.did);
|
||||
println!("MST: {} claves", s.mst_len);
|
||||
println!("Nodos almacenados: {}", s.nodes_len);
|
||||
println!("Atestaciones: {}", s.attestations_len);
|
||||
}
|
||||
Command::Ingest { file } => {
|
||||
let pass = prompt_passphrase()?;
|
||||
let r = cmd_ingest(&cli.repo, &pass, &file)?;
|
||||
println!("Ingerido: {}", file.display());
|
||||
println!("Hash: {}", r.hash);
|
||||
println!("Firmado por: {}", r.did);
|
||||
}
|
||||
Command::Listen { addr } => {
|
||||
let pass = prompt_passphrase()?;
|
||||
let rt = tokio::runtime::Runtime::new()
|
||||
.map_err(|e| CliError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
|
||||
rt.block_on(cmd_listen(&cli.repo, &pass, &addr))?;
|
||||
}
|
||||
Command::Sync { peer } => {
|
||||
let pass = prompt_passphrase()?;
|
||||
let rt = tokio::runtime::Runtime::new()
|
||||
.map_err(|e| CliError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
|
||||
rt.block_on(cmd_sync(&cli.repo, &pass, &peer))?;
|
||||
println!("Sync completo.");
|
||||
}
|
||||
Command::Watch { dir } => {
|
||||
let pass = prompt_passphrase()?;
|
||||
println!("Vigilando {}. Ctrl+C para parar.", dir.display());
|
||||
let rt = tokio::runtime::Runtime::new()
|
||||
.map_err(|e| CliError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
|
||||
rt.block_on(cmd_watch(&cli.repo, &pass, &dir))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prompt_passphrase() -> Result<String, CliError> {
|
||||
let pass = rpassword::prompt_password("Passphrase: ")
|
||||
.map_err(CliError::Io)?;
|
||||
Ok(pass)
|
||||
}
|
||||
|
||||
fn prompt_passphrase_with_confirm() -> Result<String, CliError> {
|
||||
let pass = rpassword::prompt_password("Passphrase nueva: ")
|
||||
.map_err(CliError::Io)?;
|
||||
let conf = rpassword::prompt_password("Confirma: ")
|
||||
.map_err(CliError::Io)?;
|
||||
if pass != conf {
|
||||
return Err(CliError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"passphrases no coinciden",
|
||||
)));
|
||||
}
|
||||
Ok(pass)
|
||||
}
|
||||
Reference in New Issue
Block a user