nd / nd-cli/src/commands.rs

raw file 70856 bytes
use crate::config::{self, Config};
use anyhow::{bail, Context, Result};
use nd_core::identity;
use nd_core::manifest::{Manifest, MANIFEST_FILE};
use nd_core::models::{Project, ProjectUpdate, Review};
use nd_core::{pack, slugify};
use std::path::{Path, PathBuf};

/// Load (or create on first use) the local signing identity.
fn my_identity() -> Result<identity::Identity> {
    identity::load_or_create(&config::identity_path()?)
}

/// A project ref: plain "slug" or owner-scoped "owner8/slug".
fn parse_ref(r: &str) -> (String, Option<String>) {
    match r.split_once('/') {
        Some((o, s)) if !o.is_empty() && !s.is_empty() && !o.contains('.') => {
            (s.to_string(), Some(o.to_string()))
        }
        _ => (r.trim().to_string(), None),
    }
}

/// Add ?owner=<short> to a request when the ref carried an owner id.
fn with_owner(
    mut req: reqwest::blocking::RequestBuilder,
    owner8: &Option<String>,
) -> reqwest::blocking::RequestBuilder {
    if let Some(o) = owner8 {
        req = req.query(&[("owner", o)]);
    }
    req
}

/// The canonical page for a signed upload (hub returns "path").
fn view_url(base: &str, body: &str) -> String {
    let path = serde_json::from_str::<serde_json::Value>(body)
        .ok()
        .and_then(|v| v.get("path").and_then(|p| p.as_str()).map(|s| s.to_string()));
    match path {
        Some(p) => format!("{base}/p/{p}"),
        None => String::new(),
    }
}

fn client() -> Result<reqwest::blocking::Client> {
    reqwest::blocking::Client::builder()
        .timeout(std::time::Duration::from_secs(300))
        .build()
        .context("build http client")
}

fn server_base(flag: Option<&str>, cfg: &Config) -> String {
    flag.map(|s| s.to_string())
        .or_else(|| std::env::var("ND_SERVER").ok())
        .or_else(|| cfg.server.clone())
        .unwrap_or_else(|| "https://notdouble.com".into())
        .trim_end_matches('/')
        .to_string()
}

fn auth(flag: Option<&str>, cfg: &Config) -> Option<String> {
    flag.map(|s| s.to_string())
        .or_else(|| std::env::var("ND_TOKEN").ok())
        .or_else(|| cfg.token.clone())
        .filter(|s| !s.is_empty())
}

fn truncate(s: &str, n: usize) -> String {
    if s.chars().count() <= n {
        s.to_string()
    } else {
        let t: String = s.chars().take(n).collect();
        format!("{t}...")
    }
}

fn human_bytes(n: u64) -> String {
    if n >= 1024 * 1024 {
        format!("{:.1} MB", n as f64 / 1024.0 / 1024.0)
    } else if n >= 1024 {
        format!("{:.1} KB", n as f64 / 1024.0)
    } else {
        format!("{n} B")
    }
}

/// interactive y/N prompt (used by run/upload in tty mode)
fn confirm(prompt: &str) -> bool {
    use std::io::Write as _;
    print!("{prompt} ");
    std::io::stdout().flush().ok();
    let mut line = String::new();
    std::io::stdin().read_line(&mut line).unwrap_or(0);
    matches!(line.trim().to_lowercase().as_str(), "y" | "yes")
}

// ---------- pack / status / diff ----------

fn print_plan(plan: &pack::Plan, explain: bool) {
    for e in &plan.included {
        println!("{:>10}  {}", human_bytes(e.size), e.rel);
    }
    println!(
        "\n{} file(s), {} packed{}",
        plan.included.len(),
        human_bytes(plan.total_bytes()),
        if plan.excluded.is_empty() { "" } else { ", excluded below" }
    );
    if explain {
        for (rel, rule) in &plan.excluded {
            println!("excluded {:>30}  [{}]", rel, rule);
        }
    }
}

/// Summarize a plan: per top-level dir/entry counts + bytes, 10 largest.
fn plan_summary(plan: &pack::Plan) {
    let mut top: std::collections::BTreeMap<String, (usize, u64)> = Default::default();
    for e in &plan.included {
        let key = e.rel.split('/').next().unwrap_or(".").to_string();
        let v = top.entry(key).or_default();
        v.0 += 1;
        v.1 += e.size;
    }
    let mut by_size: Vec<(String, usize, u64)> =
        top.into_iter().map(|(k, (n, b))| (k, n, b)).collect();
    by_size.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| b.1.cmp(&a.1)));
    for (k, n, b) in &by_size {
        println!("{:>10}  {:>6} file(s)  {}", human_bytes(*b), n, k);
    }
    let mut largest: Vec<&pack::PlanEntry> = plan.included.iter().collect();
    largest.sort_by(|a, b| b.size.cmp(&a.size));
    let largest: Vec<&pack::PlanEntry> = largest.into_iter().take(10).collect();
    println!("  largest files:");
    for e in largest {
        println!("{:>10}  {}", human_bytes(e.size), e.rel);
    }
}

/// `nd pack` — dry-run by default; -o writes the bundle.
pub fn pack(path: Option<PathBuf>, out: Option<PathBuf>, explain: bool, summary: bool) -> Result<()> {
    let dir = path.unwrap_or_else(|| PathBuf::from("."));
    let plan = pack::plan_dir(&dir).context("plan project")?;
    if summary {
        println!(
            "{:>10}  {:>6} file(s)  TOTAL",
            human_bytes(plan.total_bytes()),
            plan.included.len()
        );
        plan_summary(&plan);
        if explain {
            for (rel, rule) in &plan.excluded {
                println!("excluded {:>30}  [{}]", rel, rule);
            }
        }
        println!("\ndry run — nothing uploaded (use -o <file> to write the bundle, `nd upload` to push)");
        return Ok(());
    }
    print_plan(&plan, explain);
    if let Some(out) = out {
        let bundle = pack::pack_dir(&dir).context("pack project")?;
        std::fs::write(&out, &bundle)?;
        println!("\nwrote {} ({})", out.display(), human_bytes(bundle.len() as u64));
    } else {
        println!("\ndry run — nothing uploaded (use -o <file> to write the bundle, `nd upload` to push)");
    }
    Ok(())
}

/// `nd status` — which hub, which identity, token, reachability.
pub fn status(server: Option<String>) -> Result<()> {
    let cfg = config::load();
    let source = if server.is_some() {
        "--server flag"
    } else if std::env::var("ND_SERVER").is_ok() {
        "ND_SERVER env"
    } else if cfg.server.is_some() {
        "~/.config/notdouble/config.toml"
    } else {
        "default (the public hub)"
    };
    let base = server_base(server.as_deref(), &cfg);
    println!("hub:      {base}  ({source})");

    match config::identity_path() {
        Ok(p) if p.exists() => match identity::load(&p) {
            Ok(id) => println!("identity: {}  ({})", id.short(), p.display()),
            Err(e) => println!("identity: unreadable ({e})"),
        },
        _ => println!("identity: none yet (created on first upload)"),
    }

    println!(
        "token:    {}",
        if auth(None, &cfg).is_some() {
            "set"
        } else {
            "none (fine for open hubs; needed if uploads return 401)"
        }
    );

    match client()?.get(format!("{base}/api/projects?limit=1")).send() {
        Ok(r) if r.status().is_success() => println!("hub:      reachable ✓"),
        Ok(r) => println!("hub:      responded {} (unexpected)", r.status()),
        Err(e) => println!("hub:      unreachable — {e}"),
    }
    Ok(())
}

