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