Implement spindle config, list, and logs commands

- Add spindle field to Repository struct
- Implement update_repo_spindle() to enable/disable CI for repos
- Implement list_pipelines() to fetch pipeline records from PDS
- Add Pipeline, TriggerMetadata, TriggerRepo, and Workflow structs
- Implement spindle config command:
- Enable/disable spindle for a repository
- Support custom spindle URL via --url flag
- Update repo record's spindle field
- Implement spindle list command:
- List all pipeline runs for a repository
- Display trigger kind, repo, and workflows
- Implement spindle logs command:
- Stream logs from a workflow execution via WebSocket
- Support both full job_id format (knot:rkey:name) and short format (name)
- Add --lines and --follow flags for log control
- Add WebSocket dependencies: tokio-tungstenite and futures-util
- Keep spindle run as stub (to be implemented later)

Changed files
+443 -12
crates
tangled-api
src
tangled-cli
src
commands
+153 -4
Cargo.lock
···
checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394"
[[package]]
+
name = "block-buffer"
+
version = "0.10.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+
dependencies = [
+
"generic-array",
+
]
+
+
[[package]]
name = "bumpalo"
version = "3.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
+
+
[[package]]
+
name = "byteorder"
+
version = "1.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
···
]
[[package]]
+
name = "cpufeatures"
+
version = "0.2.17"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+
dependencies = [
+
"libc",
+
]
+
+
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
]
[[package]]
+
name = "crypto-common"
+
version = "0.1.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
+
dependencies = [
+
"generic-array",
+
"typenum",
+
]
+
+
[[package]]
name = "data-encoding"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
"tempfile",
"thiserror 1.0.69",
"zeroize",
+
]
+
+
[[package]]
+
name = "digest"
+
version = "0.10.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+
dependencies = [
+
"block-buffer",
+
"crypto-common",
]
[[package]]
···
"pin-project-lite",
"pin-utils",
"slab",
+
]
+
+
[[package]]
+
name = "generic-array"
+
version = "0.14.9"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
+
dependencies = [
+
"typenum",
+
"version_check",
]
[[package]]
···
"bytes",
"getrandom 0.3.3",
"lru-slab",
-
"rand",
+
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
···
[[package]]
name = "rand"
+
version = "0.8.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+
dependencies = [
+
"libc",
+
"rand_chacha 0.3.1",
+
"rand_core 0.6.4",
+
]
+
+
[[package]]
+
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
-
"rand_chacha",
-
"rand_core",
+
"rand_chacha 0.9.0",
+
"rand_core 0.9.3",
+
]
+
+
[[package]]
+
name = "rand_chacha"
+
version = "0.3.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+
dependencies = [
+
"ppv-lite86",
+
"rand_core 0.6.4",
[[package]]
···
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
-
"rand_core",
+
"rand_core 0.9.3",
+
]
+
+
[[package]]
+
name = "rand_core"
+
version = "0.6.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+
dependencies = [
+
"getrandom 0.2.16",
[[package]]
···
[[package]]
+
name = "sha1"
+
version = "0.10.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
+
dependencies = [
+
"cfg-if",
+
"cpufeatures",
+
"digest",
+
]
+
+
[[package]]
name = "shell-words"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
"clap",
"colored",
"dialoguer",
+
"futures-util",
"git2",
"indicatif",
"serde",
···
"tangled-config",
"tangled-git",
"tokio",
+
"tokio-tungstenite",
"url",
···
[[package]]
+
name = "tokio-tungstenite"
+
version = "0.21.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38"
+
dependencies = [
+
"futures-util",
+
"log",
+
"native-tls",
+
"tokio",
+
"tokio-native-tls",
+
"tungstenite",
+
]
+
+
[[package]]
name = "tokio-util"
version = "0.7.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
+
name = "tungstenite"
+
version = "0.21.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1"
+
dependencies = [
+
"byteorder",
+
"bytes",
+
"data-encoding",
+
"http",
+
"httparse",
+
"log",
+
"native-tls",
+
"rand 0.8.5",
+
"sha1",
+
"thiserror 1.0.69",
+
"url",
+
"utf-8",
+
]
+
+
[[package]]
+
name = "typenum"
+
version = "1.19.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
+
+
[[package]]
name = "unicase"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
[[package]]
+
name = "utf-8"
+
version = "0.7.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+
+
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+
[[package]]
+
name = "version_check"
+
version = "0.9.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "want"
+4
Cargo.toml
···
base64 = "0.22"
regex = "1.10"
+
# WebSocket
+
tokio-tungstenite = { version = "0.21", features = ["native-tls"] }
+
futures-util = "0.3"
+
# Testing
mockito = "1.4"
tempfile = "3.10"
+124
crates/tangled-api/src/client.rs
···
.await?;
Ok(())
+
+
pub async fn update_repo_spindle(
+
&self,
+
did: &str,
+
rkey: &str,
+
new_spindle: Option<&str>,
+
pds_base: &str,
+
access_jwt: &str,
+
) -> Result<()> {
+
let pds_client = TangledClient::new(pds_base);
+
#[derive(Deserialize, Serialize, Clone)]
+
struct Rec {
+
name: String,
+
knot: String,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
description: Option<String>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
spindle: Option<String>,
+
#[serde(rename = "createdAt")]
+
created_at: String,
+
}
+
#[derive(Deserialize)]
+
struct GetRes {
+
value: Rec,
+
}
+
let params = [
+
("repo", did.to_string()),
+
("collection", "sh.tangled.repo".to_string()),
+
("rkey", rkey.to_string()),
+
];
+
let got: GetRes = pds_client
+
.get_json("com.atproto.repo.getRecord", &params, Some(access_jwt))
+
.await?;
+
let mut rec = got.value;
+
rec.spindle = new_spindle.map(|s| s.to_string());
+
#[derive(Serialize)]
+
struct PutReq<'a> {
+
repo: &'a str,
+
collection: &'a str,
+
rkey: &'a str,
+
validate: bool,
+
record: Rec,
+
}
+
let req = PutReq {
+
repo: did,
+
collection: "sh.tangled.repo",
+
rkey,
+
validate: true,
+
record: rec,
+
};
+
let _: serde_json::Value = pds_client
+
.post_json("com.atproto.repo.putRecord", &req, Some(access_jwt))
+
.await?;
+
Ok(())
+
}
+
+
pub async fn list_pipelines(
+
&self,
+
repo_did: &str,
+
bearer: Option<&str>,
+
) -> Result<Vec<PipelineRecord>> {
+
#[derive(Deserialize)]
+
struct Item {
+
uri: String,
+
value: Pipeline,
+
}
+
#[derive(Deserialize)]
+
struct ListRes {
+
#[serde(default)]
+
records: Vec<Item>,
+
}
+
let params = vec![
+
("repo", repo_did.to_string()),
+
("collection", "sh.tangled.pipeline".to_string()),
+
("limit", "100".to_string()),
+
];
+
let res: ListRes = self
+
.get_json("com.atproto.repo.listRecords", &params, bearer)
+
.await?;
+
let mut out = vec![];
+
for it in res.records {
+
let rkey = Self::uri_rkey(&it.uri).unwrap_or_default();
+
out.push(PipelineRecord {
+
rkey,
+
pipeline: it.value,
+
});
+
}
+
Ok(out)
+
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
···
pub name: String,
pub knot: Option<String>,
pub description: Option<String>,
+
pub spindle: Option<String>,
#[serde(default)]
pub private: bool,
···
pub pds_base: &'a str,
pub access_jwt: &'a str,
+
+
#[derive(Debug, Clone, Serialize, Deserialize)]
+
pub struct TriggerMetadata {
+
pub kind: String,
+
pub repo: TriggerRepo,
+
}
+
+
#[derive(Debug, Clone, Serialize, Deserialize)]
+
pub struct TriggerRepo {
+
pub knot: String,
+
pub did: String,
+
pub repo: String,
+
#[serde(rename = "defaultBranch")]
+
pub default_branch: String,
+
}
+
+
#[derive(Debug, Clone, Serialize, Deserialize)]
+
pub struct Workflow {
+
pub name: String,
+
pub engine: String,
+
}
+
+
#[derive(Debug, Clone, Serialize, Deserialize)]
+
pub struct Pipeline {
+
#[serde(rename = "triggerMetadata")]
+
pub trigger_metadata: TriggerMetadata,
+
pub workflows: Vec<Workflow>,
+
}
+
+
#[derive(Debug, Clone)]
+
pub struct PipelineRecord {
+
pub rkey: String,
+
pub pipeline: Pipeline,
+
}
+2
crates/tangled-cli/Cargo.toml
···
tokio = { workspace = true, features = ["full"] }
git2 = { workspace = true }
url = { workspace = true }
+
tokio-tungstenite = { workspace = true }
+
futures-util = { workspace = true }
# Internal crates
tangled-config = { path = "../tangled-config" }
+160 -8
crates/tangled-cli/src/commands/spindle.rs
···
SpindleSecretAddArgs, SpindleSecretCommand, SpindleSecretListArgs, SpindleSecretRemoveArgs,
};
use anyhow::{anyhow, Result};
+
use futures_util::StreamExt;
use tangled_config::session::SessionManager;
+
use tokio_tungstenite::{connect_async, tungstenite::Message};
pub async fn run(_cli: &Cli, cmd: SpindleCommand) -> Result<()> {
match cmd {
···
}
async fn list(args: SpindleListArgs) -> Result<()> {
-
println!("Spindle list (stub) repo={:?}", args.repo);
+
let mgr = SessionManager::default();
+
let session = mgr
+
.load()?
+
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
+
+
let pds = session
+
.pds
+
.clone()
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
+
.unwrap_or_else(|| "https://bsky.social".into());
+
let pds_client = tangled_api::TangledClient::new(&pds);
+
+
let (owner, name) = parse_repo_ref(
+
args.repo.as_deref().unwrap_or(&session.handle),
+
&session.handle
+
);
+
let info = pds_client
+
.get_repo_info(owner, name, Some(session.access_jwt.as_str()))
+
.await?;
+
+
let pipelines = pds_client
+
.list_pipelines(&info.did, Some(session.access_jwt.as_str()))
+
.await?;
+
+
if pipelines.is_empty() {
+
println!("No pipelines found for {}/{}", owner, name);
+
} else {
+
println!("RKEY\tKIND\tREPO\tWORKFLOWS");
+
for p in pipelines {
+
let workflows = p.pipeline.workflows
+
.iter()
+
.map(|w| w.name.as_str())
+
.collect::<Vec<_>>()
+
.join(",");
+
println!(
+
"{}\t{}\t{}\t{}",
+
p.rkey,
+
p.pipeline.trigger_metadata.kind,
+
p.pipeline.trigger_metadata.repo.repo,
+
workflows
+
);
+
}
+
}
Ok(())
}
async fn config(args: SpindleConfigArgs) -> Result<()> {
-
println!(
-
"Spindle config (stub) repo={:?} url={:?} enable={} disable={}",
-
args.repo, args.url, args.enable, args.disable
+
let mgr = SessionManager::default();
+
let session = mgr
+
.load()?
+
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
+
+
if args.enable && args.disable {
+
return Err(anyhow!("Cannot use --enable and --disable together"));
+
}
+
+
if !args.enable && !args.disable && args.url.is_none() {
+
return Err(anyhow!(
+
"Must provide --enable, --disable, or --url"
+
));
+
}
+
+
let pds = session
+
.pds
+
.clone()
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
+
.unwrap_or_else(|| "https://bsky.social".into());
+
let pds_client = tangled_api::TangledClient::new(&pds);
+
+
let (owner, name) = parse_repo_ref(
+
args.repo.as_deref().unwrap_or(&session.handle),
+
&session.handle
);
+
let info = pds_client
+
.get_repo_info(owner, name, Some(session.access_jwt.as_str()))
+
.await?;
+
+
let new_spindle = if args.disable {
+
None
+
} else if let Some(url) = args.url.as_deref() {
+
Some(url)
+
} else if args.enable {
+
// Default spindle URL
+
Some("https://spindle.tangled.sh")
+
} else {
+
return Err(anyhow!("Invalid flags combination"));
+
};
+
+
pds_client
+
.update_repo_spindle(&info.did, &info.rkey, new_spindle, &pds, &session.access_jwt)
+
.await?;
+
+
if args.disable {
+
println!("Disabled spindle for {}/{}", owner, name);
+
} else {
+
println!(
+
"Enabled spindle for {}/{} ({})",
+
owner,
+
name,
+
new_spindle.unwrap_or_default()
+
);
+
}
Ok(())
}
···
}
async fn logs(args: SpindleLogsArgs) -> Result<()> {
-
println!(
-
"Spindle logs (stub) job_id={} follow={} lines={:?}",
-
args.job_id, args.follow, args.lines
-
);
+
// Parse job_id: format is "knot:rkey:name" or just "name" (use repo context)
+
let parts: Vec<&str> = args.job_id.split(':').collect();
+
let (knot, rkey, name) = if parts.len() == 3 {
+
(parts[0].to_string(), parts[1].to_string(), parts[2].to_string())
+
} else if parts.len() == 1 {
+
// Use repo context - need to get repo info
+
let mgr = SessionManager::default();
+
let session = mgr
+
.load()?
+
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
+
let pds = session
+
.pds
+
.clone()
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
+
.unwrap_or_else(|| "https://bsky.social".into());
+
let pds_client = tangled_api::TangledClient::new(&pds);
+
// Get repo info from current directory context or default to user's handle
+
let info = pds_client
+
.get_repo_info(&session.handle, &session.handle, Some(session.access_jwt.as_str()))
+
.await?;
+
(info.knot, info.rkey, parts[0].to_string())
+
} else {
+
return Err(anyhow!("Invalid job_id format. Expected 'knot:rkey:name' or 'name'"));
+
};
+
+
// Build WebSocket URL - spindle base is typically https://spindle.tangled.sh
+
let spindle_base = std::env::var("TANGLED_SPINDLE_BASE")
+
.unwrap_or_else(|_| "wss://spindle.tangled.sh".to_string());
+
let ws_url = format!("{}/spindle/logs/{}/{}/{}", spindle_base, knot, rkey, name);
+
+
println!("Connecting to logs stream for {}:{}:{}...", knot, rkey, name);
+
+
// Connect to WebSocket
+
let (ws_stream, _) = connect_async(&ws_url).await
+
.map_err(|e| anyhow!("Failed to connect to log stream: {}", e))?;
+
+
let (mut _write, mut read) = ws_stream.split();
+
+
// Stream log messages
+
let mut line_count = 0;
+
let max_lines = args.lines.unwrap_or(usize::MAX);
+
+
while let Some(msg) = read.next().await {
+
match msg {
+
Ok(Message::Text(text)) => {
+
println!("{}", text);
+
line_count += 1;
+
if line_count >= max_lines {
+
break;
+
}
+
}
+
Ok(Message::Close(_)) => {
+
break;
+
}
+
Err(e) => {
+
return Err(anyhow!("WebSocket error: {}", e));
+
}
+
_ => {}
+
}
+
}
+
Ok(())
}