/// `nd diff --patch` — line-level unified diffs, local dir vs the hub copy.
fn diff_patch(
    ref_: &str,
    dir: Option<PathBuf>,
    server: Option<String>,
    token: Option<String>,
) -> Result<()> {
    use nd_core::sync;
    use std::collections::BTreeSet;

    let (slug, owner8) = parse_ref(ref_);
    let dir = dir.unwrap_or_else(|| PathBuf::from("."));
    let cfg = config::load();
    let base = server_base(server.as_deref(), &cfg);

    let mut req = with_owner(
        client()?.get(format!("{base}/api/projects/{slug}/bundle")),
        &owner8,
    );
    if let Some(t) = auth(token.as_deref(), &cfg) {
        req = req.bearer_auth(&t);
    }
    let resp = req.send().context("request failed (is the hub up?)")?;
    let status = resp.status();
    if status.as_u16() == 404 {
        bail!("no such project: {slug}");
    }
    if !status.is_success() {
        bail!("bundle download failed ({status})");
    }
    let bytes = resp.bytes()?;
    if bytes.is_empty() {
        bail!("server returned an empty bundle");
    }
    let tmp = std::env::temp_dir().join(format!("nd-diff-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&tmp);
    pack::extract_bundle(&bytes, &tmp)?;

    let plan = pack::plan_dir(&dir)?;
    let local: BTreeSet<String> = plan.included.iter().map(|e| e.rel.clone()).collect();
    let remote: BTreeSet<String> = sync::hash_dir(&tmp)?.keys().cloned().collect();

    const CAP: u64 = 300 * 1024;
    let read_text = |root: &Path, rel: &str| -> Option<String> {
        let p = root.join(rel);
        let md = std::fs::metadata(&p).ok()?;
        if md.len() > CAP {
            return None;
        }
        let b = std::fs::read(&p).ok()?;
        if b.contains(&0) {
            return None;
        }
        String::from_utf8(b).ok()
    };

    let mut any = false;
    for rel in local.union(&remote) {
        let lt = read_text(&dir, rel);
        let rt = read_text(&tmp, rel);
        match (lt, rt) {
            (Some(a), Some(b)) if a == b => {}
            (Some(a), Some(b)) => {
                any = true;
                println!(
                    "{}",
                    nd_core::diff::unified(&a, &b, &format!("a/{rel}"), &format!("b/{rel}"))
                );
            }
            (Some(_), None) => {
                any = true;
                println!("only local: {rel}");
            }
            (None, Some(_)) => {
                any = true;
                println!("only on the hub: {rel}");
            }
            (None, None) => {}
        }
    }
    let _ = std::fs::remove_dir_all(&tmp);
    if !any {
        println!("no differences vs {slug}");
    }
    Ok(())
}

/// `nd diff <slug>` — file-set changes vs the last upload (`--content`
/// runs the exact three-way that `nd pull` would, without touching disk).
pub fn diff(
    ref_: &str,
    dir: Option<PathBuf>,
    content: bool,
    patch: bool,
    server: Option<String>,
    token: Option<String>,
) -> Result<()> {
    if content {
        return pull(Some(ref_.into()), dir, false, true, true, server, token);
    }
    if patch {
        return diff_patch(ref_, dir, server, token);
    }
    let (slug, owner8) = parse_ref(ref_);
    let cfg = config::load();
    let base = server_base(server.as_deref(), &cfg);
    let dir = dir.unwrap_or_else(|| PathBuf::from("."));

    let mut req = with_owner(client()?.get(format!("{base}/api/projects/{slug}")), &owner8);
    if let Some(t) = auth(token.as_deref(), &cfg) {
        req = req.bearer_auth(&t);
    }
    let resp = req.send().context("request failed (is the hub up?)")?;
    if resp.status().as_u16() == 404 {
        bail!("no such project: {slug}");
    }
    if !resp.status().is_success() {
        bail!("lookup failed ({})", resp.status());
    }
    let p: Project = resp.json()?;

    let plan = pack::plan_dir(&dir).context("plan project")?;
    let local: std::collections::HashSet<&str> =
        plan.included.iter().map(|e| e.rel.as_str()).collect();
    let published: std::collections::HashSet<&str> =
        p.files.iter().map(|s| s.as_str()).collect();

    let added: Vec<&str> = local.difference(&published).copied().collect();
    let removed: Vec<&str> = published.difference(&local).copied().collect();

    if added.is_empty() && removed.is_empty() {
        println!("no file-set changes vs {slug} (content changes are not detected — use --content)");
        return Ok(());
    }
    if !added.is_empty() {
        println!("new locally ({}):", added.len());
        for f in added.iter().take(50) {
            println!("  + {f}");
        }
        if added.len() > 50 {
            println!("  ... and {} more", added.len() - 50);
        }
    }
    if !removed.is_empty() {
        println!("on the hub but not local ({}):", removed.len());
        for f in removed.iter().take(50) {
            println!("  - {f}");
        }
        if removed.len() > 50 {
            println!("  ... and {} more", removed.len() - 50);
        }
    }
    Ok(())
}

// ---------- init ----------

pub fn init(path: Option<PathBuf>, force: bool, guess_fields: bool) -> Result<()> {
    let dir = path.unwrap_or_else(|| PathBuf::from("."));
    let mf = dir.join(MANIFEST_FILE);
    if mf.exists() && !force {
        bail!("{} already exists (use --force to overwrite)", mf.display());
    }

    let content = if guess_fields {
        let m = crate::guess::guess(&dir);
        if let Err(e) = m.validate() {
            bail!("{e} (or use a plain `nd init`)");
        }
        crate::guess::render(&m)
    } else {
        nd_core::manifest::TEMPLATE.to_string()
    };
    std::fs::write(&mf, content)?;

    // .ndignore: create from template, then seed extra rules from .gitignore
    let ig = dir.join(".ndignore");
    let mut content = std::fs::read_to_string(&ig).unwrap_or_else(|_| pack::NDIGNORE_TEMPLATE.into());
    let original = content.clone();
    if let Ok(git_raw) = std::fs::read_to_string(dir.join(".gitignore")) {
        let have: std::collections::HashSet<&str> = content.lines().map(str::trim).collect();
        let new_rules: Vec<String> = pack::gitignore_rules(&git_raw)
            .into_iter()
            .filter(|r| !have.contains(r.as_str()))
            .collect();
        if !new_rules.is_empty() {
            if !content.trim().is_empty() && !content.ends_with('\n') {
                content.push('\n');
            }
            content.push_str("\n# seeded from .gitignore by nd init\n");
            for r in new_rules {
                content.push_str(&r);
                content.push('\n');
            }
        }
    }
    let seeded = content != original;
    std::fs::write(&ig, content)?;

    println!("wrote {}", mf.display());
    if guess_fields {
        println!("guessed manifest fields from the repo — review them, and fill llm_description + keywords");
    }
    if seeded {
        println!("seeded .ndignore from .gitignore");
    }
    println!("next: fill in name, description, llm_description, keywords, code_keywords");
    println!("then: nd validate && nd upload");
    Ok(())
}

// ---------- validate ----------

pub fn validate(path: Option<PathBuf>, json: bool) -> Result<()> {
    let dir = path.unwrap_or_else(|| PathBuf::from("."));
    let m = Manifest::load(&dir)?;
    if json {
        println!("{}", serde_json::to_string_pretty(&m)?);
        return Ok(());
    }
    println!("ok: {}", dir.join(MANIFEST_FILE).display());
    println!("slug: {}", slugify(&m.name));
    for ep in &m.entry_points {
        if !dir.join(ep).is_file() {
            println!("warn: entry_point '{}' does not exist on disk", ep);
        }
    }
    if let Ok(plan) = pack::plan_dir(&dir) {
        println!(
            "pack preview: {} file(s), {} (nd pack for the full listing)",
            plan.included.len(),
            human_bytes(plan.total_bytes())
        );
        for e in &plan.included {
            let ext = e.rel.rsplit('.').next().unwrap_or("");
            if matches!(ext, "apk" | "aab" | "exe" | "dmg" | "appimage" | "deb" | "ipa") {
                println!(
                    "note: binary artifact '{}' will show on your releases tab (hub renders it)",
                    e.rel
                );
                break;
            }
        }
    }
    if m.llm_description.trim().is_empty() {
        println!("tip: llm_description is empty — it is the main search field, fill it in");
    }
    if m.keywords.is_empty() {
        println!("tip: add topic keywords (what the project is about)");
    }
    if m.code_keywords.is_empty() {
        println!("tip: add code keywords (language, framework, libraries)");
    }
    Ok(())
}

// ---------- bump ----------

/// `nd bump` — release hygiene: 0.8.4 --patch -> 0.8.5 etc.
pub fn bump(path: Option<PathBuf>, kind: &str) -> Result<()> {
    let dir = path.unwrap_or_else(|| PathBuf::from("."));
    let mut m = Manifest::load(&dir)?;
    let parts: Vec<u64> = m
        .version
        .split('.')
        .map(|p| p.parse::<u64>().unwrap_or(0))
        .collect();
    if parts.len() != 3 {
        bail!(
            "version '{}' is not plain semver (x.y.z) — bump it by hand in notdouble.toml",
            m.version
        );
    }
    let (mut ma, mut mi, mut pa) = (parts[0], parts[1], parts[2]);
    match kind {
        "major" => {
            ma += 1;
            mi = 0;
            pa = 0;
        }
        "minor" => {
            mi += 1;
            pa = 0;
        }
        "patch" => pa += 1,
        other => bail!("kind must be patch|minor|major (got '{other}')"),
    }
    let old_v = m.version.clone();
    m.version = format!("{ma}.{mi}.{pa}");
    let mf = dir.join(MANIFEST_FILE);
    std::fs::write(&mf, toml::to_string_pretty(&m)?)?;
    println!("bumped: {old_v} -> {}", m.version);
    println!("next: nd upload (posts the release note automatically)");
    Ok(())
}

// ---------- upload ----------

fn machine_specs(s: &nd_core::specs::Specs) {
    println!("machine specs:");
    println!("  os: {} ({})", s.os, s.arch);
    if !s.cpu.is_empty() {
        println!("  cpu: {}", s.cpu);
        println!("  cores: {}", s.cpu_cores);
    }
    if s.memory_gb > 0.0 {
        println!("  ram: {:.1} GB", s.memory_gb);
    }
    if !s.gpu.is_empty() {
        println!("  gpu: {}", s.gpu);
    }
}

fn decide_specs(want: bool, deny: bool) -> Result<Option<nd_core::specs::Specs>> {
    use std::io::IsTerminal;
    if deny {
        return Ok(None);
    }
    let specs = nd_core::specs::gather();
    if specs.is_empty() {
        return Ok(None);
    }
    let include = if want {
        true
    } else if std::io::stdin().is_terminal() {
        machine_specs(&specs);
        confirm("include these machine specs with the upload? [y/N]")
    } else {
        false
    };
    if include {
        machine_specs(&specs);
        Ok(Some(specs))
    } else {
        Ok(None)
    }
}

pub fn upload(
    path: Option<PathBuf>,
    yes: bool,
    server: Option<String>,
    token: Option<String>,
    want_specs: bool,
    no_specs: bool,
    dry_run: bool,
    json: bool,
) -> Result<()> {
    let cfg = config::load();
    let base = server_base(server.as_deref(), &cfg);
    let dir = path.unwrap_or_else(|| PathBuf::from("."));
    let mut m = Manifest::load(&dir)?;

    let plan = pack::plan_dir(&dir).context("plan project")?;
    let slug = slugify(&m.name);
    let url = format!("{base}/api/projects/{slug}");

    let http = client()?;
    let id = my_identity()?;
    let my8 = id.short();
    let exists = with_owner(http.get(&url), &Some(my8.clone()))
        .send()
        .map(|r| r.status().as_u16() == 200)
        .unwrap_or(false);

    if dry_run {
        let action = if exists { "update" } else { "create" };
        if json {
            let artifacts: Vec<String> = plan
                .included
                .iter()
                .filter(|e| {
                    matches!(
                        e.rel.rsplit('.').next().unwrap_or("").to_lowercase().as_str(),
                        "apk" | "aab" | "exe" | "msi" | "dmg" | "appimage" | "deb" | "rpm" | "ipa"
                    )
                })
                .map(|e| e.rel.clone())
                .collect();
            println!(
                "{}",
                serde_json::json!({
                    "slug": slug,
                    "owner": my8,
                    "would": action,
                    "files": plan.included.len(),
                    "bytes": plan.total_bytes(),
                    "artifacts": artifacts,
                    "url": format!("{base}/p/{my8}/{slug}"),
                })
            );
            return Ok(());
        }
        print_plan(&plan, true);
        println!("\ndry run — would {action} {slug} at {base}/p/{my8}/{slug} (nothing uploaded)");
        return Ok(());
    }

    // pre-flight guard: refuse absurd uploads instead of eating a timeout
    const MAX_FILES_NO_ASK: usize = 2_000;
    const MAX_BYTES_NO_ASK: u64 = 100 * 1024 * 1024;
    if plan.included.len() > MAX_FILES_NO_ASK || plan.total_bytes() > MAX_BYTES_NO_ASK {
        if !yes {
            bail!(
                "this would pack {} file(s), {} — narrow it with .ndignore (`nd pack` lists what's in), or pass --yes to force",
                plan.included.len(),
                human_bytes(plan.total_bytes())
            );
        }
    }

    if let Some(specs) = decide_specs(want_specs, no_specs)? {
        m.specs = Some(serde_json::to_value(&specs)?);
    }

    println!(
        "packing {} file(s), {} …",
        plan.included.len(),
        human_bytes(plan.total_bytes())
    );
    let bundle = pack::pack_dir(&dir).context("pack project")?;
    let bundle_len = bundle.len();

    // sign the upload: manifest json bytes followed by the bundle bytes
    let json_str = m.to_json()?;
    let owner = id.public_b64();
    let mut msg = json_str.as_bytes().to_vec();
    msg.extend_from_slice(&bundle);
    let sig = id.sign(&msg);

    let form = reqwest::blocking::multipart::Form::new()
        .text("manifest", json_str)
        .part(
            "bundle",
            reqwest::blocking::multipart::Part::bytes(bundle)
                .file_name("bundle.tar.gz")
                .mime_str("application/gzip")?,
        );

    let mut req = if exists {
        http.put(&url)
    } else {
        http.post(format!("{base}/api/projects"))
    };
    req = req
        .multipart(form)
        .header("x-nd-owner", owner.as_str())
        .header("x-nd-signature", sig.as_str());
    if let Some(t) = auth(token.as_deref(), &cfg) {
        req = req.bearer_auth(&t);
    }

    println!("sending to {base} …");
    let resp = req.send().context("request failed (is the server up?)")?;
    let status = resp.status();
    let body = resp.text().unwrap_or_default();
    if !status.is_success() {
        if status.as_u16() == 401 {
            bail!("upload failed (401): the hub requires a token — pass --token <t> or set ND_TOKEN");
        }
        if status.as_u16() == 403 {
            bail!("upload failed (403): {body}\nhint: run `nd diff {slug}` or fork: nd download {slug} + rename");
        }
        bail!("upload failed ({status}): {body}");
    }
    if json {
        println!("{body}");
        return Ok(());
    }
    let action = if exists { "updated" } else { "created" };
    println!("ok: {action} {slug}");
    println!(
        "packed: {} file(s), {}",
        plan.included.len(),
        human_bytes(plan.total_bytes())
    );
    let view = view_url(&base, &body);
    if view.is_empty() {
        println!("view: {base}/p/{slug}");
    } else {
        println!("view: {view}");
    }
    if let Some(path) = serde_json::from_str::<serde_json::Value>(&body)
        .ok()
        .and_then(|v| v.get("path").and_then(|p| p.as_str()).map(|s| s.to_string()))
    {
        let _ = nd_core::sync::stamp(&dir, &path);
    }
    let mb = bundle_len as f64 / 1024.0 / 1024.0;
    if mb >= 5.0 {
        println!(
            "note: bundle is {mb:.1} MB — if that is unintentional, add big files/binaries to .ndignore and re-upload"
        );
    }
    Ok(())
}

// ---------- download ----------

pub fn download(
    ref_: &str,
    dest: Option<PathBuf>,
    server: Option<String>,
    token: Option<String>,
    overwrite: bool,
) -> Result<()> {
    let (slug, owner8) = parse_ref(ref_);
    let cfg = config::load();
    let base = server_base(server.as_deref(), &cfg);
    let dest = dest.unwrap_or_else(|| PathBuf::from(&slug));
    if dest.exists() {
        let nonempty = std::fs::read_dir(&dest)
            .map(|mut d| d.next().is_some())
            .unwrap_or(false);
        if nonempty && !overwrite {
            bail!("{} exists and is not empty (pick another dest or --overwrite)", dest.display());
        }
    }

    let http = client()?;
    let mut req = with_owner(http.get(format!("{base}/api/projects/{slug}/bundle")), &owner8);
    if let Some(t) = auth(token.as_deref(), &cfg) {
        req = req.bearer_auth(&t);
    }
    let resp = req.send().context("request failed (is the hub up?)")?;
    let status = resp.status();
    if status.as_u16() == 404 {
        bail!("no such project: {slug}");
    }
    if !status.is_success() {
        bail!("download failed ({status})");
    }
    let bytes = resp.bytes()?;
    if bytes.is_empty() {
        bail!("server returned an empty bundle");
    }
    pack::extract_bundle(&bytes, &dest)?;
    let canonical = match &owner8 {
        Some(o) => format!("{o}/{slug}"),
        None => slug.clone(),
    };
    let _ = nd_core::sync::stamp(&dest, &canonical);
    let (n, _) = pack::walk_stats(&dest);
    println!("downloaded {slug} -> {} ({n} files)", dest.display());
    println!("manifest: {}", dest.join(MANIFEST_FILE).display());
    println!("this dir now tracks the hub: nd pull keeps it current");
    Ok(())
}

// ---------- fork ----------

/// Copy ONLY what would ship (same plan rules as pack).
fn copy_shipped(src: &Path, dest: &Path) -> Result<()> {
    let plan = pack::plan_dir(src)?;
    std::fs::create_dir_all(dest)?;
    for e in &plan.included {
        let from = src.join(&e.rel);
        let to = dest.join(&e.rel);
        if let Some(parent) = to.parent() {
            std::fs::create_dir_all(parent)?;
        }
        std::fs::copy(&from, &to)?;
    }
    if src.join(".ndignore").is_file() {
        std::fs::copy(src.join(".ndignore"), dest.join(".ndignore"))?;
    }
    Ok(())
}

/// In-place fork: copy shipped content, rewrite the manifest.
fn fork_from_dir(
    dir: &Path,
    name: Option<String>,
    do_upload: bool,
    server: Option<String>,
    token: Option<String>,
) -> Result<()> {
    let m = Manifest::load(dir).context("not an nd project dir")?;
    let origin_ref = m
        .extra
        .get("nd_ref")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
        .unwrap_or_else(|| slugify(&m.name));
    let fork_name = name.unwrap_or_else(|| format!("{}-fork", slugify(&m.name)));
    let fork_slug = slugify(&fork_name);
    let dest = dir
        .parent()
        .map(|d| d.join(&fork_slug))
        .unwrap_or_else(|| PathBuf::from(&fork_slug));
    if dest.exists() {
        let nonempty = std::fs::read_dir(&dest).map(|mut d| d.next().is_some()).unwrap_or(false);
        if nonempty {
            bail!("{} already exists — pick another --name", dest.display());
        }
    }
    copy_shipped(dir, &dest)?;

    let mut fm = Manifest::load(&dest)?;
    fm.name = fork_name.clone();
    fm.extra
        .insert("fork_of".into(), serde_json::json!(origin_ref));
    let note = format!("Fork of {origin_ref}.");
    if fm.llm_description.trim().is_empty() {
        fm.llm_description = note;
    } else if !fm.llm_description.contains("Fork of ") {
        fm.llm_description = format!("{} {}", fm.llm_description.trim_end(), note);
    }
    std::fs::write(dest.join(MANIFEST_FILE), toml::to_string_pretty(&fm)?)?;
    let _ = nd_core::sync::stamp(&dest, &fork_slug);

    println!("forked (in place) {origin_ref} -> {} (name: {fork_name})", dest.display());
    println!("destination: {}", dest.display());
    if do_upload {
        upload(Some(dest.clone()), false, server, token, false, true, false, false)?;
    } else {
        println!("next: edit it, then `nd validate && nd upload`");
        println!("(or redo with --upload to publish immediately)");
    }
    Ok(())
}

/// Fork a project: from a hub ref (download + rewrite) OR in-place from
/// an existing project dir (`nd fork` inside a downloaded dir).
pub fn fork(
    source: Option<String>,
    name: Option<String>,
    do_upload: bool,
    server: Option<String>,
    token: Option<String>,
) -> Result<()> {
    let dir_source = match &source {
        Some(s) => {
            let p = PathBuf::from(s);
            if p.join(MANIFEST_FILE).is_file() {
                Some(p)
            } else {
                None
            }
        }
        None => {
            let cwd = std::env::current_dir().context("no working dir")?;
            if cwd.join(MANIFEST_FILE).is_file() {
                Some(cwd)
            } else {
                None
            }
        }
    };
    if let Some(dir) = dir_source {
        return fork_from_dir(&dir, name, do_upload, server, token);
    }
    let Some(ref_) = source else {
        bail!("nothing to fork here — no notdouble.toml in this dir, and no ref given. try `nd fork <owner/slug>`");
    };

    let (slug, owner8) = parse_ref(&ref_);
    let cfg = config::load();
    let base = server_base(server.as_deref(), &cfg);
    let http = client()?;

    let mut req = with_owner(http.get(format!("{base}/api/projects/{slug}")), &owner8);
    if let Some(t) = auth(token.as_deref(), &cfg) {
        req = req.bearer_auth(&t);
    }
    let resp = req.send().context("request failed (is the hub up?)")?;
    if resp.status().as_u16() == 404 {
        bail!("no such project: {slug}");
    }
    if !resp.status().is_success() {
        bail!("lookup failed ({})", resp.status());
    }
    let origin: Project = resp.json()?;

    let mut req = with_owner(http.get(format!("{base}/api/projects/{slug}/bundle")), &owner8);
    if let Some(t) = auth(token.as_deref(), &cfg) {
        req = req.bearer_auth(&t);
    }
    let resp = req.send().context("request failed (is the hub up?)")?;
    if !resp.status().is_success() {
        bail!("bundle download failed ({})", resp.status());
    }
    let bytes = resp.bytes()?;
    if bytes.is_empty() {
        bail!("server returned an empty bundle");
    }

    let fork_name = name.unwrap_or_else(|| format!("{slug}-fork"));
    let fork_slug = slugify(&fork_name);
    let taken = http
        .get(format!("{base}/api/projects/{fork_slug}"))
        .send()
        .map(|r| r.status().as_u16() == 200)
        .unwrap_or(false);
    if taken {
        bail!("'{fork_slug}' already exists on the hub — pick another --name");
    }
    let dest = PathBuf::from(&fork_slug);
    if dest.exists() {
        let nonempty = std::fs::read_dir(&dest).map(|mut d| d.next().is_some()).unwrap_or(false);
        if nonempty {
            bail!("{} already exists locally — move it or pick another --name", dest.display());
        }
    }
    pack::extract_bundle(&bytes, &dest)?;

    let mut fm = Manifest::load(&dest)?;
    fm.name = fork_name.clone();
    let origin_url = format!("{base}/p/{}", origin.url_path());
    fm.extra
        .insert("fork_of".into(), serde_json::json!(origin.url_path()));
    let note = format!("Fork of {origin_url}.");
    if fm.llm_description.trim().is_empty() {
        fm.llm_description = note;
    } else if !fm.llm_description.contains("Fork of ") {
        fm.llm_description = format!("{} {}", fm.llm_description.trim_end(), note);
    }
    std::fs::write(dest.join(MANIFEST_FILE), toml::to_string_pretty(&fm)?)?;
    let _ = nd_core::sync::stamp(&dest, &fork_slug);

    println!("forked {slug} -> {} (name: {fork_name}, fork_of: {})", dest.display(), origin.url_path());
    println!("destination: {}", dest.display());
    if do_upload {
        upload(Some(dest.clone()), false, server, token, false, true, false, false)?;
    } else {
        println!("next: edit it, then `nd validate && nd upload`");
        println!("(or redo with --upload to publish immediately)");
    }
    Ok(())
}

// ---------- search / info ----------

pub fn search(
    query: &str,
    limit: usize,
    with_forks: bool,
    server: Option<String>,
    json: bool,
) -> Result<()> {
    let cfg = config::load();
    let base = server_base(server.as_deref(), &cfg);
    let limit_s = limit.to_string();
    let mut req = client()?.get(format!("{base}/api/projects")).query(&[
        ("q", query),
        ("limit", limit_s.as_str()),
    ]);
    if with_forks {
        req = req.query(&[("include_forks", "1")]);
    }
    let resp = req.send().context("request failed (is the hub up?)")?;
    let status = resp.status();
    if !status.is_success() {
        bail!("search failed ({status})");
    }
    let projects: Vec<Project> = resp.json()?;
    if json {
        println!("{}", serde_json::to_string_pretty(&projects)?);
        return Ok(());
    }
    if projects.is_empty() {
        println!("no results for \"{query}\"");
        return Ok(());
    }
    for p in &projects {
        let mut head = format!("{} {}", p.slug, p.version);
        if let Some(o) = p.owner_short() {
            head.push_str(&format!("  [{o}]"));
        }
        if !p.author.is_empty() {
            head.push_str(&format!("  by {}", p.author));
        }
        if p.fork_of.is_some() {
            head.push_str("  ⑂ fork");
        }
        println!("{head}");
        if !p.description.is_empty() {
            println!("  {}", p.description);
        }
        if !p.llm_description.is_empty() {
            println!("  {}", truncate(&p.llm_description, 160));
        }
        let tags: Vec<String> = p
            .keywords
            .iter()
            .chain(p.code_keywords.iter())
            .cloned()
            .collect();
        if !tags.is_empty() {
            println!("  [{}]", tags.join(", "));
        }
        println!();
    }
    println!("{} result(s)", projects.len());
    Ok(())
}

pub fn info(ref_: &str, server: Option<String>, token: Option<String>, json: bool) -> Result<()> {
    let (slug, owner8) = parse_ref(ref_);
    let cfg = config::load();
    let base = server_base(server.as_deref(), &cfg);
    let mut req = with_owner(client()?.get(format!("{base}/api/projects/{slug}")), &owner8);
    if let Some(t) = auth(token.as_deref(), &cfg) {
        req = req.bearer_auth(&t);
    }
    let resp = req.send().context("request failed (is the hub up?)")?;
    let status = resp.status();
    if status.as_u16() == 404 {
        bail!("no such project: {slug}");
    }
    if !status.is_success() {
        let body = resp.text().unwrap_or_default();
        bail!("info failed ({status}): {body}");
    }
    let p: Project = resp.json()?;
    if json {
        println!("{}", serde_json::to_string_pretty(&p)?);
        return Ok(());
    }
    println!("{} {}", p.name, p.version);
    println!("slug: {}", p.slug);
    if !p.description.is_empty() {
        println!("description: {}", p.description);
    }
    if !p.llm_description.is_empty() {
        println!("llm_description: {}", p.llm_description);
    }
    for (label, list) in [
        ("keywords", &p.keywords),
        ("code_keywords", &p.code_keywords),
        ("languages", &p.languages),
        ("entry_points", &p.entry_points),
    ] {
        if !list.is_empty() {
            println!("{label}: {}", list.join(", "));
        }
    }
    if !p.author.is_empty() {
        println!("author: {}", p.author);
    }
    if !p.license.is_empty() {
        println!("license: {}", p.license);
    }
    if !p.model.is_empty() {
        println!("model: {}", p.model);
    }
    if !p.prompt_summary.is_empty() {
        println!("prompt: {}", p.prompt_summary);
    }
    if !p.install.is_empty() {
        println!("install: {}", p.install);
    }
    if !p.run.is_empty() {
        println!("run: {}", p.run);
    }
    println!(
        "stats: {} files, {} bytes, {} downloads, {} views, updated {}",
        p.file_count, p.total_bytes, p.downloads, p.views, p.updated_at
    );
    if let Some(v) = &p.specs {
        if let Ok(s) = serde_json::from_value::<nd_core::specs::Specs>(v.clone()) {
            println!("built on: {}", s.summary());
        }
    }
    if !p.files.is_empty() {
        println!("files:");
        for f in p.files.iter().take(30) {
            println!("  {f}");
        }
        if p.files.len() > 30 {
            println!("  ... and {} more", p.files.len() - 30);
        }
    }
    Ok(())
}

// ---------- reviews / updates ----------

pub fn review(
    ref_: &str,
    verdict: &str,
    message: Option<String>,
    author: &str,
    server: Option<String>,
    token: Option<String>,
) -> Result<()> {
    let (slug, owner8) = parse_ref(ref_);
    let text = match message {
        Some(m) if !m.trim().is_empty() => m,
        _ => {
            let mut buf = String::new();
            use std::io::Read as _;
            let _ = std::io::stdin().read_to_string(&mut buf);
            buf
        }
    };
    let text = text.trim();
    if text.is_empty() {
        bail!("empty review — pass --message \"...\" or pipe text on stdin");
    }

    let cfg = config::load();
    let base = server_base(server.as_deref(), &cfg);
    let payload = serde_json::json!({
        "author": author.trim(),
        "verdict": verdict.trim(),
        "body": text,
    });
    let mut req = with_owner(
        client()?.post(format!("{base}/api/projects/{slug}/reviews")),
        &owner8,
    )
    .json(&payload);
    if let Some(t) = auth(token.as_deref(), &cfg) {
        req = req.bearer_auth(&t);
    }
    let resp = req.send().context("request failed (is the hub up?)")?;
    let status = resp.status();
    if !status.is_success() {
        let body = resp.text().unwrap_or_default();
        bail!("review failed ({status}): {body}");
    }
    println!("review posted: {base}/p/{slug}/reviews");
    Ok(())
}

pub fn reviews(ref_: &str, server: Option<String>, json: bool) -> Result<()> {
    let (slug, owner8) = parse_ref(ref_);
    let cfg = config::load();
    let base = server_base(server.as_deref(), &cfg);
    let resp = with_owner(
        client()?.get(format!("{base}/api/projects/{slug}/reviews")),
        &owner8,
    )
    .send()
    .context("request failed (is the hub up?)")?;
    let status = resp.status();
    if !status.is_success() {
        bail!("reviews failed ({status})");
    }
    let list: Vec<Review> = resp.json()?;
    if json {
        println!("{}", serde_json::to_string_pretty(&list)?);
        return Ok(());
    }
    if list.is_empty() {
        println!("no reviews on {slug} yet — tried it? `nd review {slug}`");
        return Ok(());
    }
    for r in &list {
        println!(
            "{} [{}] {} — {}",
            r.short_date(),
            r.verdict,
            r.author,
            truncate(&r.body, 300)
        );
    }
    Ok(())
}

pub fn post_update(
    ref_: &str,
    title: &str,
    message: Option<String>,
    server: Option<String>,
    token: Option<String>,
) -> Result<()> {
    let (slug, owner8) = parse_ref(ref_);
    let text = match message {
        Some(m) if !m.trim().is_empty() => m,
        _ => {
            let mut buf = String::new();
            use std::io::Read as _;
            let _ = std::io::stdin().read_to_string(&mut buf);
            buf
        }
    };
    let title = title.trim();
    if title.is_empty() {
        bail!("update needs a --title");
    }
    let body = text.trim();

    let cfg = config::load();
    let base = server_base(server.as_deref(), &cfg);
    let id = my_identity()?;
    let msg = format!("update:{slug}:{title}:{body}");
    let mut req = with_owner(
        client()?.post(format!("{base}/api/projects/{slug}/updates")),
        &owner8,
    )
    .json(&serde_json::json!({ "title": title, "body": body }))
    .header("x-nd-owner", id.public_b64().as_str())
    .header("x-nd-signature", id.sign(msg.as_bytes()).as_str());
    if let Some(t) = auth(token.as_deref(), &cfg) {
        req = req.bearer_auth(&t);
    }
    let resp = req.send().context("request failed (is the hub up?)")?;
    let status = resp.status();
    if !status.is_success() {
        let body = resp.text().unwrap_or_default();
        bail!("update failed ({status}): {body}");
    }
    println!("update posted: {base}/p/{slug}/updates");
    Ok(())
}

pub fn updates_list(ref_: &str, server: Option<String>, json: bool) -> Result<()> {
    let (slug, owner8) = parse_ref(ref_);
    let cfg = config::load();
    let base = server_base(server.as_deref(), &cfg);
    let resp = with_owner(
        client()?.get(format!("{base}/api/projects/{slug}/updates")),
        &owner8,
    )
    .send()
    .context("request failed (is the hub up?)")?;
    let status = resp.status();
    if status.as_u16() == 404 {
        bail!("no such project: {slug}");
    }
    if !status.is_success() {
        bail!("updates failed ({status})");
    }
    let list: Vec<ProjectUpdate> = resp.json()?;
    if json {
        println!("{}", serde_json::to_string_pretty(&list)?);
        return Ok(());
    }
    if list.is_empty() {
        println!("no updates on {slug} yet — maintainers post with `nd update {slug}`");
        return Ok(());
    }
    for u in &list {
        println!(
            "{} [{}] {} — {}",
            u.short_date(),
            u.author,
            u.title,
            truncate(&u.body, 240)
        );
    }
    Ok(())
}

// ---------- run ----------

fn cache_dir(ref_: &str) -> Result<PathBuf> {
    // windows: %LOCALAPPDATA%\nd\runs ; unix: ~/.cache/nd/runs
    #[cfg(windows)]
    {
        if let Ok(base) = std::env::var("LOCALAPPDATA") {
            return Ok(PathBuf::from(base)
                .join("nd")
                .join("runs")
                .join(ref_.replace('/', "_")));
        }
    }
    let home = config::home_dir()?;
    Ok(home.join(".cache/nd/runs").join(ref_.replace('/', "_")))
}

enum RunTarget {
    /// run an existing project dir (cwd or a child of it)
    Local(PathBuf),
    /// not present locally — cache it under this ref (maybe after asking)
    Cache(String),
}

/// Does the manifest at `mf` describe the ref `r`? (by nd_ref stamp or name)
fn manifest_tracks(mf: &Path, r: &str) -> bool {
    let Ok(raw) = std::fs::read_to_string(mf) else { return false };
    let Ok(m) = toml::from_str::<Manifest>(&raw) else { return false };
    let (want_slug, want_owner) = parse_ref(r);
    if let Some(tracked) = m.extra.get("nd_ref").and_then(|v| v.as_str()) {
        let (tslug, towner) = parse_ref(tracked);
        if tslug == want_slug
            && (want_owner.is_none() || want_owner.as_deref() == towner.as_deref())
        {
            return true;
        }
    }
    slugify(&m.name) == want_slug
}

fn resolve_run_target(cwd: &Path, ref_: Option<&str>) -> Result<RunTarget> {
    let cwd_mf = cwd.join(MANIFEST_FILE);
    match ref_ {
        None => {
            if cwd_mf.is_file() {
                Ok(RunTarget::Local(cwd.to_path_buf()))
            } else {
                bail!(
                    "no {MANIFEST_FILE} here — cd into a project, or pass a ref: nd run <owner/slug>"
                )
            }
        }
        Some(r) => {
            // already sitting in (a subdir of) the project? run it, no download
            if cwd_mf.is_file() && manifest_tracks(&cwd_mf, r) {
                Ok(RunTarget::Local(cwd.to_path_buf()))
            } else {
                let (slug, _) = parse_ref(r);
                let sub = cwd.join(&slug);
                if sub.join(MANIFEST_FILE).is_file()
                    && manifest_tracks(&sub.join(MANIFEST_FILE), r)
                {
                    Ok(RunTarget::Local(sub))
                } else {
                    Ok(RunTarget::Cache(r.to_string()))
                }
            }
        }
    }
}

pub fn run(
    ref_: Option<String>,
    refresh: bool,
    args: &[String],
    want_bin: bool,
    want_shell: bool,
    server: Option<String>,
    token: Option<String>,
) -> Result<()> {
    use std::io::IsTerminal;
    let cwd = std::env::current_dir().context("no working dir")?;
    let (mut dir, cache_ref) = match resolve_run_target(&cwd, ref_.as_deref())? {
        RunTarget::Local(d) => (d, None),
        RunTarget::Cache(r) => (cache_dir(&r)?, Some(r)),
    };
    let (slug, owner8) = match &cache_ref {
        Some(r) => parse_ref(r),
        None => {
            let m = Manifest::load(&dir)?;
            (slugify(&m.name), None)
        }
    };
    let mut downloaded_to = String::new();
    if let Some(ref_str) = &cache_ref {
        let mf = dir.join(MANIFEST_FILE);
        if refresh || !mf.exists() {
            // interactive: offer to download, and offer to use the current dir.
            // non-interactive: cache dir silently (never hang).
            if std::io::stdin().is_terminal() && !refresh {
                if !mf.exists() {
                    println!("'{ref_str}' is not downloaded yet.");
                    if !confirm("download it now? [y/N]") {
                        bail!("aborted");
                    }
                }
                if confirm("use the current directory as the workspace? (no = cache dir) [y/N]") {
                    let dest = cwd.join(&slug);
                    if dest.exists() {
                        let nonempty = std::fs::read_dir(&dest)
                            .map(|mut d| d.next().is_some())
                            .unwrap_or(false);
                        if nonempty {
                            bail!("{} already exists and is not empty", dest.display());
                        }
                    }
                    dir = dest;
                }
            }
            let cfg = config::load();
            let base = server_base(server.as_deref(), &cfg);
            let mut req = with_owner(
                client()?.get(format!("{base}/api/projects/{slug}/bundle")),
                &owner8,
            );
            if let Some(t) = auth(token.as_deref(), &cfg) {
                req = req.bearer_auth(&t);
            }
            let resp = req.send().context("request failed (is the hub up?)")?;
            let status = resp.status();
            if status.as_u16() == 404 {
                bail!("no such project: {slug}");
            }
            if !status.is_success() {
                bail!("download failed ({status})");
            }
            let bytes = resp.bytes()?;
            if bytes.is_empty() {
                bail!("server returned an empty bundle");
            }
            if dir.exists() {
                std::fs::remove_dir_all(&dir)?;
            }
            pack::extract_bundle(&bytes, &dir)?;
            let canonical = match &owner8 {
                Some(o) => format!("{o}/{slug}"),
                None => slug.clone(),
            };
            let _ = nd_core::sync::stamp(&dir, &canonical);
            downloaded_to = dir.display().to_string();
        }
    }

    let m = Manifest::load(&dir)?;
    let shell_cmd = m.run.trim().to_string();

    if !downloaded_to.is_empty() {
        println!("downloaded {slug} -> {downloaded_to}");
    }

    // Prefer a bundled binary for THIS platform (convention:
    // bin/<slug>-<os>-<arch>[.exe]), same shape the nd project itself
    // ships. --bin forces it, --shell forces the manifest command.
    let os = std::env::consts::OS;
    let arch = std::env::consts::ARCH;
    let exe = if cfg!(windows) { ".exe" } else { "" };
    let binary = format!("{slug}-{os}-{arch}{exe}");
    let candidates = [dir.join("bin").join(&binary), dir.join(&binary)];
    let bundled = if !want_shell {
        candidates.into_iter().find(|p| p.is_file())
    } else {
        None
    };

    if let Some(bin) = bundled {
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt as _;
            let _ = std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755));
        }
        println!("running: {} (bundled {os}-{arch} binary)", bin.display());
        let status = std::process::Command::new(&bin)
            .args(args)
            .current_dir(&dir)
            .status()
            .with_context(|| format!("failed to spawn {}", bin.display()))?;
        if !status.success() {
            std::process::exit(status.code().unwrap_or(1));
        }
        return Ok(());
    }
    if want_bin {
        bail!(
            "no bundled binary for {os}-{arch} in '{slug}' (looked for bin/{binary}); \
             run without --bin to use the manifest's `run` command"
        );
    }

    if shell_cmd.is_empty() {
        bail!(
            "'{slug}' has no `run` command and no bundled {os}-{arch} binary (bin/{binary})"
        );
    }
    let full = if args.is_empty() {
        shell_cmd.clone()
    } else {
        format!("{shell_cmd} {}", args.join(" "))
    };
    println!("running: {full}  (in {})", dir.display());
    // windows -> cmd /C, unix -> sh -c
    let mut proc = std::process::Command::new(if cfg!(windows) { "cmd" } else { "sh" });
    if cfg!(windows) {
        proc.arg("/C");
    } else {
        proc.arg("-c");
    }
    let status = proc
        .arg(&full)
        .current_dir(&dir)
        .status()
        .context("failed to spawn the platform shell (cmd /C or sh -c)")?;
    if !status.success() {
        std::process::exit(status.code().unwrap_or(1));
    }
    Ok(())
}

