The command line from another machine: every command through the API (#23, completes #21) 67bfa06a894f
With a server configured, the workstation needs no ledger file: reads and the owner's events go through the JSON API, and admin verify runs on the server. Locally, commands read through the daemon whenever it serves, so one path is tested on one machine and on two. An agent's shim talks to the server over TLS. Co-Authored-By: Claude Opus 5.5 <[email protected]>
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 48580f1c0455
- When
- · 2026-11-03 13:36 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
67bfa06a894ff1f171c8f284d5295a676d8e46f29f8ceb212536b0a7b9b184d8- Depends on
- 09752034b5a8 258bb3824813 2fc88a7b3a5c 36537112fcac 571572f5fb2c 5d695d869fbf 77df591d5a2f d17cbc7069ce da84e98f104e e071e91a14ef eca5af92279b
- Depended on by
- 1876f2ce71e4 862f0db224aa b044d90930d9 98a0fb9f0948 01320eefd505
- In bookmarks
- main
- In tags
- v0.1.0
- Edits
- 85 atoms, 1 tree operation, 12 files
Browse the tree at this change
added tests/client.rs +106 −0
| 1 | + | //! The whole command line from a workstation (spec §8.6, spectra#21 and |
|
| 2 | + | //! #23): the daemon on one machine, the owner, their agents and every |
|
| 3 | + | //! command on another, with no ledger file on the workstation at all. |
|
| 4 | + | ||
| 5 | + | mod common; |
|
| 6 | + | ||
| 7 | + | use common::{add_agent, delegate, hermetic, run, text, Serving}; |
|
| 8 | + | use serde_json::{json, Value}; |
|
| 9 | + | use spectra_core::keys::KeyPair; |
|
| 10 | + | use std::path::Path; |
|
| 11 | + | use std::process::Stdio; |
|
| 12 | + | use std::time::Duration; |
|
| 13 | + | use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; |
|
| 14 | + | ||
| 15 | + | fn json_of(d: &Path, args: &[&str]) -> Value { |
|
| 16 | + | let o = run(d, args); |
|
| 17 | + | assert!(o.status.success(), "{args:?}: {}", text(&o)); |
|
| 18 | + | serde_json::from_slice(&o.stdout) |
|
| 19 | + | .unwrap_or_else(|e| panic!("{args:?}: one JSON document ({e}): {}", String::from_utf8_lossy(&o.stdout))) |
|
| 20 | + | } |
|
| 21 | + | ||
| 22 | + | /// A server in `s`, claimed by the owner whose home is `c/owner`. |
|
| 23 | + | fn connected(s: &Path, c: &Path) -> (Serving, u16) { |
|
| 24 | + | KeyPair::create_home(&c.join("owner"), "Andy McCall", "").unwrap(); |
|
| 25 | + | let owner = KeyPair::load_home(&c.join("owner")).unwrap(); |
|
| 26 | + | assert!(run(s, &["init", "--owner", &owner.key.to_hex(), "--name", "earth"]).status.success()); |
|
| 27 | + | let (srv, port) = Serving::start_tls(s); |
|
| 28 | + | let url = format!("https://127.0.0.1:{port}"); |
|
| 29 | + | assert_eq!(run(c, &["connect", &url]).status.code(), Some(1)); |
|
| 30 | + | delegate(&c.join("owner"), &KeyPair::instance(&s.join("data"), "earth").unwrap()); |
|
| 31 | + | let o = run(c, &["connect", &url]); |
|
| 32 | + | assert!(o.status.success(), "{}", text(&o)); |
|
| 33 | + | (srv, port) |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | #[tokio::test] |
|
| 37 | + | async fn every_command_works_from_another_machine() { |
|
| 38 | + | let (sd, cd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); |
|
| 39 | + | let (s, c) = (sd.path(), cd.path()); |
|
| 40 | + | let (srv, _port) = connected(s, c); |
|
| 41 | + | ||
| 42 | + | std::fs::create_dir_all(c.join("oxygen")).unwrap(); |
|
| 43 | + | let o = run(c, &["project", "add", c.join("oxygen").to_str().unwrap()]); |
|
| 44 | + | assert!(o.status.success(), "{}", text(&o)); |
|
| 45 | + | add_agent(c, "claude-oxygen", &[]); |
|
| 46 | + | add_agent(c, "spare", &[]); |
|
| 47 | + | ||
| 48 | + | let id = json_of(c, &["identity", "show", "--json"]); |
|
| 49 | + | assert_eq!(id["instance"]["name"], "earth"); |
|
| 50 | + | assert_eq!(id["instance"]["delegated"], true, "{id}"); |
|
| 51 | + | let agents = json_of(c, &["agent", "list", "--json"]); |
|
| 52 | + | let claude = agents.as_array().unwrap().iter().find(|a| a["name"] == "claude-oxygen").unwrap(); |
|
| 53 | + | assert_eq!(claude["state"], "ok", "{claude}"); |
|
| 54 | + | assert_eq!(claude["display"], "claude-oxygen · Andy McCall's agent"); |
|
| 55 | + | let projects = json_of(c, &["project", "list", "--json"]); |
|
| 56 | + | assert_eq!(projects[0]["name"], "oxygen"); |
|
| 57 | + | assert!(projects[0]["agents"].as_array().unwrap().contains(&json!("claude-oxygen"))); |
|
| 58 | + | let o = run(c, &["agent", "disable", "spare"]); |
|
| 59 | + | assert!(o.status.success(), "{}", text(&o)); |
|
| 60 | + | ||
| 61 | + | assert_eq!(json_of(c, &["say", "--json", "-c", "general", "the release is tomorrow"])["channel"], "#general"); |
|
| 62 | + | assert!(json_of(c, &["inbox", "--json"]).is_array()); |
|
| 63 | + | assert_eq!(json_of(c, &["search", "--json", "release"]).as_array().unwrap().len(), 1); |
|
| 64 | + | let brief: Value = serde_json::from_slice(&run(c, &["api", "GET", "/api/v1/brief"]).stdout).unwrap(); |
|
| 65 | + | assert_eq!(brief["actor"], "human"); |
|
| 66 | + | ||
| 67 | + | // An agent's shim on the workstation talks to the server. |
|
| 68 | + | let mut cmd = tokio::process::Command::from(hermetic(c)); |
|
| 69 | + | let home = c.join("agents/claude-oxygen"); |
|
| 70 | + | cmd.args(["mcp", "--agent", "claude-oxygen", "--home", home.to_str().unwrap()]) |
|
| 71 | + | .stdin(Stdio::piped()) |
|
| 72 | + | .stdout(Stdio::piped()) |
|
| 73 | + | .stderr(Stdio::piped()) |
|
| 74 | + | .kill_on_drop(true); |
|
| 75 | + | let mut shim = cmd.spawn().unwrap(); |
|
| 76 | + | let mut stdin = shim.stdin.take().unwrap(); |
|
| 77 | + | let mut lines = BufReader::new(shim.stdout.take().unwrap()).lines(); |
|
| 78 | + | for v in [ |
|
| 79 | + | json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "t", "version": "1" } } }), |
|
| 80 | + | json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "brief", "arguments": {} } }), |
|
| 81 | + | ] { |
|
| 82 | + | stdin.write_all(format!("{v}\n").as_bytes()).await.unwrap(); |
|
| 83 | + | } |
|
| 84 | + | stdin.flush().await.unwrap(); |
|
| 85 | + | let mut brief = Value::Null; |
|
| 86 | + | for _ in 0..2 { |
|
| 87 | + | let line = tokio::time::timeout(Duration::from_secs(20), lines.next_line()).await.unwrap().unwrap().unwrap(); |
|
| 88 | + | let v: Value = serde_json::from_str(&line).unwrap(); |
|
| 89 | + | if v["id"] == 2 { |
|
| 90 | + | brief = v; |
|
| 91 | + | } |
|
| 92 | + | } |
|
| 93 | + | assert!(brief["result"]["content"][0]["text"].as_str().unwrap().contains("You are claude-oxygen · Andy McCall's agent"), "{brief}"); |
|
| 94 | + | drop(shim); |
|
| 95 | + | ||
| 96 | + | // The ledger stays on the server: verify asks it; check and rebuild refuse here. |
|
| 97 | + | let v = json_of(c, &["admin", "verify", "--json"]); |
|
| 98 | + | assert!(v["findings"].as_array().unwrap().is_empty(), "{v}"); |
|
| 99 | + | let o = run(c, &["admin", "check"]); |
|
| 100 | + | assert!(!o.status.success() && text(&o).contains("runs on the server"), "{}", text(&o)); |
|
| 101 | + | assert!(!c.join("data").exists(), "the workstation has no data directory"); |
|
| 102 | + | srv.stop(); |
|
| 103 | + | ||
| 104 | + | let o = run(s, &["admin", "verify"]); |
|
| 105 | + | assert!(o.status.success(), "{}", text(&o)); |
|
| 106 | + | } |
modified CHANGELOG.md +1 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Changed: the command line works from another machine (#23).** With a server configured by `spectra connect`, every command goes through the JSON API and the workstation needs no ledger file: `identity show`, `agent add`, `list` and `disable`, `project add` and `list`, `say`, `inbox`, `search`, `api` and `admin verify`, which the server runs on its own ledger (`GET /api/v1/admin/verify`). `admin check` and `rebuild` say they run on the server. Locally, commands read through the daemon whenever it is serving, so the same path is exercised on one machine and on two. The API's agents now carry their delegation state and expiry, projects their agents, and inbox items and search hits their author's display name. |
|
| 5 | 6 | - **Added: `spectra connect https://HOST[:PORT]` (#21).** Uses a Spectra on another machine. It trusts the server only when your own identity delegates to its instance key and that key vouches for the certificate it presents; until you have delegated, it saves the instance's public identity and prints the `oxy identity delegate add` to run. Then it pins the certificate in `~/.config/spectra/client.toml` (or `SPECTRA_SERVER`), claims a server set up with `spectra init --owner`, and brings the server up to your current identity. A certificate that changes is refused until `connect` checks it again. The stdio shim uses the configured server (with `--home`). |
|
| 6 | 7 | - **Added: setting a server up without the owner's key (#22).** `spectra init --owner KEYID` makes the instance key and the ledger with only the owner's KeyId named. Until that owner claims it, the daemon answers who it is and refuses everything else, saying it has no owner yet; the claim is the owner's identity signed by itself, and is accepted from no other key. A signed request the API refuses is now told why. |
|
| 7 | 8 | - **Added: serving other machines over TLS (#20).** With `[server] bind` beyond loopback (or `spectra serve --bind ADDR`), the daemon serves HTTPS with a certificate of its own, made on first start and kept in `keys/tls.{crt,key}`, or with `[server] tls = { cert, key }`. No certificate authority is involved: the instance key vouches for the certificate with a signed `instance.tls` event, and `GET /api/v1/instance` tells a client, before it trusts anything, who the instance is, whose it is and which certificate it signed. Clients pin that one certificate and check the server's handshake signature. An agent bound to a Unix user is refused over TCP and recorded as `guard.impersonation`, since a network connection has no uid to check. |
|
| … | 15 unchanged lines |
modified crates/spectra-api/src/lib.rs +14 −0
| … | 38 unchanged lines |
||
| 39 | 39 | pub presence: String, |
|
| 40 | 40 | pub status: Option<String>, |
|
| 41 | 41 | pub disabled: bool, |
|
| 42 | + | /// `ok` while the owner's identity delegates to the key, else why not. |
|
| 43 | + | #[serde(default)] |
|
| 44 | + | pub state: String, |
|
| 45 | + | /// When the owner's delegation to the key ends, in ms. |
|
| 46 | + | #[serde(default)] |
|
| 47 | + | pub delegation_expires: Option<u64>, |
|
| 42 | 48 | } |
|
| 43 | 49 | ||
| 44 | 50 | /// A project (spec §23.1). |
|
| … | 4 unchanged lines |
||
| 49 | 55 | pub remote: Option<String>, |
|
| 50 | 56 | pub depends_on: Vec<String>, |
|
| 51 | 57 | pub depended_on_by: Vec<String>, |
|
| 58 | + | /// Its agents that are not disabled. |
|
| 59 | + | #[serde(default)] |
|
| 60 | + | pub agents: Vec<String>, |
|
| 52 | 61 | } |
|
| 53 | 62 | ||
| 54 | 63 | /// A channel (spec §10.1). |
|
| … | 59 unchanged lines |
||
| 114 | 123 | pub kind: String, |
|
| 115 | 124 | pub at_ms: i64, |
|
| 116 | 125 | pub from: String, |
|
| 126 | + | /// `claude-oxygen · Andy McCall's agent`, or the human's name. |
|
| 127 | + | #[serde(default)] |
|
| 128 | + | pub from_display: String, |
|
| 117 | 129 | pub channel: String, |
|
| 118 | 130 | pub summary: String, |
|
| 119 | 131 | } |
|
| … | 15 unchanged lines |
||
| 135 | 147 | pub at_ms: i64, |
|
| 136 | 148 | pub snippet: String, |
|
| 137 | 149 | pub thread: Option<String>, |
|
| 150 | + | #[serde(default)] |
|
| 151 | + | pub author_display: String, |
|
| 138 | 152 | } |
|
| 139 | 153 | ||
| 140 | 154 | /// An event as the log and the stream show it: never its payload's data on |
|
| … | 55 unchanged lines |
modified crates/spectra-api/ts/index.d.ts +5 −0
| … | 14 unchanged lines |
||
| 15 | 15 | }; |
|
| 16 | 16 | ||
| 17 | 17 | export type Agent = { |
|
| 18 | + | delegation_expires?: number | null; |
|
| 18 | 19 | disabled: boolean; |
|
| 19 | 20 | display: string; |
|
| 20 | 21 | key: string; |
|
| … | 2 unchanged lines |
||
| 23 | 24 | owner: string; |
|
| 24 | 25 | presence: string; |
|
| 25 | 26 | project: string; |
|
| 27 | + | state?: string; |
|
| 26 | 28 | status?: string | null; |
|
| 27 | 29 | }; |
|
| 28 | 30 | ||
| 29 | 31 | export type Project = { |
|
| 32 | + | agents?: string[]; |
|
| 30 | 33 | depended_on_by: string[]; |
|
| 31 | 34 | depends_on: string[]; |
|
| 32 | 35 | name: string; |
|
| … | 47 unchanged lines |
||
| 80 | 83 | at_ms: number; |
|
| 81 | 84 | channel: string; |
|
| 82 | 85 | from: string; |
|
| 86 | + | from_display?: string; |
|
| 83 | 87 | id: string; |
|
| 84 | 88 | kind: string; |
|
| 85 | 89 | summary: string; |
|
| … | 7 unchanged lines |
||
| 93 | 97 | export type Hit = { |
|
| 94 | 98 | at_ms: number; |
|
| 95 | 99 | author: string; |
|
| 100 | + | author_display?: string; |
|
| 96 | 101 | channel: string; |
|
| 97 | 102 | id: string; |
|
| 98 | 103 | snippet: string; |
|
| … | 31 unchanged lines |
modified crates/spectra-mcp/src/api.rs +78 −21
| … | 35 unchanged lines |
||
| 36 | 36 | .route("/api/v1/inbox/ack", post(ack)) |
|
| 37 | 37 | .route("/api/v1/search", get(search)) |
|
| 38 | 38 | .route("/api/v1/events", get(events)) |
|
| 39 | + | .route("/api/v1/admin/verify", get(admin_verify)) |
|
| 39 | 40 | .with_state(state) |
|
| 40 | 41 | } |
|
| 41 | 42 | ||
| … | 84 unchanged lines |
||
| 126 | 127 | }; |
|
| 127 | 128 | } |
|
| 128 | 129 | ||
| 130 | + | /// `GET /api/v1/admin/verify`: the owner asks the daemon to verify its |
|
| 131 | + | /// ledger (spec §8.6): what `spectra admin verify` prints, from afar. |
|
| 132 | + | async fn admin_verify( |
|
| 133 | + | State(st): State<AppState>, |
|
| 134 | + | ConnectInfo(peer): ConnectInfo<PeerInfo>, |
|
| 135 | + | headers: HeaderMap, |
|
| 136 | + | uri: axum::http::Uri, |
|
| 137 | + | ) -> Response { |
|
| 138 | + | let c = who!(st, peer, headers, uri, "GET", b"", false); |
|
| 139 | + | if !matches!(c.principal, Principal::Owner { .. }) { |
|
| 140 | + | return problem(StatusCode::FORBIDDEN, "only the owner verifies the ledger", None); |
|
| 141 | + | } |
|
| 142 | + | let ledger = match spectra_core::ledger::Ledger::open_read_only(&st.daemon.data.join("spectra.db")).await { |
|
| 143 | + | Ok(l) => l, |
|
| 144 | + | Err(e) => return bad(e), |
|
| 145 | + | }; |
|
| 146 | + | match ledger.verify(&mut spectra_core::principals::Principals::default()).await { |
|
| 147 | + | Ok(v) => axum::Json(v).into_response(), |
|
| 148 | + | Err(e) => bad(e), |
|
| 149 | + | } |
|
| 150 | + | } |
|
| 151 | + | ||
| 129 | 152 | async fn health() -> Response { |
|
| 130 | 153 | axum::Json(serde_json::json!({ "ok": true })).into_response() |
|
| 131 | 154 | } |
|
| … | 16 unchanged lines |
||
| 148 | 171 | .await |
|
| 149 | 172 | .unwrap_or_default(); |
|
| 150 | 173 | let presence = st.daemon.presence.lock().await; |
|
| 174 | + | let principals = st.daemon.principals.read().await; |
|
| 151 | 175 | let now = now_ms(); |
|
| 152 | 176 | rows.into_iter() |
|
| 153 | - | .map(|(name, owner, key, kind, project, status, disabled)| api::Agent { |
|
| 154 | - | display: format!("{name} · {owner}'s agent"), |
|
| 155 | - | presence: if disabled.is_some() { "offline".into() } else { presence.state(&name, now).into() }, |
|
| 156 | - | disabled: disabled.is_some(), |
|
| 157 | - | name, |
|
| 158 | - | owner, |
|
| 159 | - | key, |
|
| 160 | - | kind, |
|
| 161 | - | project, |
|
| 162 | - | status, |
|
| 177 | + | .map(|(name, owner, key, kind, project, status, disabled)| { |
|
| 178 | + | let k = oxy_core::hash::ObjectRef::from_hex(&key); |
|
| 179 | + | let state = match k.map(|k| principals.principal(&k, now)) { |
|
| 180 | + | Some(Ok(_)) => "ok".to_string(), |
|
| 181 | + | Some(Err(why)) => why, |
|
| 182 | + | None => "a malformed key".into(), |
|
| 183 | + | }; |
|
| 184 | + | let delegation_expires = k.and_then(|k| principals.owner().and_then(|o| o.delegate(&k)).and_then(|d| d.expires)); |
|
| 185 | + | api::Agent { |
|
| 186 | + | display: format!("{name} · {owner}'s agent"), |
|
| 187 | + | presence: if disabled.is_some() { "offline".into() } else { presence.state(&name, now).into() }, |
|
| 188 | + | disabled: disabled.is_some(), |
|
| 189 | + | name, |
|
| 190 | + | owner, |
|
| 191 | + | key, |
|
| 192 | + | kind, |
|
| 193 | + | project, |
|
| 194 | + | status, |
|
| 195 | + | state, |
|
| 196 | + | delegation_expires, |
|
| 197 | + | } |
|
| 163 | 198 | }) |
|
| 164 | 199 | .collect() |
|
| 165 | 200 | } |
|
| … | 30 unchanged lines |
||
| 196 | 231 | .fetch_all(pool) |
|
| 197 | 232 | .await |
|
| 198 | 233 | .unwrap_or_default(); |
|
| 199 | - | out.push(api::Project { name, path, remote, depends_on, depended_on_by }); |
|
| 234 | + | let agents = sqlx::query_scalar("SELECT name FROM agents WHERE project = ? AND disabled_ms IS NULL ORDER BY name") |
|
| 235 | + | .bind(&name) |
|
| 236 | + | .fetch_all(pool) |
|
| 237 | + | .await |
|
| 238 | + | .unwrap_or_default(); |
|
| 239 | + | out.push(api::Project { name, path, remote, depends_on, depended_on_by, agents }); |
|
| 200 | 240 | } |
|
| 201 | 241 | json_etag(&headers, &out) |
|
| 202 | 242 | } |
|
| … | 121 unchanged lines |
||
| 324 | 364 | } |
|
| 325 | 365 | ||
| 326 | 366 | async fn inbox_items(st: &AppState, actor: &str) -> Vec<api::InboxItem> { |
|
| 327 | - | conv::inbox(&st.daemon.ledger, actor, None) |
|
| 328 | - | .await |
|
| 329 | - | .unwrap_or_default() |
|
| 330 | - | .into_iter() |
|
| 331 | - | .map(|i| api::InboxItem { id: i.id, kind: i.kind, at_ms: i.at_ms, from: i.from, channel: i.channel, summary: i.summary }) |
|
| 332 | - | .collect() |
|
| 367 | + | let mut out = Vec::new(); |
|
| 368 | + | for i in conv::inbox(&st.daemon.ledger, actor, None).await.unwrap_or_default() { |
|
| 369 | + | let from_display = conv::display_name(&st.daemon.ledger, &i.from).await; |
|
| 370 | + | out.push(api::InboxItem { |
|
| 371 | + | id: i.id, |
|
| 372 | + | kind: i.kind, |
|
| 373 | + | at_ms: i.at_ms, |
|
| 374 | + | from: i.from, |
|
| 375 | + | from_display, |
|
| 376 | + | channel: i.channel, |
|
| 377 | + | summary: i.summary, |
|
| 378 | + | }); |
|
| 379 | + | } |
|
| 380 | + | out |
|
| 333 | 381 | } |
|
| 334 | 382 | ||
| 335 | 383 | async fn inbox(State(st): State<AppState>, ConnectInfo(peer): ConnectInfo<PeerInfo>, headers: HeaderMap, uri: axum::http::Uri) -> Response { |
|
| … | 62 unchanged lines |
||
| 398 | 446 | match conv::search(&st.daemon.ledger, &c.principal, &q.q, q.channel.as_deref(), q.author.as_deref(), since, q.limit.unwrap_or(20)).await |
|
| 399 | 447 | { |
|
| 400 | 448 | Ok(hits) => { |
|
| 401 | - | let out: Vec<api::Hit> = hits |
|
| 402 | - | .into_iter() |
|
| 403 | - | .map(|h| api::Hit { id: h.id, channel: h.channel, author: h.author, at_ms: h.at_ms, snippet: h.snippet, thread: h.thread }) |
|
| 404 | - | .collect(); |
|
| 449 | + | let mut out: Vec<api::Hit> = Vec::new(); |
|
| 450 | + | for h in hits { |
|
| 451 | + | let author_display = conv::display_name(&st.daemon.ledger, &h.author).await; |
|
| 452 | + | out.push(api::Hit { |
|
| 453 | + | id: h.id, |
|
| 454 | + | channel: h.channel, |
|
| 455 | + | author: h.author, |
|
| 456 | + | at_ms: h.at_ms, |
|
| 457 | + | snippet: h.snippet, |
|
| 458 | + | thread: h.thread, |
|
| 459 | + | author_display, |
|
| 460 | + | }); |
|
| 461 | + | } |
|
| 405 | 462 | json_etag(&headers, &out) |
|
| 406 | 463 | } |
|
| 407 | 464 | Err(e) => bad(e), |
|
| … | 74 unchanged lines |
modified docs/plan.md +2 −2
| … | 31 unchanged lines |
||
| 32 | 32 | ||
| 33 | 33 | - [x] #20 Server over TLS — done 2026-11-03: own certificate in keys/tls.*, vouched for by a signed instance.tls event; GET /api/v1/instance; pinned client transport; a uid-bound agent refused over TCP; tests/tls.rs. |
|
| 34 | 34 | - [x] #22 Remote setup — done 2026-11-03: spectra init --owner KEYID; unowned daemon refuses all but /api/v1/instance and POST /api/v1/claim, which takes only the named key; tests/remote.rs. Found: a failing end-to-end test left its daemon running, which held the output open and looked like a hang; Serving now kills it on drop. |
|
| 35 | - | - [ ] #21 spectra connect and the TLS client: trust through the owner's delegation, pinned certificate, shims over the network. |
|
| 36 | - | - [ ] #23 The CLI without a ledger file: every command through the JSON API. |
|
| 35 | + | - [x] #21 spectra connect — done 2026-11-03: trust through the owner's delegation to the instance key, pin, claim with the rotation delegating causes; tests/connect.rs, and the shim over TLS in tests/client.rs. |
|
| 36 | + | - [x] #23 The CLI without a ledger file — done 2026-11-03: every command through the API when a daemon serves, local or remote; admin verify via the server; tests/client.rs runs them all from a workstation with no data directory. |
|
| 37 | 37 | - [ ] #24 Radar from a workstation: spectra open's loopback proxy to the pinned server. |
|
| 38 | 38 | - [ ] #25 Run the daemon on Earth: install over SSH, a unit that waits for a Spectra, README. |
|
| 39 | 39 | - [ ] #13 Acceptance (with Andy), across two machines. Ready when the rest is done: |
|
| … | 17 unchanged lines |
modified src/cli/admin.rs +44 −16
| 1 | 1 | //! `spectra admin`: the ledger's own checks (spec §9.5, §19). |
|
| 2 | 2 | ||
| 3 | 3 | use super::{ui, Ctx}; |
|
| 4 | - | use anyhow::Result; |
|
| 4 | + | use anyhow::{Context, Result}; |
|
| 5 | 5 | use spectra_core::ledger::Ledger; |
|
| 6 | 6 | use spectra_core::principals::Principals; |
|
| 7 | 7 | ||
| 8 | 8 | /// `spectra admin verify`: recompute the chain, check every checkpoint and |
|
| 9 | 9 | /// proof; exit 1 when anything is wrong. |
|
| 10 | 10 | pub async fn verify(ctx: &Ctx, json: bool) -> Result<i32> { |
|
| 11 | - | let ledger = Ledger::open_read_only(&ctx.db()?).await?; |
|
| 12 | 11 | let spin = if json { None } else { Some(ui::spinner("verifying the ledger")) }; |
|
| 13 | - | let v = ledger.verify(&mut Principals::default()).await?; |
|
| 12 | + | // On a client the server verifies its own ledger (spec §8.6). |
|
| 13 | + | let v: serde_json::Value = match ctx.remote()? { |
|
| 14 | + | Some(_) => { |
|
| 15 | + | let home = super::identity::owner_home(ctx, None); |
|
| 16 | + | let c = ctx.owner_client(&home).await?.context("no server")?; |
|
| 17 | + | super::call(&c, "GET", "/api/v1/admin/verify", None).await? |
|
| 18 | + | } |
|
| 19 | + | None => serde_json::to_value(Ledger::open_read_only(&ctx.db()?).await?.verify(&mut Principals::default()).await?)?, |
|
| 20 | + | }; |
|
| 14 | 21 | if let Some(s) = spin { |
|
| 15 | 22 | s.clear(); |
|
| 16 | 23 | } |
|
| 24 | + | let findings = v["findings"].as_array().cloned().unwrap_or_default(); |
|
| 25 | + | let clean = findings.is_empty(); |
|
| 17 | 26 | if json { |
|
| 18 | - | println!("{}", serde_json::to_string(&v)?); |
|
| 19 | - | return Ok(if v.clean() { 0 } else { 1 }); |
|
| 27 | + | println!("{v}"); |
|
| 28 | + | return Ok(if clean { 0 } else { 1 }); |
|
| 20 | 29 | } |
|
| 21 | - | if v.clean() { |
|
| 30 | + | if clean { |
|
| 22 | 31 | println!("{} {}", ui::done("Verified"), ui::subject("the ledger")); |
|
| 23 | 32 | } else { |
|
| 24 | 33 | println!( |
|
| 25 | 34 | "{} {} {}", |
|
| 26 | 35 | ui::err(ui::glyph("✘", "x")), |
|
| 27 | 36 | ui::err("The ledger does not verify:"), |
|
| 28 | - | ui::err(format!("{} finding(s)", v.findings.len())) |
|
| 37 | + | ui::err(format!("{} finding(s)", findings.len())) |
|
| 29 | 38 | ); |
|
| 30 | 39 | } |
|
| 31 | - | println!("{}", ui::kv("events", v.events)); |
|
| 32 | - | let last = v.last_checkpoint.map(|s| format!("last at #{s}")).unwrap_or_else(|| "none yet".into()); |
|
| 33 | - | println!("{}", ui::kv("signed", format!("{} checkpoint(s){}{last}", v.checkpoints, ui::sep()))); |
|
| 34 | - | if v.uncovered > 0 { |
|
| 35 | - | println!("{}", ui::kv("pending", ui::dim(format!("{} event(s) since, chained and awaiting the next checkpoint", v.uncovered)))); |
|
| 40 | + | println!("{}", ui::kv("events", v["events"].as_u64().unwrap_or(0))); |
|
| 41 | + | let last = v["last_checkpoint"].as_u64().map(|s| format!("last at #{s}")).unwrap_or_else(|| "none yet".into()); |
|
| 42 | + | println!("{}", ui::kv("signed", format!("{} checkpoint(s){}{last}", v["checkpoints"].as_u64().unwrap_or(0), ui::sep()))); |
|
| 43 | + | let uncovered = v["uncovered"].as_u64().unwrap_or(0); |
|
| 44 | + | if uncovered > 0 { |
|
| 45 | + | println!("{}", ui::kv("pending", ui::dim(format!("{uncovered} event(s) since, chained and awaiting the next checkpoint")))); |
|
| 36 | 46 | } |
|
| 37 | - | println!("{}", ui::kv("head", ui::hash(hex::encode(v.head)))); |
|
| 38 | - | for f in &v.findings { |
|
| 39 | - | println!(" {} {} {}", ui::id(format!("#{}", f.seq)), ui::err(format!("{:?}", f.problem)), f.detail); |
|
| 47 | + | println!("{}", ui::kv("head", ui::hash(v["head"].as_str().unwrap_or("")))); |
|
| 48 | + | for f in &findings { |
|
| 49 | + | // As the problem is named in code: hash_mismatch is HashMismatch. |
|
| 50 | + | let problem = match &f["problem"] { |
|
| 51 | + | serde_json::Value::String(p) => p |
|
| 52 | + | .split('_') |
|
| 53 | + | .map(|w| { |
|
| 54 | + | let mut c = w.chars(); |
|
| 55 | + | c.next().map(|f| f.to_uppercase().chain(c).collect::<String>()).unwrap_or_default() |
|
| 56 | + | }) |
|
| 57 | + | .collect(), |
|
| 58 | + | other => other.to_string(), |
|
| 59 | + | }; |
|
| 60 | + | println!( |
|
| 61 | + | " {} {} {}", |
|
| 62 | + | ui::id(format!("#{}", f["seq"].as_u64().unwrap_or(0))), |
|
| 63 | + | ui::err(problem), |
|
| 64 | + | f["detail"].as_str().unwrap_or("") |
|
| 65 | + | ); |
|
| 40 | 66 | } |
|
| 41 | - | Ok(if v.clean() { 0 } else { 1 }) |
|
| 67 | + | Ok(if clean { 0 } else { 1 }) |
|
| 42 | 68 | } |
|
| 43 | 69 | ||
| 44 | 70 | /// `spectra admin check`: do the projections equal a rebuild? |
|
| 45 | 71 | pub async fn check(ctx: &Ctx) -> Result<i32> { |
|
| 72 | + | ctx.local_only("spectra admin check")?; |
|
| 46 | 73 | let ledger = ctx.writer().await?; |
|
| 47 | 74 | let differ = ledger.check().await?; |
|
| 48 | 75 | if differ.is_empty() { |
|
| … | 8 unchanged lines |
||
| 57 | 84 | ||
| 58 | 85 | /// `spectra admin rebuild`: replay the ledger into every projection. |
|
| 59 | 86 | pub async fn rebuild(ctx: &Ctx) -> Result<i32> { |
|
| 87 | + | ctx.local_only("spectra admin rebuild")?; |
|
| 60 | 88 | let ledger = ctx.writer().await?; |
|
| 61 | 89 | let spin = ui::spinner("replaying the ledger"); |
|
| 62 | 90 | ledger.rebuild().await?; |
|
| … | 3 unchanged lines |
modified src/cli/agent.rs +43 −16
| … | 3 unchanged lines |
||
| 4 | 4 | //! command that makes it the owner's, and records the agent only once the |
|
| 5 | 5 | //! owner's identity delegates to it. There is no agent without an owner. |
|
| 6 | 6 | ||
| 7 | - | use super::identity::{owner_home, refresh_event}; |
|
| 8 | - | use super::{marks, ui, Ctx}; |
|
| 7 | + | use super::identity::owner_home; |
|
| 8 | + | use super::{call, marks, ui, Ctx}; |
|
| 9 | 9 | use anyhow::{bail, Context, Result}; |
|
| 10 | 10 | use spectra_core::ids::now_ms; |
|
| 11 | 11 | use spectra_core::keys::{home_identity, KeyPair}; |
|
| … | 22 unchanged lines |
||
| 34 | 34 | if !KINDS.contains(&a.kind.as_str()) { |
|
| 35 | 35 | bail!("--kind is one of {}", KINDS.join(", ")); |
|
| 36 | 36 | } |
|
| 37 | - | { |
|
| 38 | - | let ledger = Ledger::open_read_only(&ctx.db()?).await?; |
|
| 39 | - | let p = Principals::load(&ledger).await?; |
|
| 40 | - | if p.agent_key(&a.name).is_some() { |
|
| 41 | - | bail!("an agent named {} already exists (spectra agent list)", a.name); |
|
| 37 | + | let home = owner_home(ctx, a.owner_home.clone()); |
|
| 38 | + | let (exists, known) = match ctx.owner_client(&home).await? { |
|
| 39 | + | Some(c) => { |
|
| 40 | + | let agents = call(&c, "GET", "/api/v1/agents", None).await?; |
|
| 41 | + | let projects = call(&c, "GET", "/api/v1/projects", None).await?; |
|
| 42 | + | let has = |v: &serde_json::Value, n: &str| v.as_array().is_some_and(|l| l.iter().any(|x| x["name"] == n)); |
|
| 43 | + | (has(&agents, &a.name), has(&projects, &a.project)) |
|
| 42 | 44 | } |
|
| 43 | - | let known: Option<String> = |
|
| 44 | - | sqlx::query_scalar("SELECT name FROM projects WHERE name = ?").bind(&a.project).fetch_optional(ledger.pool()).await?; |
|
| 45 | - | if known.is_none() { |
|
| 46 | - | bail!("no project named {} (spectra project add PATH --name {})", a.project, a.project); |
|
| 45 | + | None => { |
|
| 46 | + | let ledger = Ledger::open_read_only(&ctx.db()?).await?; |
|
| 47 | + | let p = Principals::load(&ledger).await?; |
|
| 48 | + | let known: Option<String> = |
|
| 49 | + | sqlx::query_scalar("SELECT name FROM projects WHERE name = ?").bind(&a.project).fetch_optional(ledger.pool()).await?; |
|
| 50 | + | (p.agent_key(&a.name).is_some(), known.is_some()) |
|
| 47 | 51 | } |
|
| 52 | + | }; |
|
| 53 | + | if exists { |
|
| 54 | + | bail!("an agent named {} already exists (spectra agent list)", a.name); |
|
| 55 | + | } |
|
| 56 | + | if !known { |
|
| 57 | + | bail!("no project named {} (spectra project add PATH --name {})", a.project, a.project); |
|
| 48 | 58 | } |
|
| 49 | - | let home = owner_home(ctx, a.owner_home.clone()); |
|
| 50 | 59 | // The agent's key, made once in its own home and reused if `add` is run again. |
|
| 51 | 60 | let agent = if a.home.join("keys").exists() { |
|
| 52 | 61 | KeyPair::load_home(&a.home).with_context(|| format!("reading the agent's key in {}", a.home.display()))? |
|
| … | 30 unchanged lines |
||
| 83 | 92 | } |
|
| 84 | 93 | } |
|
| 85 | 94 | let w = ctx.owner_writer(&home).await?; |
|
| 86 | - | let mut events: Vec<_> = refresh_event(w.ledger(), &home).await?.into_iter().collect(); |
|
| 95 | + | let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect(); |
|
| 87 | 96 | let owner = KeyPair::load_home(&home)?; |
|
| 88 | 97 | let mut p = identity_payload(&agent.identity); |
|
| 89 | 98 | p["name"] = a.name.clone().into(); |
|
| … | 32 unchanged lines |
||
| 122 | 131 | } |
|
| 123 | 132 | ||
| 124 | 133 | pub async fn list(ctx: &Ctx, json: bool) -> Result<i32> { |
|
| 134 | + | let out = match ctx.owner_client(&owner_home(ctx, None)).await? { |
|
| 135 | + | Some(c) => call(&c, "GET", "/api/v1/agents", None).await?.as_array().cloned().unwrap_or_default(), |
|
| 136 | + | None => list_local(ctx).await?, |
|
| 137 | + | }; |
|
| 138 | + | print_list(out, json) |
|
| 139 | + | } |
|
| 140 | + | ||
| 141 | + | async fn list_local(ctx: &Ctx) -> Result<Vec<serde_json::Value>> { |
|
| 125 | 142 | let ledger = Ledger::open_read_only(&ctx.db()?).await?; |
|
| 126 | 143 | let p = Principals::load(&ledger).await?; |
|
| 127 | 144 | let rows: Vec<AgentRow> = sqlx::query_as( |
|
| … | 18 unchanged lines |
||
| 146 | 163 | let presence = if disabled.is_some() { "offline".to_string() } else { presence.unwrap_or_else(|| "offline".into()) }; |
|
| 147 | 164 | out.push(serde_json::json!({ "name": name, "display": who, "key": key, "kind": kind, "project": project, "state": state, "presence": presence, "status": status, "delegation_expires": expires, "disabled": disabled.is_some() })); |
|
| 148 | 165 | } |
|
| 166 | + | Ok(out) |
|
| 167 | + | } |
|
| 168 | + | ||
| 169 | + | fn print_list(out: Vec<serde_json::Value>, json: bool) -> Result<i32> { |
|
| 149 | 170 | if json { |
|
| 150 | 171 | println!("{}", serde_json::Value::Array(out)); |
|
| 151 | 172 | return Ok(0); |
|
| … | 38 unchanged lines |
||
| 190 | 211 | pub async fn disable(ctx: &Ctx, name: &str, owner_home_flag: Option<PathBuf>) -> Result<i32> { |
|
| 191 | 212 | let home = owner_home(ctx, owner_home_flag); |
|
| 192 | 213 | let w = ctx.owner_writer(&home).await?; |
|
| 193 | - | let p = Principals::load(w.ledger()).await?; |
|
| 194 | - | p.agent_key(name).with_context(|| format!("no agent named {name}"))?; |
|
| 195 | - | let mut events: Vec<_> = refresh_event(w.ledger(), &home).await?.into_iter().collect(); |
|
| 214 | + | let known = match (w.ledger(), w.client()) { |
|
| 215 | + | (_, Some(c)) => call(c, "GET", "/api/v1/agents", None).await?.as_array().is_some_and(|l| l.iter().any(|x| x["name"] == name)), |
|
| 216 | + | (Some(l), None) => Principals::load(l).await?.agent_key(name).is_some(), |
|
| 217 | + | (None, None) => false, |
|
| 218 | + | }; |
|
| 219 | + | if !known { |
|
| 220 | + | bail!("no agent named {name}"); |
|
| 221 | + | } |
|
| 222 | + | let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect(); |
|
| 196 | 223 | let owner = KeyPair::load_home(&home)?; |
|
| 197 | 224 | events.push(signed_event(&owner, "human", "agent.disabled", serde_json::json!({ "name": name }))); |
|
| 198 | 225 | w.append(events).await?; |
|
| … | 11 unchanged lines |
modified src/cli/human.rs +55 −50
| … | 2 unchanged lines |
||
| 3 | 3 | //! signs with the owner's own key: through the daemon when it is serving, |
|
| 4 | 4 | //! else straight into the ledger. |
|
| 5 | 5 | ||
| 6 | - | use super::identity::{owner_home, recorded_owner}; |
|
| 6 | + | use super::identity::owner_home; |
|
| 7 | 7 | use super::{ui, Ctx}; |
|
| 8 | 8 | use anyhow::{bail, Context, Result}; |
|
| 9 | 9 | use serde_json::{json, Value}; |
|
| … | 4 unchanged lines |
||
| 14 | 14 | use spectra_mcp::client::Client; |
|
| 15 | 15 | use std::path::PathBuf; |
|
| 16 | 16 | ||
| 17 | - | /// A client signing as the owner, when the daemon is serving. |
|
| 18 | 17 | async fn owner_client(ctx: &Ctx, home: &std::path::Path) -> Result<Option<Client>> { |
|
| 19 | - | if !Client::alive(&ctx.socket()).await { |
|
| 20 | - | return Ok(None); |
|
| 21 | - | } |
|
| 22 | - | let ledger = Ledger::open_read_only(&ctx.db()?).await?; |
|
| 23 | - | Ok(Some(Client::new(&ctx.socket(), Some(recorded_owner(&ledger, home).await?)))) |
|
| 18 | + | ctx.owner_client(home).await |
|
| 24 | 19 | } |
|
| 25 | 20 | ||
| 26 | 21 | async fn api(c: &Client, method: &str, path: &str, body: Option<Value>) -> Result<Value> { |
|
| 27 | - | let r = c.send(method, path, &[], body.map(|b| serde_json::to_vec(&b)).transpose()?.unwrap_or_default()).await?; |
|
| 28 | - | let v: Value = r.json().unwrap_or(Value::Null); |
|
| 29 | - | if r.status >= 400 { |
|
| 30 | - | let why = v |
|
| 31 | - | .get("detail") |
|
| 32 | - | .or(v.get("title")) |
|
| 33 | - | .and_then(Value::as_str) |
|
| 34 | - | .map(String::from) |
|
| 35 | - | .unwrap_or_else(|| String::from_utf8_lossy(&r.body).into()); |
|
| 36 | - | bail!("{why}"); |
|
| 37 | - | } |
|
| 38 | - | Ok(v) |
|
| 22 | + | super::call(c, method, path, body).await |
|
| 39 | 23 | } |
|
| 40 | 24 | ||
| 41 | - | fn encode(s: &str) -> String { |
|
| 42 | - | s.bytes() |
|
| 43 | - | .map(|b| match b { |
|
| 44 | - | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(), |
|
| 45 | - | _ => format!("%{b:02X}"), |
|
| 46 | - | }) |
|
| 47 | - | .collect() |
|
| 48 | - | } |
|
| 25 | + | use super::encode; |
|
| 49 | 26 | ||
| 50 | 27 | pub async fn say( |
|
| 51 | 28 | ctx: &Ctx, |
|
| … | 68 unchanged lines |
||
| 120 | 97 | println!(" {}", ui::dim(format!("reply: spectra say --reply-to {} \"…\"", i["id"].as_str().unwrap_or("")))); |
|
| 121 | 98 | } |
|
| 122 | 99 | ||
| 123 | - | pub async fn inbox(ctx: &Ctx, follow: bool, owner_home_flag: Option<PathBuf>, as_json: bool) -> Result<i32> { |
|
| 124 | - | let home = owner_home(ctx, owner_home_flag); |
|
| 100 | + | /// The owner's unread items: from the daemon when one serves, else the ledger. |
|
| 101 | + | async fn inbox_items(ctx: &Ctx, client: Option<&Client>) -> Result<Vec<Value>> { |
|
| 102 | + | if let Some(c) = client { |
|
| 103 | + | return Ok(api(c, "GET", "/api/v1/inbox", None).await?.as_array().cloned().unwrap_or_default()); |
|
| 104 | + | } |
|
| 125 | 105 | let ledger = Ledger::open_read_only(&ctx.db()?).await?; |
|
| 126 | - | let mut items: Vec<Value> = Vec::new(); |
|
| 106 | + | let mut items = Vec::new(); |
|
| 127 | 107 | for i in conv::inbox(&ledger, "human", None).await? { |
|
| 128 | 108 | let from_display = conv::display_name(&ledger, &i.from).await; |
|
| 129 | 109 | let mut v = serde_json::to_value(&i)?; |
|
| 130 | 110 | v["from_display"] = from_display.into(); |
|
| 131 | 111 | items.push(v); |
|
| 132 | 112 | } |
|
| 113 | + | Ok(items) |
|
| 114 | + | } |
|
| 115 | + | ||
| 116 | + | pub async fn inbox(ctx: &Ctx, follow: bool, owner_home_flag: Option<PathBuf>, as_json: bool) -> Result<i32> { |
|
| 117 | + | let home = owner_home(ctx, owner_home_flag); |
|
| 118 | + | let client = owner_client(ctx, &home).await?; |
|
| 119 | + | let items = inbox_items(ctx, client.as_ref()).await?; |
|
| 133 | 120 | if as_json && !follow { |
|
| 134 | 121 | println!("{}", Value::Array(items)); |
|
| 135 | 122 | return Ok(0); |
|
| … | 13 unchanged lines |
||
| 149 | 136 | if !follow { |
|
| 150 | 137 | return Ok(0); |
|
| 151 | 138 | } |
|
| 152 | - | let client = owner_client(ctx, &home).await?.context("--follow needs `spectra serve` running")?; |
|
| 139 | + | let client = client.context("--follow needs `spectra serve` running")?; |
|
| 153 | 140 | if !as_json { |
|
| 154 | 141 | println!("{} {}", ui::note(), ui::dim("following; Ctrl-C stops")); |
|
| 155 | 142 | } |
|
| 156 | 143 | // Every new message is a cue to look at the inbox again. |
|
| 157 | 144 | let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<()>(); |
|
| 145 | + | let stream_client = owner_client(ctx, &home).await?.context("--follow needs `spectra serve` running")?; |
|
| 158 | 146 | let watcher = tokio::spawn(async move { |
|
| 159 | - | client |
|
| 147 | + | stream_client |
|
| 160 | 148 | .stream("/api/v1/events?follow=1&kind=message.posted", |l| { |
|
| 161 | 149 | if l.starts_with("data:") { |
|
| 162 | 150 | let _ = tx.send(()); |
|
| … | 3 unchanged lines |
||
| 166 | 154 | .await |
|
| 167 | 155 | }); |
|
| 168 | 156 | while rx.recv().await.is_some() { |
|
| 169 | - | for i in conv::inbox(&ledger, "human", None).await? { |
|
| 170 | - | if seen.insert(i.id.clone()) { |
|
| 171 | - | let mut v = serde_json::to_value(&i)?; |
|
| 172 | - | v["from_display"] = conv::display_name(&ledger, &i.from).await.into(); |
|
| 157 | + | for v in inbox_items(ctx, Some(&client)).await? { |
|
| 158 | + | if seen.insert(v["id"].as_str().unwrap_or("").to_string()) { |
|
| 173 | 159 | if as_json { |
|
| 174 | 160 | println!("{v}"); |
|
| 175 | 161 | } else { |
|
| … | 15 unchanged lines |
||
| 191 | 177 | limit: u32, |
|
| 192 | 178 | as_json: bool, |
|
| 193 | 179 | ) -> Result<i32> { |
|
| 194 | - | let ledger = Ledger::open_read_only(&ctx.db()?).await?; |
|
| 195 | - | let owner = Principals::load(&ledger).await?; |
|
| 196 | - | let me = owner.owner().context("no owner is recorded")?; |
|
| 197 | - | let principal = spectra_core::principals::Principal::Owner { key: me.hash(), name: me.name.clone() }; |
|
| 198 | - | let since = since.as_deref().and_then(|s| spectra_mcp::tools::parse_since(s, spectra_core::ids::now_ms())); |
|
| 199 | - | let hits = conv::search(&ledger, &principal, query, channel.as_deref(), author.as_deref(), since, limit).await?; |
|
| 180 | + | let hits: Vec<Value> = match owner_client(ctx, &owner_home(ctx, None)).await? { |
|
| 181 | + | Some(c) => { |
|
| 182 | + | let mut q = format!("/api/v1/search?q={}&limit={limit}", encode(query)); |
|
| 183 | + | for (k, v) in [("channel", &channel), ("author", &author), ("since", &since)] { |
|
| 184 | + | if let Some(v) = v { |
|
| 185 | + | q.push_str(&format!("&{k}={}", encode(v))); |
|
| 186 | + | } |
|
| 187 | + | } |
|
| 188 | + | api(&c, "GET", &q, None).await?.as_array().cloned().unwrap_or_default() |
|
| 189 | + | } |
|
| 190 | + | None => { |
|
| 191 | + | let ledger = Ledger::open_read_only(&ctx.db()?).await?; |
|
| 192 | + | let owner = Principals::load(&ledger).await?; |
|
| 193 | + | let me = owner.owner().context("no owner is recorded")?; |
|
| 194 | + | let principal = spectra_core::principals::Principal::Owner { key: me.hash(), name: me.name.clone() }; |
|
| 195 | + | let since = since.as_deref().and_then(|s| spectra_mcp::tools::parse_since(s, spectra_core::ids::now_ms())); |
|
| 196 | + | let mut out = Vec::new(); |
|
| 197 | + | for h in conv::search(&ledger, &principal, query, channel.as_deref(), author.as_deref(), since, limit).await? { |
|
| 198 | + | let mut v = serde_json::to_value(&h)?; |
|
| 199 | + | v["author_display"] = conv::display_name(&ledger, &h.author).await.into(); |
|
| 200 | + | out.push(v); |
|
| 201 | + | } |
|
| 202 | + | out |
|
| 203 | + | } |
|
| 204 | + | }; |
|
| 200 | 205 | if as_json { |
|
| 201 | - | println!("{}", serde_json::to_string(&hits)?); |
|
| 206 | + | println!("{}", Value::Array(hits)); |
|
| 202 | 207 | return Ok(0); |
|
| 203 | 208 | } |
|
| 204 | 209 | if hits.is_empty() { |
|
| 205 | 210 | println!("{} {}", ui::note(), ui::dim("no matches")); |
|
| 206 | 211 | } |
|
| 207 | 212 | for h in hits { |
|
| 208 | - | let who = conv::display_name(&ledger, &h.author).await; |
|
| 213 | + | let id = h["id"].as_str().unwrap_or(""); |
|
| 209 | 214 | println!( |
|
| 210 | 215 | "{} {}{}{}{}{}", |
|
| 211 | - | ui::id(&h.id[..12.min(h.id.len())]), |
|
| 212 | - | ui::author(who), |
|
| 216 | + | ui::id(&id[..12.min(id.len())]), |
|
| 217 | + | ui::author(h["author_display"].as_str().or(h["author"].as_str()).unwrap_or("")), |
|
| 213 | 218 | ui::sep(), |
|
| 214 | - | h.channel, |
|
| 219 | + | h["channel"].as_str().unwrap_or(""), |
|
| 215 | 220 | ui::sep(), |
|
| 216 | - | ui::dim(ui::ago(h.at_ms as u64)) |
|
| 221 | + | ui::dim(ui::ago(h["at_ms"].as_u64().unwrap_or(0))) |
|
| 217 | 222 | ); |
|
| 218 | - | println!(" {}", h.snippet); |
|
| 223 | + | println!(" {}", h["snippet"].as_str().unwrap_or("")); |
|
| 219 | 224 | } |
|
| 220 | 225 | Ok(0) |
|
| 221 | 226 | } |
|
| … | 2 unchanged lines |
||
| 224 | 229 | /// JSON out (the signing proxy for scripts and the editor extensions). |
|
| 225 | 230 | pub async fn raw(ctx: &Ctx, method: &str, path: &str, data: Option<String>, owner_home_flag: Option<PathBuf>) -> Result<i32> { |
|
| 226 | 231 | let home = owner_home(ctx, owner_home_flag); |
|
| 227 | - | let c = owner_client(ctx, &home).await?.context("`spectra api` needs `spectra serve` running")?; |
|
| 232 | + | let c = owner_client(ctx, &home).await?.context("`spectra api` needs `spectra serve` running, or `spectra connect`")?; |
|
| 228 | 233 | let r = c.send(&method.to_uppercase(), path, &[], data.map(String::into_bytes).unwrap_or_default()).await?; |
|
| 229 | 234 | println!("{}", String::from_utf8_lossy(&r.body)); |
|
| 230 | 235 | Ok(if r.status < 400 { 0 } else { 2 }) |
|
| … | 1 unchanged lines |
modified src/cli/identity.rs +44 −1
| … | 66 unchanged lines |
||
| 67 | 67 | } |
|
| 68 | 68 | ||
| 69 | 69 | pub async fn show(ctx: &Ctx, json: bool) -> Result<i32> { |
|
| 70 | + | if let Some(cfg) = ctx.remote()? { |
|
| 71 | + | return show_remote(&cfg, json).await; |
|
| 72 | + | } |
|
| 70 | 73 | let ledger = Ledger::open_read_only(&ctx.db()?).await?; |
|
| 71 | 74 | let p = Principals::load(&ledger).await?; |
|
| 72 | 75 | let owner = p.owner().context("no owner is recorded")?; |
|
| … | 37 unchanged lines |
||
| 110 | 113 | pub async fn refresh(ctx: &Ctx, owner_home_flag: Option<PathBuf>) -> Result<i32> { |
|
| 111 | 114 | let home = owner_home(ctx, owner_home_flag); |
|
| 112 | 115 | let w = ctx.owner_writer(&home).await?; |
|
| 113 | - | let rotated = refresh_event(w.ledger(), &home).await?; |
|
| 116 | + | let rotated = w.refresh_event(&home).await?; |
|
| 114 | 117 | let outcome = rotated.as_ref().map(|_| KeyPair::load_home(&home).map(|p| p.identity)).transpose()?; |
|
| 115 | 118 | if let Some(e) = rotated { |
|
| 116 | 119 | w.append(vec![e]).await?; |
|
| … | 61 unchanged lines |
||
| 178 | 181 | let keys = home.join("keys"); |
|
| 179 | 182 | KeyPair::load(&keys.join(format!("{}.secret", recorded.to_hex())), &keys.join(format!("{}.identity", recorded.to_hex()))) |
|
| 180 | 183 | .with_context(|| format!("the recorded owner's key is not in {}", home.display())) |
|
| 184 | + | } |
|
| 185 | + | ||
| 186 | + | /// `identity show` for a Spectra on another machine: what it says of itself. |
|
| 187 | + | async fn show_remote(cfg: &super::connect::ClientCfg, json: bool) -> Result<i32> { |
|
| 188 | + | let info = super::call(&spectra_mcp::client::Client::at(cfg.endpoint()?, None), "GET", "/api/v1/instance", None).await?; |
|
| 189 | + | let owner_key = info["owner"].as_str().unwrap_or("").to_string(); |
|
| 190 | + | let instance_key = info["instance"]["key"].as_str().unwrap_or("").to_string(); |
|
| 191 | + | let home = oxy_core::repo::default_home(); |
|
| 192 | + | let me = KeyPair::load_home(&home).ok(); |
|
| 193 | + | let delegated = me.as_ref().is_some_and(|m| { |
|
| 194 | + | oxy_core::hash::ObjectRef::from_hex(&instance_key) |
|
| 195 | + | .is_some_and(|k| m.identity.delegates_to(&k, spectra_core::principals::DELEGATION_SCOPE, spectra_core::ids::now_ms())) |
|
| 196 | + | }); |
|
| 197 | + | if json { |
|
| 198 | + | println!( |
|
| 199 | + | "{}", |
|
| 200 | + | serde_json::json!({ |
|
| 201 | + | "server": cfg.server, |
|
| 202 | + | "owner": { "name": me.as_ref().map(|m| m.identity.name.clone()), "key": owner_key }, |
|
| 203 | + | "instance": { "name": info["name"], "key": instance_key, "delegated": delegated }, |
|
| 204 | + | }) |
|
| 205 | + | ); |
|
| 206 | + | return Ok(0); |
|
| 207 | + | } |
|
| 208 | + | println!("{}", ui::section("Server")); |
|
| 209 | + | println!("{}", ui::kv("url", ui::path(&cfg.server))); |
|
| 210 | + | println!("{}", ui::section("Owner")); |
|
| 211 | + | if let Some(m) = &me { |
|
| 212 | + | println!("{}", ui::kv("name", ui::author(format!("{} <{}>", m.identity.name, m.identity.email)))); |
|
| 213 | + | } |
|
| 214 | + | println!("{}", ui::kv("key", ui::hash(&owner_key))); |
|
| 215 | + | println!("{}", ui::section("Instance")); |
|
| 216 | + | println!("{}", ui::kv("name", info["name"].as_str().unwrap_or(""))); |
|
| 217 | + | println!("{}", ui::kv("key", ui::hash(&instance_key))); |
|
| 218 | + | if delegated { |
|
| 219 | + | println!("{}", ui::kv("status", ui::ok("delegated by you: its checkpoints and certificate are verifiably yours"))); |
|
| 220 | + | } else { |
|
| 221 | + | println!("{}", ui::kv("status", ui::warn("not delegated by your current identity"))); |
|
| 222 | + | } |
|
| 223 | + | Ok(0) |
|
| 181 | 224 | } |
|
| 182 | 225 | ||
| 183 | 226 | /// `spectra init --owner KEYID` (spec §8.6): a server's instance key and |
|
| … | 52 unchanged lines |
modified src/cli/mod.rs +104 −8
| … | 15 unchanged lines |
||
| 16 | 16 | pub mod serve; |
|
| 17 | 17 | pub mod ui; |
|
| 18 | 18 | ||
| 19 | - | use anyhow::{bail, Result}; |
|
| 19 | + | use anyhow::{bail, Context, Result}; |
|
| 20 | 20 | use clap::{Parser, Subcommand}; |
|
| 21 | 21 | use std::path::PathBuf; |
|
| 22 | 22 | ||
| … | 279 unchanged lines |
||
| 302 | 302 | }) |
|
| 303 | 303 | } |
|
| 304 | 304 | ||
| 305 | - | /// Somewhere to append the owner's signed events: straight to the |
|
| 306 | - | /// ledger, or through the daemon when it is serving (spec §8.5). |
|
| 305 | + | /// A client signing as the owner: to the configured server (spec §8.6), |
|
| 306 | + | /// or to the local daemon when it is serving; none when neither. It |
|
| 307 | + | /// signs with the owner identity the ledger records, since the daemon |
|
| 308 | + | /// may not know a newer one yet. |
|
| 309 | + | pub async fn owner_client(&self, owner_home: &std::path::Path) -> Result<Option<spectra_mcp::client::Client>> { |
|
| 310 | + | use spectra_mcp::client::Client; |
|
| 311 | + | if let Some(cfg) = self.remote()? { |
|
| 312 | + | let ep = cfg.endpoint()?; |
|
| 313 | + | let pair = remote_owner(&ep, owner_home).await?; |
|
| 314 | + | return Ok(Some(Client::at(ep, Some(pair)))); |
|
| 315 | + | } |
|
| 316 | + | if !self.data.join("spectra.db").exists() || !Client::alive(&self.socket()).await { |
|
| 317 | + | return Ok(None); |
|
| 318 | + | } |
|
| 319 | + | let ledger = spectra_core::ledger::Ledger::open_read_only(&self.db()?).await?; |
|
| 320 | + | Ok(Some(Client::new(&self.socket(), Some(identity::recorded_owner(&ledger, owner_home).await?)))) |
|
| 321 | + | } |
|
| 322 | + | ||
| 323 | + | /// Somewhere to append the owner's signed events: the configured server, |
|
| 324 | + | /// the local daemon when it is serving, else straight to the ledger |
|
| 325 | + | /// (spec §8.5, §8.6). |
|
| 307 | 326 | pub async fn owner_writer(&self, owner_home: &std::path::Path) -> Result<Writer> { |
|
| 327 | + | if let Some(cfg) = self.remote()? { |
|
| 328 | + | let ep = cfg.endpoint()?; |
|
| 329 | + | let pair = remote_owner(&ep, owner_home).await?; |
|
| 330 | + | let recorded = pair.identity.clone(); |
|
| 331 | + | return Ok(Writer::Remote { client: Box::new(spectra_mcp::client::Client::at(ep, Some(pair))), recorded }); |
|
| 332 | + | } |
|
| 308 | 333 | let db = self.db()?; |
|
| 309 | 334 | if spectra_mcp::client::Client::alive(&self.socket()).await { |
|
| 310 | 335 | let ledger = spectra_core::ledger::Ledger::open_read_only(&db).await?; |
|
| … | 1 unchanged lines |
||
| 312 | 337 | return Ok(Writer::Daemon { ledger, client: Box::new(spectra_mcp::client::Client::new(&self.socket(), Some(transport))) }); |
|
| 313 | 338 | } |
|
| 314 | 339 | Ok(Writer::Direct(self.writer().await?)) |
|
| 340 | + | } |
|
| 341 | + | ||
| 342 | + | /// Refuse a command that needs the ledger file on this machine. |
|
| 343 | + | pub fn local_only(&self, what: &str) -> Result<()> { |
|
| 344 | + | if self.remote()?.is_some() { |
|
| 345 | + | bail!("{what} runs on the server, which holds the ledger (this machine uses {})", connect::path().display()); |
|
| 346 | + | } |
|
| 347 | + | Ok(()) |
|
| 315 | 348 | } |
|
| 316 | 349 | ||
| 317 | 350 | /// `[owner] home` from spectra.toml. |
|
| … | 7 unchanged lines |
||
| 325 | 358 | /// Where the owner's events go. |
|
| 326 | 359 | pub enum Writer { |
|
| 327 | 360 | Direct(spectra_core::ledger::Ledger), |
|
| 328 | - | Daemon { ledger: spectra_core::ledger::Ledger, client: Box<spectra_mcp::client::Client> }, |
|
| 361 | + | Daemon { |
|
| 362 | + | ledger: spectra_core::ledger::Ledger, |
|
| 363 | + | client: Box<spectra_mcp::client::Client>, |
|
| 364 | + | }, |
|
| 365 | + | /// A Spectra on another machine; `recorded` is the owner identity it knows. |
|
| 366 | + | Remote { |
|
| 367 | + | client: Box<spectra_mcp::client::Client>, |
|
| 368 | + | recorded: oxy_core::identity::Identity, |
|
| 369 | + | }, |
|
| 329 | 370 | } |
|
| 330 | 371 | ||
| 331 | 372 | impl Writer { |
|
| 332 | - | /// The ledger, for reading. |
|
| 333 | - | pub fn ledger(&self) -> &spectra_core::ledger::Ledger { |
|
| 373 | + | /// The ledger on this machine, for reading, if there is one. |
|
| 374 | + | pub fn ledger(&self) -> Option<&spectra_core::ledger::Ledger> { |
|
| 334 | 375 | match self { |
|
| 335 | - | Writer::Direct(l) | Writer::Daemon { ledger: l, .. } => l, |
|
| 376 | + | Writer::Direct(l) | Writer::Daemon { ledger: l, .. } => Some(l), |
|
| 377 | + | Writer::Remote { .. } => None, |
|
| 336 | 378 | } |
|
| 337 | 379 | } |
|
| 338 | 380 | ||
| 381 | + | /// A client of the daemon, when one is serving. |
|
| 382 | + | pub fn client(&self) -> Option<&spectra_mcp::client::Client> { |
|
| 383 | + | match self { |
|
| 384 | + | Writer::Daemon { client, .. } | Writer::Remote { client, .. } => Some(client), |
|
| 385 | + | Writer::Direct(_) => None, |
|
| 386 | + | } |
|
| 387 | + | } |
|
| 388 | + | ||
| 389 | + | /// The rotation that brings the recorded owner to the current identity |
|
| 390 | + | /// in `home`, if it has moved. |
|
| 391 | + | pub async fn refresh_event(&self, home: &std::path::Path) -> Result<Option<spectra_core::event::NewEvent>> { |
|
| 392 | + | match self { |
|
| 393 | + | Writer::Direct(l) | Writer::Daemon { ledger: l, .. } => identity::refresh_event(l, home).await, |
|
| 394 | + | Writer::Remote { recorded, .. } => identity::rotation(recorded, home), |
|
| 395 | + | } |
|
| 396 | + | } |
|
| 397 | + | ||
| 339 | 398 | /// Append events signed over their payload, then checkpoint. |
|
| 340 | 399 | pub async fn append(&self, events: Vec<spectra_core::event::NewEvent>) -> Result<()> { |
|
| 341 | 400 | match self { |
|
| … | 1 unchanged lines |
||
| 343 | 402 | l.append(events).await?; |
|
| 344 | 403 | l.checkpoint().await?; |
|
| 345 | 404 | } |
|
| 346 | - | Writer::Daemon { client, .. } => { |
|
| 405 | + | Writer::Daemon { client, .. } | Writer::Remote { client, .. } => { |
|
| 347 | 406 | let wire: Vec<_> = events.iter().map(spectra_core::daemon::WireEvent::from_new).collect(); |
|
| 348 | 407 | let r = client.send("POST", "/api/v1/ledger/events", &[], serde_json::to_vec(&wire)?).await?; |
|
| 349 | 408 | if r.status != 200 { |
|
| … | 8 unchanged lines |
||
| 358 | 417 | } |
|
| 359 | 418 | Ok(()) |
|
| 360 | 419 | } |
|
| 420 | + | } |
|
| 421 | + | ||
| 422 | + | /// The owner identity a server records, as a key pair from the owner's |
|
| 423 | + | /// home: what requests to it are signed with. |
|
| 424 | + | async fn remote_owner(ep: &spectra_mcp::client::Endpoint, home: &std::path::Path) -> Result<spectra_core::keys::KeyPair> { |
|
| 425 | + | let r = spectra_mcp::client::Client::at(ep.clone(), None).send("GET", "/api/v1/instance", &[], vec![]).await?; |
|
| 426 | + | let info = r.json()?; |
|
| 427 | + | let key = info.get("owner").and_then(|o| o.as_str()).context("the server has no owner yet: spectra connect claims it")?; |
|
| 428 | + | let keys = home.join("keys"); |
|
| 429 | + | spectra_core::keys::KeyPair::load(&keys.join(format!("{key}.secret")), &keys.join(format!("{key}.identity"))) |
|
| 430 | + | .with_context(|| format!("the server's owner {} is not an identity in {}", &key[..12.min(key.len())], home.display())) |
|
| 431 | + | } |
|
| 432 | + | ||
| 433 | + | /// An API call; a refusal becomes an error that says why. |
|
| 434 | + | pub async fn call(c: &spectra_mcp::client::Client, method: &str, path: &str, body: Option<serde_json::Value>) -> Result<serde_json::Value> { |
|
| 435 | + | let r = c.send(method, path, &[], body.map(|b| serde_json::to_vec(&b)).transpose()?.unwrap_or_default()).await?; |
|
| 436 | + | let v: serde_json::Value = r.json().unwrap_or(serde_json::Value::Null); |
|
| 437 | + | if r.status >= 400 { |
|
| 438 | + | let why = v |
|
| 439 | + | .get("detail") |
|
| 440 | + | .or(v.get("title")) |
|
| 441 | + | .and_then(serde_json::Value::as_str) |
|
| 442 | + | .map(String::from) |
|
| 443 | + | .unwrap_or_else(|| String::from_utf8_lossy(&r.body).into()); |
|
| 444 | + | bail!("{why}"); |
|
| 445 | + | } |
|
| 446 | + | Ok(v) |
|
| 447 | + | } |
|
| 448 | + | ||
| 449 | + | /// A query string value, percent-encoded. |
|
| 450 | + | pub fn encode(s: &str) -> String { |
|
| 451 | + | s.bytes() |
|
| 452 | + | .map(|b| match b { |
|
| 453 | + | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(), |
|
| 454 | + | _ => format!("%{b:02X}"), |
|
| 455 | + | }) |
|
| 456 | + | .collect() |
|
| 361 | 457 | } |
|
| 362 | 458 | ||
| 363 | 459 | fn init_logging() { |
|
| … | 89 unchanged lines |
modified src/cli/project.rs +32 −15
| 1 | 1 | //! `spectra project`: the checkouts agents work in (spec §23.1). |
|
| 2 | 2 | ||
| 3 | - | use super::identity::{owner_home, refresh_event}; |
|
| 4 | - | use super::{ui, Ctx}; |
|
| 3 | + | use super::identity::owner_home; |
|
| 4 | + | use super::{call, ui, Ctx}; |
|
| 5 | 5 | use anyhow::{bail, Context, Result}; |
|
| 6 | 6 | use spectra_core::keys::KeyPair; |
|
| 7 | 7 | use spectra_core::ledger::Ledger; |
|
| … | 13 unchanged lines |
||
| 21 | 21 | let checkout = projects::inspect(&path)?; |
|
| 22 | 22 | let home = owner_home(ctx, owner_home_flag); |
|
| 23 | 23 | let w = ctx.owner_writer(&home).await?; |
|
| 24 | - | let ledger = w.ledger(); |
|
| 25 | - | let exists: Option<String> = sqlx::query_scalar("SELECT name FROM projects WHERE name = ? OR path = ?") |
|
| 26 | - | .bind(&name) |
|
| 27 | - | .bind(path.display().to_string()) |
|
| 28 | - | .fetch_optional(ledger.pool()) |
|
| 29 | - | .await?; |
|
| 30 | - | if let Some(e) = exists { |
|
| 31 | - | bail!("{} is already a project ({e})", path.display()); |
|
| 24 | + | let path_s = path.display().to_string(); |
|
| 25 | + | let existing = all(&w).await?; |
|
| 26 | + | if let Some(e) = existing.iter().find(|p| p["name"] == name.as_str() || p["path"] == path_s.as_str()) { |
|
| 27 | + | bail!("{} is already a project ({})", path.display(), e["name"].as_str().unwrap_or("")); |
|
| 32 | 28 | } |
|
| 33 | - | let mut events: Vec<_> = refresh_event(ledger, &home).await?.into_iter().collect(); |
|
| 29 | + | let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect(); |
|
| 34 | 30 | let owner = KeyPair::load_home(&home)?; |
|
| 35 | 31 | let remote = remote.or(checkout.remote.clone()); |
|
| 36 | 32 | let payload = serde_json::json!({ |
|
| … | 9 unchanged lines |
||
| 46 | 42 | if let Some(id) = &checkout.repo_id { |
|
| 47 | 43 | println!("{}", ui::kv("repo", ui::hash(id))); |
|
| 48 | 44 | } |
|
| 49 | - | let (deps, users) = relations(ledger, &name).await?; |
|
| 45 | + | let now = all(&w).await?; |
|
| 46 | + | let me = now.iter().find(|p| p["name"] == name.as_str()).cloned().unwrap_or_default(); |
|
| 47 | + | let names = |
|
| 48 | + | |k: &str| me[k].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect::<Vec<_>>()).unwrap_or_default(); |
|
| 49 | + | let (deps, users) = (names("depends_on"), names("depended_on_by")); |
|
| 50 | 50 | if !deps.is_empty() { |
|
| 51 | 51 | println!("{}", ui::kv("pins", deps.join(", "))); |
|
| 52 | 52 | } |
|
| … | 18 unchanged lines |
||
| 71 | 71 | Ok((deps, users)) |
|
| 72 | 72 | } |
|
| 73 | 73 | ||
| 74 | - | pub async fn list(ctx: &Ctx, json: bool) -> Result<i32> { |
|
| 75 | - | let ledger = Ledger::open_read_only(&ctx.db()?).await?; |
|
| 74 | + | /// Every project with its relations and agents: from the daemon when one |
|
| 75 | + | /// serves, else from the ledger here. |
|
| 76 | + | async fn all(w: &super::Writer) -> Result<Vec<serde_json::Value>> { |
|
| 77 | + | match (w.client(), w.ledger()) { |
|
| 78 | + | (Some(c), _) => Ok(call(c, "GET", "/api/v1/projects", None).await?.as_array().cloned().unwrap_or_default()), |
|
| 79 | + | (None, Some(l)) => local(l).await, |
|
| 80 | + | (None, None) => Ok(vec![]), |
|
| 81 | + | } |
|
| 82 | + | } |
|
| 83 | + | ||
| 84 | + | async fn local(ledger: &Ledger) -> Result<Vec<serde_json::Value>> { |
|
| 76 | 85 | let rows: Vec<(String, String, Option<String>)> = |
|
| 77 | 86 | sqlx::query_as("SELECT name, path, remote FROM projects ORDER BY name").fetch_all(ledger.pool()).await?; |
|
| 78 | 87 | let mut out = Vec::new(); |
|
| 79 | 88 | for (name, path, remote) in rows { |
|
| 80 | - | let (deps, users) = relations(&ledger, &name).await?; |
|
| 89 | + | let (deps, users) = relations(ledger, &name).await?; |
|
| 81 | 90 | let agents: Vec<String> = sqlx::query_scalar("SELECT name FROM agents WHERE project = ? AND disabled_ms IS NULL ORDER BY name") |
|
| 82 | 91 | .bind(&name) |
|
| 83 | 92 | .fetch_all(ledger.pool()) |
|
| 84 | 93 | .await?; |
|
| 85 | 94 | out.push(serde_json::json!({ "name": name, "path": path, "remote": remote, "depends_on": deps, "depended_on_by": users, "agents": agents })); |
|
| 86 | 95 | } |
|
| 96 | + | Ok(out) |
|
| 97 | + | } |
|
| 98 | + | ||
| 99 | + | pub async fn list(ctx: &Ctx, json: bool) -> Result<i32> { |
|
| 100 | + | let out = match ctx.owner_client(&owner_home(ctx, None)).await? { |
|
| 101 | + | Some(c) => call(&c, "GET", "/api/v1/projects", None).await?.as_array().cloned().unwrap_or_default(), |
|
| 102 | + | None => local(&Ledger::open_read_only(&ctx.db()?).await?).await?, |
|
| 103 | + | }; |
|
| 87 | 104 | if json { |
|
| 88 | 105 | println!("{}", serde_json::Value::Array(out)); |
|
| 89 | 106 | return Ok(0); |
|
| … | 22 unchanged lines |