From 9c361fdb17fd6f463eca61529aba5f4e127a5438 Mon Sep 17 00:00:00 2001 From: Louis Thibault Date: Sun, 12 Apr 2026 19:30:27 -0400 Subject: [PATCH 1/3] fix: make http-client capability optional in kernel The kernel was hard-requiring 'http-client' from the membrane graft, but the daemon doesn't pass --http-dial. Session.http_client is now Option<...>, and make_host_handler returns an error when the cap is requested but unavailable. The graft loop gracefully skips http-client if the Session has None instead of panicking. --- std/kernel/src/lib.rs | 69 +++++++++++++++++++++++++++---------------- 1 file changed, 43 insertions(+), 26 deletions(-) diff --git a/std/kernel/src/lib.rs b/std/kernel/src/lib.rs index 6cae034..74ccde9 100644 --- a/std/kernel/src/lib.rs +++ b/std/kernel/src/lib.rs @@ -98,7 +98,8 @@ struct Session { /// /// Domain-scoped proxy — the host checks URL host against an allowlist. /// Exposed to glia scripts via `(perform host :http-client)`. - http_client: http_capnp::http_client::Client, + /// `None` when the operator did not pass `--http-dial`. + http_client: Option, cwd: String, } @@ -276,7 +277,7 @@ fn call_resume(resume: &Val, val: Val) -> Result { fn make_host_handler( host: system_capnp::host::Client, runtime: system_capnp::runtime::Client, - http_client: http_capnp::http_client::Client, + http_client: Option, ) -> Val { Val::AsyncNativeFn { name: "host-handler".into(), @@ -597,10 +598,15 @@ fn make_host_handler( "http-client" => { // (perform host :http-client) → Val::Cap wrapping HttpClient // Future: parse :allow and :rate kwargs from rest - Val::Cap { - name: "http".into(), - schema_cid: schema_ids::HTTP_CLIENT_CID.to_string(), - inner: Rc::new(http_client.clone()), + match &http_client { + Some(c) => Val::Cap { + name: "http".into(), + schema_cid: schema_ids::HTTP_CLIENT_CID.to_string(), + inner: Rc::new(c.clone()), + }, + None => return Err(Val::from( + "http-client not available (node started without --http-dial)", + )), } } _ => return Err(Val::from(format!("host: unknown method :{method}"))), @@ -1331,7 +1337,8 @@ fn run_impl() { let runtime: system_capnp::runtime::Client = get_graft_cap(&caps, "runtime")?; let routing: routing_capnp::routing::Client = get_graft_cap(&caps, "routing")?; let identity: stem_capnp::identity::Client = get_graft_cap(&caps, "identity")?; - let http_client: http_capnp::http_client::Client = get_graft_cap(&caps, "http-client")?; + let http_client: Option = + get_graft_cap(&caps, "http-client").ok(); let ctx = RefCell::new(Session { host: host.clone(), @@ -1385,13 +1392,21 @@ fn run_impl() { // Glia effect handler — skip env binding. continue; } - "http-client" => ( - schema_ids::HTTP_CLIENT_CID, - Rc::new(s.http_client.clone()), - // No standalone handler — http-client is accessed - // via (perform host :http-client). - Val::Nil, - ), + "http-client" => { + match s.http_client.clone() { + Some(c) => ( + schema_ids::HTTP_CLIENT_CID, + Rc::new(c), + // No standalone handler — http-client is accessed + // via (perform host :http-client). + Val::Nil, + ), + None => { + log::warn!("graft: host sent 'http-client' but Session has None, skipping"); + continue; + } + } + } other => { log::warn!("graft: unknown cap '{other}', skipping"); continue; @@ -1955,7 +1970,7 @@ mod tests { runtime: capnp_rpc::new_client(TestRuntime), routing: capnp_rpc::new_client(TestRouting), identity: capnp_rpc::new_client(TestIdentity), - http_client: capnp_rpc::new_client(TestHttpClient), + http_client: Some(capnp_rpc::new_client(TestHttpClient)), cwd: "/".into(), } } @@ -2529,15 +2544,17 @@ mod tests { env.set(format!("{name}-handler"), handler); } - // http-client with real capnp client. - env.set( - "http-client".to_string(), - Val::Cap { - name: "http-client".into(), - schema_cid: "test-http-cid".into(), - inner: Rc::new(session.http_client.clone()), - }, - ); + // http-client with real capnp client (tests always provide one). + if let Some(ref c) = session.http_client { + env.set( + "http-client".to_string(), + Val::Cap { + name: "http-client".into(), + schema_cid: "test-http-cid".into(), + inner: Rc::new(c.clone()), + }, + ); + } } /// Verify that (perform :load "path") inside wrap_with_handlers @@ -2718,7 +2735,7 @@ mod tests { "should extract routing client" ); // HttpClient - let inner: Rc = Rc::new(s.http_client.clone()); + let inner: Rc = Rc::new(s.http_client.clone().unwrap()); assert!( extract_capnp_client(&inner).is_some(), "should extract http_client" @@ -2745,7 +2762,7 @@ mod tests { let http_cap = Val::Cap { name: "http".into(), schema_cid: "test-http-cid".into(), - inner: Rc::new(s.http_client.clone()), + inner: Rc::new(s.http_client.clone().unwrap()), }; let cell = Val::Cell { wasm: b"fake-wasm".to_vec(), From d495cb662705832dd64bed3ad1b4f80974fd81f1 Mon Sep 17 00:00:00 2001 From: Louis Thibault Date: Sun, 12 Apr 2026 19:30:58 -0400 Subject: [PATCH 2/3] feat: add `ww perform update` and refactor install/upgrade Extract perform_update() from perform_install(). The update command refreshes WASM images (CID comparison), republishes stdlib, regenerates daemon config + service file, restarts the daemon if images changed, and re-wires MCP. install now detects ~/.ww and delegates to update if already bootstrapped. upgrade calls update after binary replacement. Also fixes: daemon image layers no longer include MCP (its bin/main.wasm was clobbering the kernel entry point). --- CHANGELOG.md | 9 + src/cli/main.rs | 471 ++++++++++++++++++++++++++---------------------- 2 files changed, 268 insertions(+), 212 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1c5322..5ff0398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,16 +6,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [0.0.1.0] - 2026-04-12 + ### Added +- `ww perform update` refreshes WASM images, daemon config, service file, and MCP wiring to match the current binary. Safe to run repeatedly. Does not touch identity or directory structure. - `ww shell` now auto-discovers local nodes via Kubo's LAN DHT when no address is given. The daemon advertises a well-known discovery CID; the shell queries Kubo's `findprovs` API to find it. - `ww shell` accepts `/dnsaddr/` multiaddrs (e.g. `ww shell /dnsaddr/master.wetware.run`). Address is now a positional argument instead of `--addr`. - Admin HTTP server (`--with-http-admin`) now exposes `GET /host/id` (peer ID) and `GET /host/addrs` (listen addresses). `MetricsService` renamed to `AdminService`. ### Changed +- `ww perform install` now detects an existing `~/.ww` and delegates to `perform_update` instead of re-running the full bootstrap. First-time install still creates directories, generates identity, provisions IPNS keys. +- `ww perform upgrade` now automatically runs `perform_update` after replacing the binary, so WASM images, daemon, and MCP wiring are refreshed without a manual step. +- WASM images use CID comparison (BLAKE3) instead of file-existence checks, so stale images from a previous install are always replaced. +- Daemon image layers no longer include the MCP cell. The MCP cell's `bin/main.wasm` was clobbering the kernel's entry point, causing the daemon to crash-loop. - Outbound HTTP access for cells now requires explicit `--http-dial` flag. No flag means no `http-client` capability. Supports exact hosts, subdomain globs (`*.example.com`), and `*` for unrestricted access. - Documentation overhaul: README rewritten with quick start, cell modes, AI integration, roadmap. CLI reference now covers all 12 commands. Architecture doc updated for `List(Export)` membrane, virtual WASI FS, state management, and distribution model. ### Fixed +- Daemon no longer crash-loops on startup. The MCP mount layer was overwriting the kernel binary; removed from daemon image layers. +- Kernel no longer crashes when `--http-dial` is not passed. The `http-client` capability is now optional in the membrane graft. - `host :listen` now gives a clear error when passed an undefined variable instead of a cell (e.g. when `load` fails). Previously showed misleading "runtime capability required". - IPFS release tree now includes all example WASM binaries (oracle, counter, chess, etc.), not just echo. Previously `make examples` built them but the CI artifact upload and publish steps dropped them, so `ww run /ipns//examples/oracle` failed with missing `bin/oracle.wasm`. diff --git a/src/cli/main.rs b/src/cli/main.rs index 5b2deaf..66f2618 100644 --- a/src/cli/main.rs +++ b/src/cli/main.rs @@ -331,11 +331,24 @@ enum PerformAction { /// 3. Optionally remove ~/.ww (prompts for confirmation) Uninstall, + /// Refresh WASM images, daemon, and MCP wiring to match this binary. + /// + /// Safe to run repeatedly. Does not touch identity or directory structure. + /// + /// Steps: + /// 1. Sync WASM images (CID compare, overwrite if changed) + /// 2. Republish standard library (if images changed and Kubo running) + /// 3. Regenerate daemon config and service file + /// 4. Restart daemon + /// 5. Re-wire MCP into Claude Code + Update, + /// Self-update the ww binary via IPNS. /// /// Resolves /ipns/releases.wetware.run/Cargo.toml to check for a /// newer version, then fetches the platform binary and atomically - /// replaces the running executable. + /// replaces the running executable, then runs `update` to refresh + /// WASM images, daemon, and MCP wiring. Upgrade { /// IPFS HTTP API endpoint. #[arg(long, default_value = "http://localhost:5001", env = "IPFS_API")] @@ -536,6 +549,7 @@ impl Commands { Commands::Perform { action } => match action { PerformAction::Install => Self::perform_install().await, PerformAction::Uninstall => Self::perform_uninstall().await, + PerformAction::Update => Self::perform_update().await, PerformAction::Upgrade { ipfs_url } => Self::perform_upgrade(ipfs_url).await, }, Commands::Doctor => Self::doctor().await, @@ -1898,69 +1912,47 @@ wasip2::cli::command::export!({iface_name}Guest); Ok(()) } - /// Bootstrap the ~/.ww user layer, daemon, and MCP wiring. - /// - /// Idempotent: re-running skips completed steps, retries failed ones. - async fn perform_install() -> Result<()> { + /// CIDv1 (raw codec, BLAKE3) for a byte slice. + fn wasm_cid(data: &[u8]) -> cid::Cid { + let digest = blake3::hash(data); + let mh = cid::multihash::Multihash::<64>::wrap(0x1e, digest.as_bytes()).unwrap(); + cid::Cid::new_v1(0x55, mh) + } + + /// Refresh WASM images, daemon config/service file, and MCP wiring + /// to match the current binary. Safe to run repeatedly. + async fn perform_update() -> Result<()> { use indicatif::{ProgressBar, ProgressStyle}; use std::time::Duration; - // Spinner for slow operations. Subtle, one line, clears on finish. let spin = || { let pb = ProgressBar::new_spinner(); pb.set_style( ProgressStyle::default_spinner() - .template(" ⚙ {msg}") + .template(" \u{2699} {msg}") .expect("valid template"), ); pb.enable_steady_tick(Duration::from_millis(80)); pb }; let done = |msg: String| println!(" \u{2713} {msg}"); - let skip = |msg: String| println!(" · {msg}"); + let skip = |msg: String| println!(" \u{00b7} {msg}"); let fail = |msg: String| println!(" \u{2717} {msg}"); let home = dirs::home_dir().context("Cannot determine home directory")?; let ww_dir = home.join(".ww"); - // ── Directories ────────────────────────────────────────────── - let subdirs = [ - "boot", - "bin", - "lib", - "etc/init.d", - "etc/ns", - "logs", - "kernel/bin", - "shell/bin", - "mcp/bin", - ]; - let mut any_created = false; - for sub in &subdirs { + if !ww_dir.exists() { + bail!("~/.ww does not exist. Run `ww perform install` first."); + } + + // Ensure subdirectories exist (may be missing if created by older version). + for sub in &["kernel/bin", "shell/bin", "mcp/bin", "logs", "etc/ns"] { let dir = ww_dir.join(sub); if !dir.exists() { - std::fs::create_dir_all(&dir) - .with_context(|| format!("Failed to create {}", dir.display()))?; - any_created = true; + std::fs::create_dir_all(&dir)?; } } - if any_created { - done("Directories".into()); - } else { - skip("Directories".into()); - } - - // ── Identity ───────────────────────────────────────────────── - let identity_path = ww_dir.join("identity"); - if identity_path.exists() { - skip("Identity".into()); - } else { - let sk = ww::keys::generate()?; - ww::keys::save(&sk, &identity_path)?; - let kp = ww::keys::to_libp2p(&sk)?; - let peer_id = kp.public().to_peer_id(); - done(format!("Identity ({peer_id})")); - } // ── WASM images ────────────────────────────────────────────── let image_cells: &[(&str, &str, &[u8])] = &[ @@ -1969,37 +1961,40 @@ wasip2::cli::command::export!({iface_name}Guest); ("mcp", "main.wasm", EMBEDDED_MCP), ]; let mut images_ok = true; + let mut any_images_changed = false; for (name, wasm_name, bytes) in image_cells { let wasm_dest = ww_dir.join(format!("{name}/bin/{wasm_name}")); if !bytes.is_empty() { - if !wasm_dest.exists() - || std::fs::metadata(&wasm_dest) - .map(|m| m.len() == 0) - .unwrap_or(true) - { + let embedded_cid = Self::wasm_cid(bytes); + let needs_write = !wasm_dest.exists() + || std::fs::read(&wasm_dest) + .map(|on_disk| Self::wasm_cid(&on_disk) != embedded_cid) + .unwrap_or(true); + if needs_write { std::fs::write(&wasm_dest, bytes) .with_context(|| format!("write {}", wasm_dest.display()))?; + any_images_changed = true; } } else { images_ok = false; } } - if images_ok { - done("WASM images".into()); - } else { + if !images_ok { skip("WASM images (not embedded, build from source)".into()); + } else if any_images_changed { + done("WASM images (updated)".into()); + } else { + skip("WASM images (unchanged)".into()); } - // ── Kubo probe ─────────────────────────────────────────────── + // ── Stdlib republish (if images changed + Kubo running) ────── let ipfs_client = ipfs::HttpClient::new("http://localhost:5001".into()); let kubo_ok = ipfs_client.kubo_info().await.is_ok(); - // ── Namespace + IPNS key + publish ─────────────────────────── { let ns_path = ww_dir.join("etc/ns/ww"); let std_cid = ww::namespace::WW_STD_CID; - // Read existing config or start fresh. let mut config = if ns_path.exists() { let content = std::fs::read_to_string(&ns_path)?; ww::ns::NamespaceConfig::parse("ww", &content) @@ -2011,94 +2006,62 @@ wasip2::cli::command::export!({iface_name}Guest); } }; - // Provision IPNS key if Kubo is available. - if kubo_ok { - let keys = ipfs_client.key_list().await.unwrap_or_default(); - if keys.iter().any(|k| k == "ww") { - skip("IPNS key".into()); - } else { - let sp = spin(); - sp.set_message("Generating IPNS key..."); - match ipfs_client.key_gen("ww").await { - Ok(id) => { - config.ipns = id.clone(); - sp.finish_and_clear(); - done(format!("IPNS key ({id})")); - } - Err(e) => { - sp.finish_and_clear(); - fail(format!("IPNS key ({e})")); + if kubo_ok && images_ok && any_images_changed { + let sp = spin(); + sp.set_message("Indexing standard library..."); + + let tmp = tempfile::TempDir::new()?; + let tree = tmp.path(); + let lib_dir = tree.join("lib/ww"); + std::fs::create_dir_all(&lib_dir)?; + std::fs::create_dir_all(tree.join("kernel/bin"))?; + std::fs::create_dir_all(tree.join("shell/bin"))?; + std::fs::create_dir_all(tree.join("mcp/bin"))?; + + // Copy Glia stdlib if present on disk. + let glia_src = std::path::Path::new("std/lib/ww"); + if glia_src.is_dir() { + for entry in std::fs::read_dir(glia_src)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("glia") { + if let Some(name) = path.file_name() { + std::fs::copy(&path, lib_dir.join(name))?; + } } } } - // Publish std to IPFS if images are available. - if images_ok { - let sp = spin(); - sp.set_message("Indexing standard library..."); - - // Assemble namespace tree in temp dir. - let tmp = tempfile::TempDir::new()?; - let tree = tmp.path(); - let lib_dir = tree.join("lib/ww"); - std::fs::create_dir_all(&lib_dir)?; - std::fs::create_dir_all(tree.join("kernel/bin"))?; - std::fs::create_dir_all(tree.join("shell/bin"))?; - std::fs::create_dir_all(tree.join("mcp/bin"))?; - - // Copy Glia stdlib if present on disk. - let glia_src = std::path::Path::new("std/lib/ww"); - if glia_src.is_dir() { - for entry in std::fs::read_dir(glia_src)? { - let entry = entry?; - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) == Some("glia") { - if let Some(name) = path.file_name() { - std::fs::copy(&path, lib_dir.join(name))?; - } - } - } + for (name, wasm_name, bytes) in image_cells { + if !bytes.is_empty() { + std::fs::write(tree.join(format!("{name}/bin/{wasm_name}")), bytes)?; } + } - // Copy embedded WASM into the tree. - for (name, wasm_name, bytes) in image_cells { - if !bytes.is_empty() { - std::fs::write(tree.join(format!("{name}/bin/{wasm_name}")), bytes)?; + match ipfs_client.add_dir(tree).await { + Ok(cid) => { + let ipfs_path = format!("/ipfs/{cid}"); + config.bootstrap = ipfs_path.clone(); + let _ = ipfs_client.pin_add(&ipfs_path).await; + if !config.ipns.is_empty() { + let _ = ipfs_client.name_publish(&ipfs_path, "ww").await; } + sp.finish_and_clear(); + done(format!("Standard library ({ipfs_path})")); } - - // Publish to IPFS. - match ipfs_client.add_dir(tree).await { - Ok(cid) => { - let ipfs_path = format!("/ipfs/{cid}"); - config.bootstrap = ipfs_path.clone(); - - // Pin for offline access. - let _ = ipfs_client.pin_add(&ipfs_path).await; - - // Publish to IPNS if we have a key. - if !config.ipns.is_empty() { - let _ = ipfs_client.name_publish(&ipfs_path, "ww").await; - } - - sp.finish_and_clear(); - done(format!("Standard library ({ipfs_path})")); - } - Err(e) => { - sp.finish_and_clear(); - fail(format!("Standard library ({e})")); - } + Err(e) => { + sp.finish_and_clear(); + fail(format!("Standard library ({e})")); } } - } else { - skip("IPNS (Kubo not running)".into()); + } else if !kubo_ok { + skip("Standard library (Kubo not running)".into()); + } else if !any_images_changed { + skip("Standard library (images unchanged)".into()); } - // Always write the config. config.write_to(&ns_path)?; - if config.ipns.is_empty() && config.bootstrap.is_empty() { - done("Namespace ww".into()); - } else { + if !config.bootstrap.is_empty() { let detail = if !config.ipns.is_empty() { format!("ipns={}", config.ipns) } else { @@ -2108,77 +2071,59 @@ wasip2::cli::command::export!({iface_name}Guest); } } - // ── Daemon ─────────────────────────────────────────────────── + // ── Daemon config + service file (unconditional) ───────────── let ww_bin = std::env::current_exe().context("cannot determine ww binary path")?; - let ww_bin_str = ww_bin.display().to_string(); + let identity_path = ww_dir.join("identity"); + // Only mount kernel and shell as root layers. The MCP cell + // is spawned separately by `ww run --mcp` and must NOT be a + // root layer — its bin/main.wasm would clobber the kernel's. + let image_layers: Vec = ["kernel", "shell"] + .iter() + .map(|name| ww_dir.join(name).display().to_string()) + .collect(); + Self::daemon_install(Some(identity_path), Some(2025), image_layers, true).await?; + done("Background daemon".into()); + // ── Restart daemon (only if images changed) ───────────────── let plist_path = home.join("Library/LaunchAgents/io.wetware.ww.plist"); let systemd_path = home.join(".config/systemd/user/ww.service"); - let daemon_exists = plist_path.exists() || systemd_path.exists(); - if daemon_exists { - skip("Background daemon".into()); - } else { - let sp = spin(); - sp.set_message("Registering daemon..."); - // Only mount kernel and shell as root layers. The MCP cell - // is spawned separately by `ww run --mcp` and must NOT be a - // root layer — its bin/main.wasm would clobber the kernel's. - let image_layers: Vec = ["kernel", "shell"] - .iter() - .map(|name| ww_dir.join(name).display().to_string()) - .collect(); - Self::daemon_install(Some(identity_path.clone()), Some(2025), image_layers, true) - .await?; - sp.finish_and_clear(); - done("Background daemon".into()); - } - - // ── Start daemon ───────────────────────────────────────────── - { - let already_running = if cfg!(target_os = "macos") { - std::process::Command::new("launchctl") - .args(["list", "io.wetware.ww"]) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } else { - std::process::Command::new("systemctl") - .args(["--user", "is-active", "--quiet", "ww"]) - .status() - .map(|s| s.success()) - .unwrap_or(false) - }; - - if already_running { - skip("Daemon running".into()); - } else if cfg!(target_os = "macos") && plist_path.exists() { - match std::process::Command::new("launchctl") - .args(["load", &plist_path.display().to_string()]) - .status() - { - Ok(s) if s.success() => done("Daemon started".into()), - _ => fail( - "Daemon start (try: launchctl load {})" - .replace("{}", &plist_path.display().to_string()), - ), - } - } else if cfg!(target_os = "linux") && systemd_path.exists() { - match std::process::Command::new("systemctl") - .args(["--user", "enable", "--now", "ww"]) - .status() - { - Ok(s) if s.success() => done("Daemon started".into()), - _ => fail("Daemon start (try: systemctl --user enable --now ww)".into()), - } - } else { - skip("Daemon start (no service file)".into()); + if !any_images_changed { + skip("Daemon restart (nothing changed)".into()); + } else if cfg!(target_os = "macos") && plist_path.exists() { + // Stop if running, then start. + let _ = std::process::Command::new("launchctl") + .args(["unload", &plist_path.display().to_string()]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + match std::process::Command::new("launchctl") + .args(["load", &plist_path.display().to_string()]) + .status() + { + Ok(s) if s.success() => done("Daemon restarted".into()), + _ => fail( + "Daemon start (try: launchctl load {})" + .replace("{}", &plist_path.display().to_string()), + ), } + } else if cfg!(target_os = "linux") && systemd_path.exists() { + let _ = std::process::Command::new("systemctl") + .args(["--user", "daemon-reload"]) + .status(); + match std::process::Command::new("systemctl") + .args(["--user", "restart", "ww"]) + .status() + { + Ok(s) if s.success() => done("Daemon restarted".into()), + _ => fail("Daemon restart (try: systemctl --user restart ww)".into()), + } + } else { + skip("Daemon start (no service file)".into()); } // ── Claude Code MCP ────────────────────────────────────────── + let ww_bin_str = ww_bin.display().to_string(); let claude_available = std::process::Command::new("claude") .args(["--version"]) .stdout(std::process::Stdio::null()) @@ -2188,19 +2133,32 @@ wasip2::cli::command::export!({iface_name}Guest); .unwrap_or(false); if claude_available { + // Try add first. If it fails with "already exists", remove and retry. let output = std::process::Command::new("claude") .args(["mcp", "add", "wetware", "--", &ww_bin_str, "run", "--mcp"]) .output(); match output { Ok(o) if o.status.success() => done("Claude Code MCP".into()), Ok(o) => { - let msg = format!( - "{}{}", - String::from_utf8_lossy(&o.stdout), - String::from_utf8_lossy(&o.stderr) - ); + let msg = String::from_utf8_lossy(&o.stdout).to_string() + + &String::from_utf8_lossy(&o.stderr); if msg.contains("already exists") { - skip("Claude Code MCP".into()); + // Remove stale entry and re-add with current binary path. + let _ = std::process::Command::new("claude") + .args(["mcp", "remove", "wetware"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + let retry = std::process::Command::new("claude") + .args(["mcp", "add", "wetware", "--", &ww_bin_str, "run", "--mcp"]) + .output(); + match retry { + Ok(r) if r.status.success() => done("Claude Code MCP (updated)".into()), + _ => { + fail("Claude Code MCP".into()); + println!(" claude mcp add wetware -- {} run --mcp", ww_bin_str); + } + } } else { fail("Claude Code MCP".into()); println!(" claude mcp add wetware -- {} run --mcp", ww_bin_str); @@ -2215,30 +2173,118 @@ wasip2::cli::command::export!({iface_name}Guest); skip("Claude Code MCP (claude CLI not found)".into()); } - // ── PATH ────────────────────────────────────────────────────── + Ok(()) + } + + /// Bootstrap the ~/.ww user layer, daemon, and MCP wiring. + /// + /// If ~/.ww already exists, delegates directly to `perform_update`. + /// Otherwise performs first-time bootstrap then calls `perform_update`. + async fn perform_install() -> Result<()> { + let home = dirs::home_dir().context("Cannot determine home directory")?; + let ww_dir = home.join(".ww"); + + // Already bootstrapped — just refresh. + if ww_dir.exists() { + return Self::perform_update().await; + } + + // ── Cold start: first-time bootstrap ───────────────────────── + let done = |msg: String| println!(" \u{2713} {msg}"); + + // ── Directories ────────────────────────────────────────────── + let subdirs = [ + "boot", + "bin", + "lib", + "etc/init.d", + "etc/ns", + "logs", + "kernel/bin", + "shell/bin", + "mcp/bin", + ]; + for sub in &subdirs { + let dir = ww_dir.join(sub); + std::fs::create_dir_all(&dir) + .with_context(|| format!("Failed to create {}", dir.display()))?; + } + done("Directories".into()); + + // ── Identity ───────────────────────────────────────────────── + let identity_path = ww_dir.join("identity"); + let sk = ww::keys::generate()?; + ww::keys::save(&sk, &identity_path)?; + let kp = ww::keys::to_libp2p(&sk)?; + let peer_id = kp.public().to_peer_id(); + done(format!("Identity ({peer_id})")); + + // ── IPNS key (first-time only, before update so publish works) ─ + let ipfs_client = ipfs::HttpClient::new("http://localhost:5001".into()); + let kubo_ok = ipfs_client.kubo_info().await.is_ok(); + + if kubo_ok { + use indicatif::{ProgressBar, ProgressStyle}; + use std::time::Duration; + + let spin = || { + let pb = ProgressBar::new_spinner(); + pb.set_style( + ProgressStyle::default_spinner() + .template(" \u{2699} {msg}") + .expect("valid template"), + ); + pb.enable_steady_tick(Duration::from_millis(80)); + pb + }; + let fail = |msg: String| println!(" \u{2717} {msg}"); + + let keys = ipfs_client.key_list().await.unwrap_or_default(); + if !keys.iter().any(|k| k == "ww") { + let sp = spin(); + sp.set_message("Generating IPNS key..."); + match ipfs_client.key_gen("ww").await { + Ok(id) => { + // Write the key into namespace config so perform_update + // can publish to IPNS on the first install. + let ns_path = ww_dir.join("etc/ns/ww"); + let mut config = ww::ns::NamespaceConfig { + name: "ww".to_string(), + ipns: id.clone(), + bootstrap: ww::namespace::WW_STD_CID.to_string(), + }; + let _ = config.write_to(&ns_path); + sp.finish_and_clear(); + done(format!("IPNS key ({id})")); + } + Err(e) => { + sp.finish_and_clear(); + fail(format!("IPNS key ({e})")); + } + } + } + } + + // ── Update: WASM images, stdlib, daemon, MCP ───────────────── + Self::perform_update().await?; + + // ── PATH + summary ─────────────────────────────────────────── let ww_bin_dir = ww_dir.join("bin"); let in_path = std::env::var("PATH") .unwrap_or_default() .split(':') .any(|p| std::path::Path::new(p) == ww_bin_dir); - let path_cmd = if !in_path { + println!(); + println!("\u{2697}\u{fe0f} Next steps:"); + println!(); + if !in_path { let shell = std::env::var("SHELL").unwrap_or_default(); if shell.ends_with("/fish") { - Some(format!("fish_add_path {}", ww_bin_dir.display())) + println!(" fish_add_path {}", ww_bin_dir.display()); } else { - Some(format!("export PATH=\"{}:$PATH\"", ww_bin_dir.display())) + println!(" export PATH=\"{}:$PATH\"", ww_bin_dir.display()); } - } else { - None - }; - - // ── Summary ────────────────────────────────────────────────── - println!(); - println!("\u{2697}\u{fe0f} Next steps:"); - println!(); - if let Some(cmd) = &path_cmd { - println!(" {cmd}"); } println!(" ww shell"); println!(); @@ -2548,8 +2594,9 @@ wasip2::cli::command::export!({iface_name}Guest); // still holds the inode, but the directory entry is removed). let _ = std::fs::remove_file(&old_exe); - println!("Upgraded ww to {remote_version}. Restart any running daemon."); - Ok(()) + println!("Upgraded ww to {remote_version}. Running update..."); + println!(); + Self::perform_update().await } /// Parse `version = "x.y.z"` from the [package] section of Cargo.toml. From e6bc9d5b65dc83fe9a80454cfccd15052bd8ac2a Mon Sep 17 00:00:00 2001 From: Louis Thibault Date: Sun, 12 Apr 2026 19:42:34 -0400 Subject: [PATCH 3/3] fix: remove unused mut flagged by clippy --- src/cli/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/main.rs b/src/cli/main.rs index 66f2618..28dae77 100644 --- a/src/cli/main.rs +++ b/src/cli/main.rs @@ -2248,7 +2248,7 @@ wasip2::cli::command::export!({iface_name}Guest); // Write the key into namespace config so perform_update // can publish to IPNS on the first install. let ns_path = ww_dir.join("etc/ns/ww"); - let mut config = ww::ns::NamespaceConfig { + let config = ww::ns::NamespaceConfig { name: "ww".to_string(), ipns: id.clone(), bootstrap: ww::namespace::WW_STD_CID.to_string(),