// ---------- doubles / forks / merge ----------

#[derive(serde::Serialize, serde::Deserialize)]
struct SimilarHit {
    p: Project,
    sim: nd_core::similar::Similarity,
}

pub fn doubles(ref_: &str, server: Option<String>, json: bool) -> Result<()> {
    let (slug, owner8) = parse_ref(ref_);
    let cfg = config::load();
    let base = server_base(server.as_deref(), &cfg);
    let resp = with_owner(
        client()?.get(format!("{base}/api/projects/{slug}/similar")),
        &owner8,
    )
    .send()
    .context("request failed (is the hub up?)")?;
    let status = resp.status();
    if status.as_u16() == 404 {
        bail!("no such project: {slug}");
    }
    if !status.is_success() {
        bail!("doubles failed ({status})");
    }
    let hits: Vec<SimilarHit> = resp.json()?;
    if json {
        println!("{}", serde_json::to_string_pretty(&hits)?);
        return Ok(());
    }
    if hits.is_empty() {
        println!("no doubles for {slug} — nobody shares its keywords yet");
        return Ok(());
    }
    println!("projects that look like {slug}:");
    for h in &hits {
        let mut shared: Vec<String> = Vec::new();
        for group in [
            &h.sim.shared_keywords,
            &h.sim.shared_code_keywords,
            &h.sim.shared_languages,
        ] {
            for s in group {
                if !shared.contains(s) {
                    shared.push(s.clone());
                }
            }
        }
        println!("  {:>3}  {}  — shared: {}", h.sim.score, h.p.slug, shared.join(", "));
    }
    Ok(())
}

