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(())
}