Rust CLI for tangled

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)

+443 -12
+153 -4
Cargo.lock
··· 204 204 checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" 205 205 206 206 [[package]] 207 + name = "block-buffer" 208 + version = "0.10.4" 209 + source = "registry+https://github.com/rust-lang/crates.io-index" 210 + checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" 211 + dependencies = [ 212 + "generic-array", 213 + ] 214 + 215 + [[package]] 207 216 name = "bumpalo" 208 217 version = "3.19.0" 209 218 source = "registry+https://github.com/rust-lang/crates.io-index" 210 219 checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" 220 + 221 + [[package]] 222 + name = "byteorder" 223 + version = "1.5.0" 224 + source = "registry+https://github.com/rust-lang/crates.io-index" 225 + checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" 211 226 212 227 [[package]] 213 228 name = "bytes" ··· 388 403 ] 389 404 390 405 [[package]] 406 + name = "cpufeatures" 407 + version = "0.2.17" 408 + source = "registry+https://github.com/rust-lang/crates.io-index" 409 + checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" 410 + dependencies = [ 411 + "libc", 412 + ] 413 + 414 + [[package]] 391 415 name = "crc32fast" 392 416 version = "1.5.0" 393 417 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 397 421 ] 398 422 399 423 [[package]] 424 + name = "crypto-common" 425 + version = "0.1.6" 426 + source = "registry+https://github.com/rust-lang/crates.io-index" 427 + checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" 428 + dependencies = [ 429 + "generic-array", 430 + "typenum", 431 + ] 432 + 433 + [[package]] 400 434 name = "data-encoding" 401 435 version = "2.9.0" 402 436 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 455 489 "tempfile", 456 490 "thiserror 1.0.69", 457 491 "zeroize", 492 + ] 493 + 494 + [[package]] 495 + name = "digest" 496 + version = "0.10.7" 497 + source = "registry+https://github.com/rust-lang/crates.io-index" 498 + checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" 499 + dependencies = [ 500 + "block-buffer", 501 + "crypto-common", 458 502 ] 459 503 460 504 [[package]] ··· 631 675 "pin-project-lite", 632 676 "pin-utils", 633 677 "slab", 678 + ] 679 + 680 + [[package]] 681 + name = "generic-array" 682 + version = "0.14.9" 683 + source = "registry+https://github.com/rust-lang/crates.io-index" 684 + checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" 685 + dependencies = [ 686 + "typenum", 687 + "version_check", 634 688 ] 635 689 636 690 [[package]] ··· 1472 1526 "bytes", 1473 1527 "getrandom 0.3.3", 1474 1528 "lru-slab", 1475 - "rand", 1529 + "rand 0.9.2", 1476 1530 "ring", 1477 1531 "rustc-hash", 1478 1532 "rustls", ··· 1515 1569 1516 1570 [[package]] 1517 1571 name = "rand" 1572 + version = "0.8.5" 1573 + source = "registry+https://github.com/rust-lang/crates.io-index" 1574 + checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 1575 + dependencies = [ 1576 + "libc", 1577 + "rand_chacha 0.3.1", 1578 + "rand_core 0.6.4", 1579 + ] 1580 + 1581 + [[package]] 1582 + name = "rand" 1518 1583 version = "0.9.2" 1519 1584 source = "registry+https://github.com/rust-lang/crates.io-index" 1520 1585 checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" 1521 1586 dependencies = [ 1522 - "rand_chacha", 1523 - "rand_core", 1587 + "rand_chacha 0.9.0", 1588 + "rand_core 0.9.3", 1589 + ] 1590 + 1591 + [[package]] 1592 + name = "rand_chacha" 1593 + version = "0.3.1" 1594 + source = "registry+https://github.com/rust-lang/crates.io-index" 1595 + checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 1596 + dependencies = [ 1597 + "ppv-lite86", 1598 + "rand_core 0.6.4", 1524 1599 ] 1525 1600 1526 1601 [[package]] ··· 1530 1605 checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 1531 1606 dependencies = [ 1532 1607 "ppv-lite86", 1533 - "rand_core", 1608 + "rand_core 0.9.3", 1609 + ] 1610 + 1611 + [[package]] 1612 + name = "rand_core" 1613 + version = "0.6.4" 1614 + source = "registry+https://github.com/rust-lang/crates.io-index" 1615 + checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 1616 + dependencies = [ 1617 + "getrandom 0.2.16", 1534 1618 ] 1535 1619 1536 1620 [[package]] ··· 1851 1935 ] 1852 1936 1853 1937 [[package]] 1938 + name = "sha1" 1939 + version = "0.10.6" 1940 + source = "registry+https://github.com/rust-lang/crates.io-index" 1941 + checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" 1942 + dependencies = [ 1943 + "cfg-if", 1944 + "cpufeatures", 1945 + "digest", 1946 + ] 1947 + 1948 + [[package]] 1854 1949 name = "shell-words" 1855 1950 version = "1.1.0" 1856 1951 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1997 2092 "clap", 1998 2093 "colored", 1999 2094 "dialoguer", 2095 + "futures-util", 2000 2096 "git2", 2001 2097 "indicatif", 2002 2098 "serde", ··· 2005 2101 "tangled-config", 2006 2102 "tangled-git", 2007 2103 "tokio", 2104 + "tokio-tungstenite", 2008 2105 "url", 2009 2106 ] 2010 2107 ··· 2169 2266 ] 2170 2267 2171 2268 [[package]] 2269 + name = "tokio-tungstenite" 2270 + version = "0.21.0" 2271 + source = "registry+https://github.com/rust-lang/crates.io-index" 2272 + checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" 2273 + dependencies = [ 2274 + "futures-util", 2275 + "log", 2276 + "native-tls", 2277 + "tokio", 2278 + "tokio-native-tls", 2279 + "tungstenite", 2280 + ] 2281 + 2282 + [[package]] 2172 2283 name = "tokio-util" 2173 2284 version = "0.7.16" 2174 2285 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2304 2415 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 2305 2416 2306 2417 [[package]] 2418 + name = "tungstenite" 2419 + version = "0.21.0" 2420 + source = "registry+https://github.com/rust-lang/crates.io-index" 2421 + checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" 2422 + dependencies = [ 2423 + "byteorder", 2424 + "bytes", 2425 + "data-encoding", 2426 + "http", 2427 + "httparse", 2428 + "log", 2429 + "native-tls", 2430 + "rand 0.8.5", 2431 + "sha1", 2432 + "thiserror 1.0.69", 2433 + "url", 2434 + "utf-8", 2435 + ] 2436 + 2437 + [[package]] 2438 + name = "typenum" 2439 + version = "1.19.0" 2440 + source = "registry+https://github.com/rust-lang/crates.io-index" 2441 + checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" 2442 + 2443 + [[package]] 2307 2444 name = "unicase" 2308 2445 version = "2.8.1" 2309 2446 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2346 2483 ] 2347 2484 2348 2485 [[package]] 2486 + name = "utf-8" 2487 + version = "0.7.6" 2488 + source = "registry+https://github.com/rust-lang/crates.io-index" 2489 + checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" 2490 + 2491 + [[package]] 2349 2492 name = "utf8_iter" 2350 2493 version = "1.0.4" 2351 2494 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2362 2505 version = "0.2.15" 2363 2506 source = "registry+https://github.com/rust-lang/crates.io-index" 2364 2507 checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 2508 + 2509 + [[package]] 2510 + name = "version_check" 2511 + version = "0.9.5" 2512 + source = "registry+https://github.com/rust-lang/crates.io-index" 2513 + checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 2365 2514 2366 2515 [[package]] 2367 2516 name = "want"
+4
Cargo.toml
··· 53 53 base64 = "0.22" 54 54 regex = "1.10" 55 55 56 + # WebSocket 57 + tokio-tungstenite = { version = "0.21", features = ["native-tls"] } 58 + futures-util = "0.3" 59 + 56 60 # Testing 57 61 mockito = "1.4" 58 62 tempfile = "3.10"
+124
crates/tangled-api/src/client.rs
··· 1175 1175 .await?; 1176 1176 Ok(()) 1177 1177 } 1178 + 1179 + pub async fn update_repo_spindle( 1180 + &self, 1181 + did: &str, 1182 + rkey: &str, 1183 + new_spindle: Option<&str>, 1184 + pds_base: &str, 1185 + access_jwt: &str, 1186 + ) -> Result<()> { 1187 + let pds_client = TangledClient::new(pds_base); 1188 + #[derive(Deserialize, Serialize, Clone)] 1189 + struct Rec { 1190 + name: String, 1191 + knot: String, 1192 + #[serde(skip_serializing_if = "Option::is_none")] 1193 + description: Option<String>, 1194 + #[serde(skip_serializing_if = "Option::is_none")] 1195 + spindle: Option<String>, 1196 + #[serde(rename = "createdAt")] 1197 + created_at: String, 1198 + } 1199 + #[derive(Deserialize)] 1200 + struct GetRes { 1201 + value: Rec, 1202 + } 1203 + let params = [ 1204 + ("repo", did.to_string()), 1205 + ("collection", "sh.tangled.repo".to_string()), 1206 + ("rkey", rkey.to_string()), 1207 + ]; 1208 + let got: GetRes = pds_client 1209 + .get_json("com.atproto.repo.getRecord", &params, Some(access_jwt)) 1210 + .await?; 1211 + let mut rec = got.value; 1212 + rec.spindle = new_spindle.map(|s| s.to_string()); 1213 + #[derive(Serialize)] 1214 + struct PutReq<'a> { 1215 + repo: &'a str, 1216 + collection: &'a str, 1217 + rkey: &'a str, 1218 + validate: bool, 1219 + record: Rec, 1220 + } 1221 + let req = PutReq { 1222 + repo: did, 1223 + collection: "sh.tangled.repo", 1224 + rkey, 1225 + validate: true, 1226 + record: rec, 1227 + }; 1228 + let _: serde_json::Value = pds_client 1229 + .post_json("com.atproto.repo.putRecord", &req, Some(access_jwt)) 1230 + .await?; 1231 + Ok(()) 1232 + } 1233 + 1234 + pub async fn list_pipelines( 1235 + &self, 1236 + repo_did: &str, 1237 + bearer: Option<&str>, 1238 + ) -> Result<Vec<PipelineRecord>> { 1239 + #[derive(Deserialize)] 1240 + struct Item { 1241 + uri: String, 1242 + value: Pipeline, 1243 + } 1244 + #[derive(Deserialize)] 1245 + struct ListRes { 1246 + #[serde(default)] 1247 + records: Vec<Item>, 1248 + } 1249 + let params = vec![ 1250 + ("repo", repo_did.to_string()), 1251 + ("collection", "sh.tangled.pipeline".to_string()), 1252 + ("limit", "100".to_string()), 1253 + ]; 1254 + let res: ListRes = self 1255 + .get_json("com.atproto.repo.listRecords", &params, bearer) 1256 + .await?; 1257 + let mut out = vec![]; 1258 + for it in res.records { 1259 + let rkey = Self::uri_rkey(&it.uri).unwrap_or_default(); 1260 + out.push(PipelineRecord { 1261 + rkey, 1262 + pipeline: it.value, 1263 + }); 1264 + } 1265 + Ok(out) 1266 + } 1178 1267 } 1179 1268 1180 1269 #[derive(Debug, Clone, Serialize, Deserialize, Default)] ··· 1184 1273 pub name: String, 1185 1274 pub knot: Option<String>, 1186 1275 pub description: Option<String>, 1276 + pub spindle: Option<String>, 1187 1277 #[serde(default)] 1188 1278 pub private: bool, 1189 1279 } ··· 1295 1385 pub pds_base: &'a str, 1296 1386 pub access_jwt: &'a str, 1297 1387 } 1388 + 1389 + #[derive(Debug, Clone, Serialize, Deserialize)] 1390 + pub struct TriggerMetadata { 1391 + pub kind: String, 1392 + pub repo: TriggerRepo, 1393 + } 1394 + 1395 + #[derive(Debug, Clone, Serialize, Deserialize)] 1396 + pub struct TriggerRepo { 1397 + pub knot: String, 1398 + pub did: String, 1399 + pub repo: String, 1400 + #[serde(rename = "defaultBranch")] 1401 + pub default_branch: String, 1402 + } 1403 + 1404 + #[derive(Debug, Clone, Serialize, Deserialize)] 1405 + pub struct Workflow { 1406 + pub name: String, 1407 + pub engine: String, 1408 + } 1409 + 1410 + #[derive(Debug, Clone, Serialize, Deserialize)] 1411 + pub struct Pipeline { 1412 + #[serde(rename = "triggerMetadata")] 1413 + pub trigger_metadata: TriggerMetadata, 1414 + pub workflows: Vec<Workflow>, 1415 + } 1416 + 1417 + #[derive(Debug, Clone)] 1418 + pub struct PipelineRecord { 1419 + pub rkey: String, 1420 + pub pipeline: Pipeline, 1421 + }
+2
crates/tangled-cli/Cargo.toml
··· 16 16 tokio = { workspace = true, features = ["full"] } 17 17 git2 = { workspace = true } 18 18 url = { workspace = true } 19 + tokio-tungstenite = { workspace = true } 20 + futures-util = { workspace = true } 19 21 20 22 # Internal crates 21 23 tangled-config = { path = "../tangled-config" }
+160 -8
crates/tangled-cli/src/commands/spindle.rs
··· 3 3 SpindleSecretAddArgs, SpindleSecretCommand, SpindleSecretListArgs, SpindleSecretRemoveArgs, 4 4 }; 5 5 use anyhow::{anyhow, Result}; 6 + use futures_util::StreamExt; 6 7 use tangled_config::session::SessionManager; 8 + use tokio_tungstenite::{connect_async, tungstenite::Message}; 7 9 8 10 pub async fn run(_cli: &Cli, cmd: SpindleCommand) -> Result<()> { 9 11 match cmd { ··· 16 18 } 17 19 18 20 async fn list(args: SpindleListArgs) -> Result<()> { 19 - println!("Spindle list (stub) repo={:?}", args.repo); 21 + let mgr = SessionManager::default(); 22 + let session = mgr 23 + .load()? 24 + .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 25 + 26 + let pds = session 27 + .pds 28 + .clone() 29 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 30 + .unwrap_or_else(|| "https://bsky.social".into()); 31 + let pds_client = tangled_api::TangledClient::new(&pds); 32 + 33 + let (owner, name) = parse_repo_ref( 34 + args.repo.as_deref().unwrap_or(&session.handle), 35 + &session.handle 36 + ); 37 + let info = pds_client 38 + .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 39 + .await?; 40 + 41 + let pipelines = pds_client 42 + .list_pipelines(&info.did, Some(session.access_jwt.as_str())) 43 + .await?; 44 + 45 + if pipelines.is_empty() { 46 + println!("No pipelines found for {}/{}", owner, name); 47 + } else { 48 + println!("RKEY\tKIND\tREPO\tWORKFLOWS"); 49 + for p in pipelines { 50 + let workflows = p.pipeline.workflows 51 + .iter() 52 + .map(|w| w.name.as_str()) 53 + .collect::<Vec<_>>() 54 + .join(","); 55 + println!( 56 + "{}\t{}\t{}\t{}", 57 + p.rkey, 58 + p.pipeline.trigger_metadata.kind, 59 + p.pipeline.trigger_metadata.repo.repo, 60 + workflows 61 + ); 62 + } 63 + } 20 64 Ok(()) 21 65 } 22 66 23 67 async fn config(args: SpindleConfigArgs) -> Result<()> { 24 - println!( 25 - "Spindle config (stub) repo={:?} url={:?} enable={} disable={}", 26 - args.repo, args.url, args.enable, args.disable 68 + let mgr = SessionManager::default(); 69 + let session = mgr 70 + .load()? 71 + .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 72 + 73 + if args.enable && args.disable { 74 + return Err(anyhow!("Cannot use --enable and --disable together")); 75 + } 76 + 77 + if !args.enable && !args.disable && args.url.is_none() { 78 + return Err(anyhow!( 79 + "Must provide --enable, --disable, or --url" 80 + )); 81 + } 82 + 83 + let pds = session 84 + .pds 85 + .clone() 86 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 87 + .unwrap_or_else(|| "https://bsky.social".into()); 88 + let pds_client = tangled_api::TangledClient::new(&pds); 89 + 90 + let (owner, name) = parse_repo_ref( 91 + args.repo.as_deref().unwrap_or(&session.handle), 92 + &session.handle 27 93 ); 94 + let info = pds_client 95 + .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 96 + .await?; 97 + 98 + let new_spindle = if args.disable { 99 + None 100 + } else if let Some(url) = args.url.as_deref() { 101 + Some(url) 102 + } else if args.enable { 103 + // Default spindle URL 104 + Some("https://spindle.tangled.sh") 105 + } else { 106 + return Err(anyhow!("Invalid flags combination")); 107 + }; 108 + 109 + pds_client 110 + .update_repo_spindle(&info.did, &info.rkey, new_spindle, &pds, &session.access_jwt) 111 + .await?; 112 + 113 + if args.disable { 114 + println!("Disabled spindle for {}/{}", owner, name); 115 + } else { 116 + println!( 117 + "Enabled spindle for {}/{} ({})", 118 + owner, 119 + name, 120 + new_spindle.unwrap_or_default() 121 + ); 122 + } 28 123 Ok(()) 29 124 } 30 125 ··· 37 132 } 38 133 39 134 async fn logs(args: SpindleLogsArgs) -> Result<()> { 40 - println!( 41 - "Spindle logs (stub) job_id={} follow={} lines={:?}", 42 - args.job_id, args.follow, args.lines 43 - ); 135 + // Parse job_id: format is "knot:rkey:name" or just "name" (use repo context) 136 + let parts: Vec<&str> = args.job_id.split(':').collect(); 137 + let (knot, rkey, name) = if parts.len() == 3 { 138 + (parts[0].to_string(), parts[1].to_string(), parts[2].to_string()) 139 + } else if parts.len() == 1 { 140 + // Use repo context - need to get repo info 141 + let mgr = SessionManager::default(); 142 + let session = mgr 143 + .load()? 144 + .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 145 + let pds = session 146 + .pds 147 + .clone() 148 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 149 + .unwrap_or_else(|| "https://bsky.social".into()); 150 + let pds_client = tangled_api::TangledClient::new(&pds); 151 + // Get repo info from current directory context or default to user's handle 152 + let info = pds_client 153 + .get_repo_info(&session.handle, &session.handle, Some(session.access_jwt.as_str())) 154 + .await?; 155 + (info.knot, info.rkey, parts[0].to_string()) 156 + } else { 157 + return Err(anyhow!("Invalid job_id format. Expected 'knot:rkey:name' or 'name'")); 158 + }; 159 + 160 + // Build WebSocket URL - spindle base is typically https://spindle.tangled.sh 161 + let spindle_base = std::env::var("TANGLED_SPINDLE_BASE") 162 + .unwrap_or_else(|_| "wss://spindle.tangled.sh".to_string()); 163 + let ws_url = format!("{}/spindle/logs/{}/{}/{}", spindle_base, knot, rkey, name); 164 + 165 + println!("Connecting to logs stream for {}:{}:{}...", knot, rkey, name); 166 + 167 + // Connect to WebSocket 168 + let (ws_stream, _) = connect_async(&ws_url).await 169 + .map_err(|e| anyhow!("Failed to connect to log stream: {}", e))?; 170 + 171 + let (mut _write, mut read) = ws_stream.split(); 172 + 173 + // Stream log messages 174 + let mut line_count = 0; 175 + let max_lines = args.lines.unwrap_or(usize::MAX); 176 + 177 + while let Some(msg) = read.next().await { 178 + match msg { 179 + Ok(Message::Text(text)) => { 180 + println!("{}", text); 181 + line_count += 1; 182 + if line_count >= max_lines { 183 + break; 184 + } 185 + } 186 + Ok(Message::Close(_)) => { 187 + break; 188 + } 189 + Err(e) => { 190 + return Err(anyhow!("WebSocket error: {}", e)); 191 + } 192 + _ => {} 193 + } 194 + } 195 + 44 196 Ok(()) 45 197 } 46 198