pub fn forks(ref_: &str, server: Option<String>, json: bool) -> Result<()> {
    let (slug, owner8) = parse_ref(ref_);
    let cfg = config::load();
    let base = server_base(server.as_deref(), &cfg);
    let resp = with_owner(
        client()?.get(format!("{base}/api/projects/{slug}/forks")),
        &owner8,
    )
    .send()
    .context("request failed (is the hub up?)")?;
    let status = resp.status();
    if status.as_u16() == 404 {
        bail!("no such project: {slug}");
    }
    if !status.is_success() {
        bail!("forks failed ({status})");
    }
    let list: Vec<Project> = resp.json()?;
    if json {
        println!("{}", serde_json::to_string_pretty(&list)?);
        return Ok(());
    }
    if list.is_empty() {
        println!("no forks of {slug} yet");
        return Ok(());
    }
    println!("{} fork(s) of {slug}:", list.len());
    for f in &list {
        println!(
            "  {} {} — {}",
            f.slug,
            f.version,
            truncate(f.description.trim(), 90)
        );
        println!(
            "    forked by {} · {} files · {}/p/{}",
            f.owner_short().unwrap_or_else(|| "?".into()),
            f.file_count,
            base,
            f.url_path()
        );
    }
    Ok(())
}

pub fn merge(
    fork_ref: &str,
    dir: Option<PathBuf>,
    force: bool,
    server: Option<String>,
    token: Option<String>,
) -> Result<()> {
    println!("merging {fork_ref} into this dir — conflicts keep your side; review then nd upload");
    pull(
        Some(fork_ref.to_string()),
        dir,
        force,
        false,
        false,
        server,
        token,
    )
}

