OXYGENHUB
# f4b3e9369e28 spectra / src / cli / project.rs
7.1 KiB · Rust · last changed Spectra stands alone: its own identities, signatures and CBOR; projects of any kind of work (#26, #27) 1 day ago source blame history raw
1
//! `spectra project`: the work agents share (spec §23.1), any kind of it.
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
pub async fn add(ctx: &Ctx, a: Add, owner_home_flag: Option<PathBuf>) -> Result<i32> {
21
    // A folder that exists is the work's folder; anything else is a name.
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),
33
    };
34
    if !super::agent::valid_name(&name) {
35
        bail!("a project's name is lower-case letters, digits and dashes, starting with a letter or digit (--name)");
36
    }
37
    let home = owner_home(ctx, owner_home_flag);
38
    let w = ctx.owner_writer(&home).await?;
39
    let path_s = path.as_ref().map(|p| p.display().to_string());
40
    let existing = all(&w).await?;
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(""));
43
    }
44
    let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect();
45
    let owner = KeyPair::load_home(&home)?;
46
    let payload = serde_json::json!({ "name": name, "path": path_s, "description": a.description, "url": a.url });
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?;
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));
53
    }
54
    if let Some(u) = &a.url {
55
        println!("{}", ui::kv("link", u));
56
    }
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
/// `~/x` as the shell would have it.
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(),
70
    }
71
}
72
73
/// `spectra project link P --depends-on Q [--remove]`.
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");
77
    }
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
        }
85
    }
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));
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
110
/// Every project with its relations and agents: from the daemon when one
111
/// serves, else from the ledger here.
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>> {
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?;
123
    let mut out = Vec::new();
124
    for (name, path, description, url) in rows {
125
        let (deps, users) = relations(ledger, &name).await?;
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?;
130
        out.push(serde_json::json!({ "name": name, "path": path, "description": description, "url": url, "depends_on": deps, "depended_on_by": users, "agents": agents }));
131
    }
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
    };
140
    if json {
141
        println!("{}", serde_json::Value::Array(out));
142
        return Ok(0);
143
    }
144
    if out.is_empty() {
145
        println!("{} {}", ui::note(), ui::dim("no projects yet: spectra project add NAME-OR-FOLDER"));
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
        );
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")] {
160
            let v = list(k);
161
            if !v.is_empty() {
162
                println!("{}", ui::kv(label, v));
163
            }
164
        }
165
    }
166
    Ok(0)
167
}