| 1 | |
|
| 2 | |
|
| 3 | | use super::identity::owner_home; |
| 4 | | use super::{call, ui, Ctx}; |
| 5 | | use anyhow::{bail, Context, Result}; |
| 6 | | use spectra_core::keys::KeyPair; |
| 7 | | use spectra_core::ledger::Ledger; |
| 8 | | use spectra_core::principals::signed_event; |
| 9 | | use spectra_core::projects; |
| 10 | | use std::path::{Path, PathBuf}; |
| 11 | |
|
| 12 | | pub struct Add { |
| 13 | | pub target: String, |
| 14 | | pub name: Option<String>, |
| 15 | | pub path: Option<PathBuf>, |
| 16 | | pub description: String, |
| 17 | | pub url: Option<String>, |
| 18 | | } |
| 19 | |
|
| 20 | 23× | pub async fn add(ctx: &Ctx, a: Add, owner_home_flag: Option<PathBuf>) -> Result<i32> { |
| 21 | | |
| 22 | 23× | let as_dir = Path::new(&shellexpand(&a.target)).to_path_buf(); |
| 23 | 23× | let (name, path) = match (&a.path, as_dir.is_dir()) { |
| 24 | | (None, true) => { |
| 25 | 20× | let path = as_dir.canonicalize()?; |
| 26 | 20× | (a.name.clone().map(Ok).unwrap_or_else(|| projects::default_name(&path))?, Some(path)) |
| 27 | | } |
| 28 | 0× | (Some(p), _) => { |
| 29 | 0× | let path = p.canonicalize().with_context(|| format!("{} does not exist", p.display()))?; |
| 30 | 0× | (a.name.clone().unwrap_or_else(|| a.target.clone()), Some(path)) |
| 31 | | } |
| 32 | 3× | (None, false) => (a.name.clone().unwrap_or_else(|| a.target.clone()), None), |
| 33 | | }; |
| 34 | 23× | if !super::agent::valid_name(&name) { |
| 35 | 1× | bail!("a project's name is lower-case letters, digits and dashes, starting with a letter or digit (--name)"); |
| 36 | 22× | } |
| 37 | 22× | let home = owner_home(ctx, owner_home_flag); |
| 38 | 22× | let w = ctx.owner_writer(&home).await?; |
| 39 | 22× | let path_s = path.as_ref().map(|p| p.display().to_string()); |
| 40 | 22× | let existing = all(&w).await?; |
| 41 | 22× | if let Some(e) = existing.iter().find(|p| p["name"] == name.as_str() || (path_s.is_some() && p["path"].as_str() == path_s.as_deref())) { |
| 42 | 1× | bail!("{} is already a project", e["name"].as_str().unwrap_or("")); |
| 43 | 21× | } |
| 44 | 21× | let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect(); |
| 45 | 21× | let owner = KeyPair::load_home(&home)?; |
| 46 | 21× | let payload = serde_json::json!({ "name": name, "path": path_s, "description": a.description, "url": a.url }); |
| 47 | 21× | events.push(signed_event(&owner, "human", "project.added", payload).project(name.clone()).subject(name.clone())); |
| 48 | 21× | events.push(spectra_core::conversation::project_channel_event(&name)); |
| 49 | 21× | w.append(events).await?; |
| 50 | 21× | println!("{} {}{}", ui::done("Added"), ui::subject(&name), path_s.map(|p| format!(" {}", ui::path(p))).unwrap_or_default()); |
| 51 | 21× | if !a.description.is_empty() { |
| 52 | 3× | println!("{}", ui::kv("about", &a.description)); |
| 53 | 18× | } |
| 54 | 21× | if let Some(u) = &a.url { |
| 55 | 1× | println!("{}", ui::kv("link", u)); |
| 56 | 20× | } |
| 57 | 21× | println!( |
| 58 | | " {} {}", |
| 59 | 21× | ui::note(), |
| 60 | 21× | ui::dim(format!("agents talk about it in #{name}; link it with spectra project link {name} --depends-on OTHER")) |
| 61 | | ); |
| 62 | 21× | Ok(0) |
| 63 | 23× | } |
| 64 | |
|
| 65 | |
|
| 66 | 23× | fn shellexpand(s: &str) -> String { |
| 67 | 23× | match (s.strip_prefix("~/"), std::env::var_os("HOME")) { |
| 68 | 0× | (Some(rest), Some(h)) => Path::new(&h).join(rest).display().to_string(), |
| 69 | 23× | _ => s.to_string(), |
| 70 | | } |
| 71 | 23× | } |
| 72 | |
|
| 73 | |
|
| 74 | 3× | pub async fn link(ctx: &Ctx, project: &str, on: &str, remove: bool, owner_home_flag: Option<PathBuf>) -> Result<i32> { |
| 75 | 3× | if project == on { |
| 76 | 0× | bail!("a project cannot depend on itself"); |
| 77 | 3× | } |
| 78 | 3× | let home = owner_home(ctx, owner_home_flag); |
| 79 | 3× | let w = ctx.owner_writer(&home).await?; |
| 80 | 3× | let existing = all(&w).await?; |
| 81 | 6× | for n in [project, on] { |
| 82 | 9× | if !existing.iter().any(|p| p["name"] == n) { |
| 83 | 1× | bail!("no project named {n} (spectra project list)"); |
| 84 | 5× | } |
| 85 | | } |
| 86 | 2× | let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect(); |
| 87 | 2× | let owner = KeyPair::load_home(&home)?; |
| 88 | 2× | let kind = if remove { "project.unlinked" } else { "project.linked" }; |
| 89 | 2× | events.push( |
| 90 | 2× | signed_event(&owner, "human", kind, serde_json::json!({ "project": project, "depends_on": on })).project(project.to_string()), |
| 91 | | ); |
| 92 | 2× | w.append(events).await?; |
| 93 | 2× | let verb = if remove { "Unlinked" } else { "Linked" }; |
| 94 | 2× | println!("{} {} {} {}", ui::done(verb), ui::subject(project), ui::dim("depends on"), ui::subject(on)); |
| 95 | 2× | Ok(0) |
| 96 | 3× | } |
| 97 | |
|
| 98 | 13× | async fn relations(ledger: &Ledger, name: &str) -> Result<(Vec<String>, Vec<String>)> { |
| 99 | 13× | let deps = sqlx::query_scalar("SELECT depends_on FROM project_relations WHERE project = ? ORDER BY 1") |
| 100 | 13× | .bind(name) |
| 101 | 13× | .fetch_all(ledger.pool()) |
| 102 | 13× | .await?; |
| 103 | 13× | let users = sqlx::query_scalar("SELECT project FROM project_relations WHERE depends_on = ? ORDER BY 1") |
| 104 | 13× | .bind(name) |
| 105 | 13× | .fetch_all(ledger.pool()) |
| 106 | 13× | .await?; |
| 107 | 13× | Ok((deps, users)) |
| 108 | 13× | } |
| 109 | |
|
| 110 | |
|
| 111 | |
|
| 112 | 25× | async fn all(w: &super::Writer) -> Result<Vec<serde_json::Value>> { |
| 113 | 25× | match (w.client(), w.ledger()) { |
| 114 | 2× | (Some(c), _) => Ok(call(c, "GET", "/api/v1/projects", None).await?.as_array().cloned().unwrap_or_default()), |
| 115 | 23× | (None, Some(l)) => local(l).await, |
| 116 | 0× | (None, None) => Ok(vec![]), |
| 117 | | } |
| 118 | 25× | } |
| 119 | |
|
| 120 | 26× | async fn local(ledger: &Ledger) -> Result<Vec<serde_json::Value>> { |
| 121 | 26× | let rows: Vec<(String, Option<String>, String, Option<String>)> = |
| 122 | 26× | sqlx::query_as("SELECT name, path, description, url FROM projects ORDER BY name").fetch_all(ledger.pool()).await?; |
| 123 | 26× | let mut out = Vec::new(); |
| 124 | 26× | for (name, path, description, url) in rows { |
| 125 | 13× | let (deps, users) = relations(ledger, &name).await?; |
| 126 | 13× | let agents: Vec<String> = sqlx::query_scalar("SELECT name FROM agents WHERE project = ? AND disabled_ms IS NULL ORDER BY name") |
| 127 | 13× | .bind(&name) |
| 128 | 13× | .fetch_all(ledger.pool()) |
| 129 | 13× | .await?; |
| 130 | 13× | out.push(serde_json::json!({ "name": name, "path": path, "description": description, "url": url, "depends_on": deps, "depended_on_by": users, "agents": agents })); |
| 131 | | } |
| 132 | 26× | Ok(out) |
| 133 | 26× | } |
| 134 | |
|
| 135 | 4× | pub async fn list(ctx: &Ctx, json: bool) -> Result<i32> { |
| 136 | 4× | let out = match ctx.owner_client(&owner_home(ctx, None)).await? { |
| 137 | 1× | Some(c) => call(&c, "GET", "/api/v1/projects", None).await?.as_array().cloned().unwrap_or_default(), |
| 138 | 3× | None => local(&Ledger::open_read_only(&ctx.db()?).await?).await?, |
| 139 | | }; |
| 140 | 4× | if json { |
| 141 | 4× | println!("{}", serde_json::Value::Array(out)); |
| 142 | 4× | return Ok(0); |
| 143 | 0× | } |
| 144 | 0× | if out.is_empty() { |
| 145 | 0× | println!("{} {}", ui::note(), ui::dim("no projects yet: spectra project add NAME-OR-FOLDER")); |
| 146 | 0× | } |
| 147 | 0× | for p in out { |
| 148 | 0× | let list = |
| 149 | 0× | |k: &str| p[k].as_array().map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", ")).unwrap_or_default(); |
| 150 | 0× | println!( |
| 151 | | "{} {} {}", |
| 152 | 0× | ui::glyph("▸", ">"), |
| 153 | 0× | ui::bookmark(p["name"].as_str().unwrap_or("")), |
| 154 | 0× | ui::path(p["path"].as_str().unwrap_or("")) |
| 155 | | ); |
| 156 | 0× | if let Some(d) = p["description"].as_str().filter(|d| !d.is_empty()) { |
| 157 | 0× | println!("{}", ui::kv("about", d)); |
| 158 | 0× | } |
| 159 | 0× | for (k, label) in [("depends_on", "depends on"), ("depended_on_by", "needed by"), ("agents", "agents")] { |
| 160 | 0× | let v = list(k); |
| 161 | 0× | if !v.is_empty() { |
| 162 | 0× | println!("{}", ui::kv(label, v)); |
| 163 | 0× | } |
| 164 | | } |
| 165 | | } |
| 166 | 0× | Ok(0) |
| 167 | 4× | } |