// ---------- delete / identity / transfer ----------

pub fn delete(ref_: &str, yes: bool, server: Option<String>, token: Option<String>) -> Result<()> {
    use std::io::IsTerminal;
    let (slug, owner8) = parse_ref(ref_);
    let cfg = config::load();
    let base = server_base(server.as_deref(), &cfg);
    let http = client()?;

    let mut req = with_owner(http.get(format!("{base}/api/projects/{slug}")), &owner8);
    if let Some(t) = auth(token.as_deref(), &cfg) {
        req = req.bearer_auth(&t);
    }
    let resp = req.send().context("request failed (is the hub up?)")?;
    if resp.status().as_u16() == 404 {
        bail!("no such project: {slug}");
    }
    if !resp.status().is_success() {
        bail!("lookup failed ({})", resp.status());
    }
    let p: Project = resp.json()?;
    println!(
        "\"{}\" — {} files, {} downloads. deleting also removes its reviews.",
        p.name, p.file_count, p.downloads
    );

    if !yes {
        if !std::io::stdin().is_terminal() {
            bail!("refusing to delete without --yes (non-interactive)");
        }
        print!("type 'delete' to confirm: ");
        use std::io::Write as _;
        std::io::stdout().flush().ok();
        let mut line = String::new();
        std::io::stdin().read_line(&mut line).unwrap_or(0);
        if line.trim() != "delete" {
            println!("aborted");
            return Ok(());
        }
    }

    let mut req = http.delete(format!("{base}/api/projects/{slug}"));
    let id = my_identity()?;
    if let Some(o) = &owner8 {
        req = req.query(&[("owner", o)]);
    }
    let sig = id.sign(format!("delete:{slug}").as_bytes());
    req = req
        .header("x-nd-owner", id.public_b64().as_str())
        .header("x-nd-signature", sig.as_str());
    if let Some(t) = auth(token.as_deref(), &cfg) {
        req = req.bearer_auth(&t);
    }
    let resp = req.send().context("request failed (is the hub up?)")?;
    let status = resp.status();
    if !status.is_success() {
        let body = resp.text().unwrap_or_default();
        bail!("delete failed ({status}): {body}");
    }
    println!("deleted: {slug}");
    Ok(())
}

