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::load_or_create(&config::identity_path()?) } /// A project ref: plain "slug" or owner-scoped "owner8/slug". fn parse_ref(r: &str) -> (String, Option) { 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= to a request when the ref carried an owner id. fn with_owner( mut req: reqwest::blocking::RequestBuilder, owner8: &Option, ) -> 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::(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::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 { 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 = 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, out: Option, 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 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 to write the bundle, `nd upload` to push)"); } Ok(()) } /// `nd status` — which hub, which identity, token, reachability. pub fn status(server: Option) -> 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, server: Option, token: Option, ) -> 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 = plan.included.iter().map(|e| e.rel.clone()).collect(); let remote: BTreeSet = sync::hash_dir(&tmp)?.keys().cloned().collect(); const CAP: u64 = 300 * 1024; let read_text = |root: &Path, rel: &str| -> Option { 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 ` — 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, content: bool, patch: bool, server: Option, token: Option, ) -> 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, 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 = 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, 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, kind: &str) -> Result<()> { let dir = path.unwrap_or_else(|| PathBuf::from(".")); let mut m = Manifest::load(&dir)?; let parts: Vec = m .version .split('.') .map(|p| p.parse::().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> { 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, yes: bool, server: Option, token: Option, 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 = 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 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::(&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, server: Option, token: Option, 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, do_upload: bool, server: Option, token: Option, ) -> 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, name: Option, do_upload: bool, server: Option, token: Option, ) -> 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 `"); }; 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, 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 = 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 = 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, token: Option, 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::(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, author: &str, server: Option, token: Option, ) -> 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, 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 = 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, server: Option, token: Option, ) -> 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, 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 = 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 { // 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::(&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 { 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 " ) } } 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, refresh: bool, args: &[String], want_bin: bool, want_shell: bool, server: Option, token: Option, ) -> 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/--[.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, 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 = 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 = 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, 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 = 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, force: bool, server: Option, token: Option, ) -> 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, token: Option) -> 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, token: Option, ) -> 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, dir: Option, force: bool, dry: bool, retrack: bool, server: Option, token: Option, ) -> 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 = remote.keys().cloned().collect(); let local_rels: BTreeSet = local.keys().cloned().collect(); let mut updated: Vec<(String, String)> = Vec::new(); let mut removed: Vec = Vec::new(); let mut kept: Vec = Vec::new(); let mut conflicts: Vec = 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, 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 = 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, markdown: bool, json: bool, server: Option, ) -> 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 = 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 = 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::(), "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, token: Option) -> 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(()) }