pub fn whoami(export: bool) -> Result<()> {
    let path = config::identity_path()?;
    let id = my_identity()?;
    println!("identity:   {}", path.display());
    println!("public key: {}", id.public_b64());
    println!("short:      {}", id.short());
    println!("this file is your maintainership — back it up; whoever holds it owns your projects");
    if export {
        let seed = std::fs::read_to_string(&path)?;
        println!();
        println!("SECRET seed (store somewhere safe; restorable to the path above):");
        println!("{}", seed.trim());
    }
    Ok(())
}

pub fn transfer(
    slug: &str,
    new_owner: &str,
    server: Option<String>,
    token: Option<String>,
) -> Result<()> {
    let cfg = config::load();
    let base = server_base(server.as_deref(), &cfg);
    if !identity::pubkey_valid(new_owner) {
        bail!("new owner must be a base64 ed25519 public key — `nd whoami` prints yours");
    }
    let id = my_identity()?;
    let msg = format!("transfer:{slug}:{new_owner}");
    let mut req = client()?
        .post(format!("{base}/api/projects/{slug}/transfer"))
        .json(&serde_json::json!({ "new_owner": new_owner }))
        .header("x-nd-owner", id.public_b64().as_str())
        .header("x-nd-signature", id.sign(msg.as_bytes()).as_str());
    if let Some(t) = auth(token.as_deref(), &cfg) {
        req = req.bearer_auth(&t);
    }
    let resp = req.send().context("request failed (is the hub up?)")?;
    let status = resp.status();
    if !status.is_success() {
        let body = resp.text().unwrap_or_default();
        bail!("transfer failed ({status}): {body}");
    }
    println!("transferred {slug} to {}", identity::short_key(new_owner));
    Ok(())
}

// ---------- pull (three-way dir sync) ----------

pub fn pull(
    ref__: Option<String>,
    dir: Option<PathBuf>,
    force: bool,
    dry: bool,
    retrack: bool,
    server: Option<String>,
    token: Option<String>,
) -> Result<()> {
    use nd_core::sync::{self, Hashes};
    use std::collections::BTreeSet;

    let dir = dir.unwrap_or_else(|| PathBuf::from("."));
    let cfg = config::load();
    let base_url = server_base(server.as_deref(), &cfg);
    let http = client()?;

    let m = Manifest::load(&dir)?;
    let tracked = m
        .extra
        .get("nd_ref")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string());
    let refl = match (&ref__, &tracked) {
        (Some(r), _) => r.clone(),
        (None, Some(t)) => t.clone(),
        (None, None) => slugify(&m.name),
    };
    let (slug, owner8) = parse_ref(&refl);

    let mut req = with_owner(http.get(format!("{base_url}/api/projects/{slug}")), &owner8);
    if let Some(t) = auth(token.as_deref(), &cfg) {
        req = req.bearer_auth(&t);
    }
    let resp = req.send().context("request failed (is the hub up?)")?;
    let status = resp.status();
    if status.as_u16() == 404 {
        bail!("no project '{slug}' on {base_url} — it was never uploaded (nd upload first)");
    }
    if !status.is_success() {
        let body = resp.text().unwrap_or_default();
        bail!("lookup failed ({status}): {body}");
    }
    let p: Project = resp.json()?;
    let canonical = p.url_path();

    let mut req = with_owner(
        http.get(format!("{base_url}/api/projects/{slug}/bundle")),
        &owner8,
    );
    if let Some(t) = auth(token.as_deref(), &cfg) {
        req = req.bearer_auth(&t);
    }
    let resp = req.send().context("request failed (is the hub up?)")?;
    if !resp.status().is_success() {
        bail!("bundle download failed ({})", resp.status());
    }
    let bundle = resp.bytes()?;
    if bundle.is_empty() {
        bail!("hub returned an empty bundle");
    }

    let tmp = std::env::temp_dir().join(format!("nd-pull-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&tmp);
    pack::extract_bundle(&bundle, &tmp).context("unpack bundle")?;

    let remote: Hashes = sync::hash_dir(&tmp)?;
    let local: Hashes = sync::hash_dir(&dir)?;
    let b = sync::load(&dir);

    let remote_rels: BTreeSet<String> = remote.keys().cloned().collect();
    let local_rels: BTreeSet<String> = local.keys().cloned().collect();

    let mut updated: Vec<(String, String)> = Vec::new();
    let mut removed: Vec<String> = Vec::new();
    let mut kept: Vec<String> = Vec::new();
    let mut conflicts: Vec<String> = Vec::new();
    let mut unchanged = 0usize;

    let is_merge = !dry && !retrack;
    for rel in &remote_rels {
        // merging a FORK: never copy its manifest over the origin's.
        if is_merge && rel == MANIFEST_FILE {
            let dot = dir.join(".nd");
            let _ = std::fs::create_dir_all(&dot);
            let _ = std::fs::copy(tmp.join(rel), dot.join("proposed-notdouble.toml"));
            kept.push(format!(
                "{rel} — fork's manifest saved to .nd/proposed-notdouble.toml (never shipped; review + merge by hand)"
            ));
            continue;
        }
        let rhash = remote.get(rel).unwrap().clone();
        match local.get(rel) {
            None => {
                if b.files.contains_key(rel) {
                    if !force {
                        conflicts.push(format!(
                            "{rel} — deleted locally, still on the hub (--force to re-add)"
                        ));
                        continue;
                    }
                }
                if !dry {
                    let dst = dir.join(rel);
                    if let Some(parent) = dst.parent() {
                        std::fs::create_dir_all(parent)?;
                    }
                    std::fs::copy(tmp.join(rel), &dst)?;
                }
                updated.push((rel.clone(), "(new)".into()));
            }
            Some(lhash) => {
                if lhash == &rhash {
                    unchanged += 1;
                    continue;
                }
                let base = b.files.get(rel);
                let local_changed = base.map(|x| x != lhash).unwrap_or(true);
                let remote_changed = base.map(|x| x != &rhash).unwrap_or(true);
                if !local_changed {
                    if !dry {
                        let dst = dir.join(rel);
                        std::fs::copy(tmp.join(rel), &dst)?;
                    }
                    updated.push((rel.clone(), String::new()));
                } else if !remote_changed {
                    kept.push(format!("{rel} — your local changes, kept"));
                } else if force {
                    if !dry {
                        let dst = dir.join(rel);
                        std::fs::copy(tmp.join(rel), &dst)?;
                    }
                    updated.push((rel.clone(), "(--force took the hub copy)".into()));
                } else {
                    conflicts.push(format!(
                        "{rel} — changed both locally and on the hub (edit + nd upload, or --force)"
                    ));
                }
            }
        }
    }
    for rel in local_rels.difference(&remote_rels) {
        let rel = rel.clone();
        if is_merge && rel == MANIFEST_FILE {
            continue;
        }
        let Some(base) = b.files.get(&rel) else {
            kept.push(format!("{rel} — local only"));
            continue;
        };
        let lhash = local.get(&rel).unwrap();
        if base != lhash {
            kept.push(format!("{rel} — locally changed, deleted on the hub"));
            continue;
        }
        if !dry {
            let _ = std::fs::remove_file(dir.join(&rel));
        }
        removed.push(rel.clone());
    }

    if !dry {
        let mut nb = b.clone();
        nb.nd_ref = if retrack { canonical.clone() } else { b.nd_ref.clone() };
        for (rel, _note) in &updated {
            let h = remote.get(rel).or_else(|| local.get(rel));
            if let Some(h) = h {
                nb.files.insert(rel.clone(), h.clone());
            }
        }
        for rel in &remote_rels {
            if let (Some(l), Some(r)) = (local.get(rel), remote.get(rel)) {
                if l == r {
                    nb.files.insert(rel.clone(), r.clone());
                }
            }
        }
        for rel in &removed {
            nb.files.remove(rel);
        }
        sync::save(&dir, &nb)?;
    }

    let _ = std::fs::remove_dir_all(&tmp);

    if dry {
        println!("diff --content preview for /p/{canonical} (nothing on disk changed):");
    } else if retrack {
        println!("pulled from {base_url} — tracking /p/{canonical}");
    } else {
        println!("pulled {refl} from {base_url}");
    }
    for (rel, note) in &updated {
        println!("  updated {rel} {note}");
    }
    for rel in &removed {
        println!("  removed {rel}");
    }
    for rel in &kept {
        println!("  kept {rel}");
    }
    for c in &conflicts {
        println!("  conflict {c}");
    }
    println!(
        "{} updated, {} removed, {} kept (local), {} unchanged, {} conflicts{}",
        updated.len(),
        removed.len(),
        kept.len(),
        unchanged,
        conflicts.len(),
        if conflicts.is_empty() {
            ".".to_string()
        } else if dry {
            " — nothing was overwritten (nd pull to apply, --force to take hub copies)".to_string()
        } else {
            " — edit and `nd upload`, or `nd pull --force`".to_string()
        }
    );
    Ok(())
}

// ---------- ls / resume ----------

fn print_ls(p: &Project) {
    let mut line = format!("  {} {}", p.slug, p.version);
    if let Some(o) = p.owner_short() {
        line.push_str(&format!("  [{o}]"));
    }
    println!("{line}");
    if !p.description.is_empty() {
        println!("    {}", truncate(p.description.trim(), 90));
    }
}

pub fn ls(all: bool, server: Option<String>, json: bool) -> Result<()> {
    let cfg = config::load();
    let base = server_base(server.as_deref(), &cfg);
    let resp = client()?
        .get(format!("{base}/api/projects?limit=200"))
        .send()
        .context("request failed (is the hub up?)")?;
    let status = resp.status();
    if !status.is_success() {
        bail!("ls failed ({status})");
    }
    let projects: Vec<Project> = resp.json()?;
    if json {
        println!("{}", serde_json::to_string_pretty(&projects)?);
        return Ok(());
    }
    let my8 = config::identity_path()
        .ok()
        .filter(|p| p.exists())
        .and_then(|p| identity::load(&p).ok())
        .map(|id| id.short());
    if all {
        println!("all projects on the hub:");
        for p in &projects {
            print_ls(p);
        }
        println!("{} project(s)", projects.len());
        return Ok(());
    }
    let Some(my8) = my8 else {
        bail!("no identity yet — it is created on your first signed upload");
    };
    let mine: Vec<&Project> = projects
        .iter()
        .filter(|p| p.owner_short().as_deref() == Some(my8.as_str()))
        .collect();
    println!("your projects ({my8}):");
    for p in &mine {
        print_ls(p);
    }
    println!("{} project(s)", mine.len());
    Ok(())
}

pub fn resume(
    owner: Option<String>,
    markdown: bool,
    json: bool,
    server: Option<String>,
) -> Result<()> {
    let cfg = config::load();
    let base = server_base(server.as_deref(), &cfg);
    let resp = client()?
        .get(format!("{base}/api/projects?limit=200"))
        .send()
        .context("request failed (is the hub up?)")?;
    let status = resp.status();
    if !status.is_success() {
        bail!("resume failed ({status})");
    }
    let projects: Vec<Project> = resp.json()?;

    let target = match owner {
        Some(o) => o.trim().to_string(),
        None => config::identity_path()
            .ok()
            .filter(|p| p.exists())
            .and_then(|p| identity::load(&p).ok())
            .map(|id| id.short())
            .ok_or_else(|| {
                anyhow::anyhow!("no identity yet — pass an owner id or nd upload something first")
            })?,
    };
    let target = target.as_str();
    let mine: Vec<Project> = projects
        .into_iter()
        .filter(|p| p.owner_short().as_deref() == Some(target))
        .collect();
    if mine.is_empty() {
        bail!("no projects under '{target}' (owner ids are the 8-char key; `nd ls --all` lists everything)");
    }

    let skills = nd_core::resume::collect(&mine);
    if json {
        let stats = serde_json::json!({
            "owner": target,
            "hub": base,
            "project_count": mine.len(),
            "total_downloads": mine.iter().map(|p| p.downloads).sum::<i64>(),
            "skills": skills,
            "projects": mine,
        });
        println!("{}", serde_json::to_string_pretty(&stats)?);
        return Ok(());
    }
    println!(
        "{}",
        if markdown {
            crate::resume::render_markdown(&mine, &skills, target, &base)
        } else {
            crate::resume::render_plain(&mine, &skills, target, &base)
        }
    );
    if markdown {
        println!("paste hint: pipe it to a file — `nd resume --markdown > projects.md`");
    }
    Ok(())
}

// ---------- update-nd ----------

pub fn update_nd(force: bool, server: Option<String>, token: Option<String>) -> Result<()> {
    let current = env!("CARGO_PKG_VERSION").to_string();
    let cfg = config::load();
    let base = server_base(server.as_deref(), &cfg);
    let http = client()?;

    let mut req = http.get(format!("{base}/api/projects/nd"));
    if let Some(t) = auth(token.as_deref(), &cfg) {
        req = req.bearer_auth(&t);
    }
    let resp = req.send().context("request failed (is the hub up?)")?;
    if resp.status().as_u16() == 404 {
        bail!("no 'nd' project published on {base} yet");
    }
    if !resp.status().is_success() {
        bail!("could not reach the hub ({})", resp.status());
    }
    let meta: Project = resp.json()?;

    if meta.version == current && !force {
        println!("already up to date (nd {current})");
        return Ok(());
    }

    let mut req = http.get(format!("{base}/api/projects/nd/bundle"));
    if let Some(t) = auth(token.as_deref(), &cfg) {
        req = req.bearer_auth(&t);
    }
    let resp = req.send().context("request failed (is the hub up?)")?;
    if !resp.status().is_success() {
        bail!("bundle download failed ({})", resp.status());
    }
    let bytes = resp.bytes()?;
    if bytes.is_empty() {
        bail!("hub returned an empty bundle");
    }

    let tmp = std::env::temp_dir().join(format!("nd-update-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&tmp);
    pack::extract_bundle(&bytes, &tmp).context("unpack bundle")?;

    let candidate = tmp.join("bin").join(format!(
        "nd-{}-{}{}",
        std::env::consts::OS,
        std::env::consts::ARCH,
        if cfg!(windows) { ".exe" } else { "" }
    ));
    let check = match std::process::Command::new(&candidate).arg("--version").output() {
        Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_string(),
        _ => {
            let _ = std::fs::remove_dir_all(&tmp);
            bail!("downloaded binary is not runnable: {}", candidate.display());
        }
    };

    let exe = std::env::current_exe().context("locate current nd binary")?;
    let staged = exe.with_extension("update-new");
    std::fs::copy(&candidate, &staged).with_context(|| format!("stage {}", staged.display()))?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt as _;
        std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))?;
    }
    if cfg!(windows) {
        // windows locks a running exe — rename IT aside, then move the new
        // one into place, rolling back on failure.
        let old = exe.with_extension("old");
        let _ = std::fs::remove_file(&old);
        std::fs::rename(&exe, &old)
            .with_context(|| format!("rename running exe aside ({})", old.display()))?;
        if let Err(e) = std::fs::rename(&staged, &exe) {
            let _ = std::fs::rename(&old, &exe);
            return Err(anyhow::anyhow!("replace {} (rolled back): {e}", exe.display()));
        }
    } else {
        std::fs::rename(&staged, &exe).with_context(|| format!("replace {}", exe.display()))?;
    }
    let _ = std::fs::remove_dir_all(&tmp);

    let why = if force && meta.version == current {
        " (forced)"
    } else {
        ""
    };
    println!("updated: {check}{why}");
    println!("restart any running sessions to pick it up");
    Ok(())
}