Rust CLI for tangled

More repo commands, migrate

Changed files
+801 -15
crates
tangled-api
src
tangled-cli
+2
Cargo.lock
··· 1997 "clap", 1998 "colored", 1999 "dialoguer", 2000 "indicatif", 2001 "serde", 2002 "serde_json", ··· 2004 "tangled-config", 2005 "tangled-git", 2006 "tokio", 2007 ] 2008 2009 [[package]]
··· 1997 "clap", 1998 "colored", 1999 "dialoguer", 2000 + "git2", 2001 "indicatif", 2002 "serde", 2003 "serde_json", ··· 2005 "tangled-config", 2006 "tangled-git", 2007 "tokio", 2008 + "url", 2009 ] 2010 2011 [[package]]
+396 -1
crates/tangled-api/src/client.rs
··· 152 153 #[derive(Deserialize)] 154 struct RecordItem { 155 value: Repository, 156 } 157 #[derive(Deserialize)] ··· 169 let res: ListRes = self 170 .get_json("com.atproto.repo.listRecords", &params, bearer) 171 .await?; 172 - let mut repos: Vec<Repository> = res.records.into_iter().map(|r| r.value).collect(); 173 // Apply optional filters client-side 174 if let Some(k) = knot { 175 repos.retain(|r| r.knot.as_deref().unwrap_or("") == k); ··· 277 let _: serde_json::Value = self.post_json(REPO_CREATE, &req, Some(&sa.token)).await?; 278 Ok(()) 279 } 280 } 281 282 #[derive(Debug, Clone, Serialize, Deserialize, Default)] ··· 288 pub description: Option<String>, 289 #[serde(default)] 290 pub private: bool, 291 } 292 293 #[derive(Debug, Clone)]
··· 152 153 #[derive(Deserialize)] 154 struct RecordItem { 155 + uri: String, 156 value: Repository, 157 } 158 #[derive(Deserialize)] ··· 170 let res: ListRes = self 171 .get_json("com.atproto.repo.listRecords", &params, bearer) 172 .await?; 173 + let mut repos: Vec<Repository> = res 174 + .records 175 + .into_iter() 176 + .map(|r| { 177 + let mut val = r.value; 178 + if val.rkey.is_none() { 179 + if let Some(k) = Self::uri_rkey(&r.uri) { 180 + val.rkey = Some(k); 181 + } 182 + } 183 + if val.did.is_none() { 184 + if let Some(d) = Self::uri_did(&r.uri) { 185 + val.did = Some(d); 186 + } 187 + } 188 + val 189 + }) 190 + .collect(); 191 // Apply optional filters client-side 192 if let Some(k) = knot { 193 repos.retain(|r| r.knot.as_deref().unwrap_or("") == k); ··· 295 let _: serde_json::Value = self.post_json(REPO_CREATE, &req, Some(&sa.token)).await?; 296 Ok(()) 297 } 298 + 299 + pub async fn get_repo_info( 300 + &self, 301 + owner: &str, 302 + name: &str, 303 + bearer: Option<&str>, 304 + ) -> Result<RepoRecord> { 305 + let did = if owner.starts_with("did:") { 306 + owner.to_string() 307 + } else { 308 + #[derive(Deserialize)] 309 + struct Res { 310 + did: String, 311 + } 312 + let params = [("handle", owner.to_string())]; 313 + let res: Res = self 314 + .get_json("com.atproto.identity.resolveHandle", &params, bearer) 315 + .await?; 316 + res.did 317 + }; 318 + 319 + #[derive(Deserialize)] 320 + struct RecordItem { 321 + uri: String, 322 + value: Repository, 323 + } 324 + #[derive(Deserialize)] 325 + struct ListRes { 326 + #[serde(default)] 327 + records: Vec<RecordItem>, 328 + } 329 + let params = vec![ 330 + ("repo", did.clone()), 331 + ("collection", "sh.tangled.repo".to_string()), 332 + ("limit", "100".to_string()), 333 + ]; 334 + let res: ListRes = self 335 + .get_json("com.atproto.repo.listRecords", &params, bearer) 336 + .await?; 337 + for item in res.records { 338 + if item.value.name == name { 339 + let rkey = 340 + Self::uri_rkey(&item.uri).ok_or_else(|| anyhow!("missing rkey in uri"))?; 341 + let knot = item.value.knot.unwrap_or_default(); 342 + return Ok(RepoRecord { 343 + did: did.clone(), 344 + name: name.to_string(), 345 + rkey, 346 + knot, 347 + description: item.value.description, 348 + }); 349 + } 350 + } 351 + Err(anyhow!("repo not found for owner/name")) 352 + } 353 + 354 + pub async fn delete_repo( 355 + &self, 356 + did: &str, 357 + name: &str, 358 + pds_base: &str, 359 + access_jwt: &str, 360 + ) -> Result<()> { 361 + let pds_client = TangledClient::new(pds_base); 362 + let info = pds_client 363 + .get_repo_info(did, name, Some(access_jwt)) 364 + .await?; 365 + 366 + #[derive(Serialize)] 367 + struct DeleteRecordReq<'a> { 368 + repo: &'a str, 369 + collection: &'a str, 370 + rkey: &'a str, 371 + } 372 + let del = DeleteRecordReq { 373 + repo: did, 374 + collection: "sh.tangled.repo", 375 + rkey: &info.rkey, 376 + }; 377 + let _: serde_json::Value = pds_client 378 + .post_json("com.atproto.repo.deleteRecord", &del, Some(access_jwt)) 379 + .await?; 380 + 381 + let host = self 382 + .base_url 383 + .trim_end_matches('/') 384 + .strip_prefix("https://") 385 + .or_else(|| self.base_url.trim_end_matches('/').strip_prefix("http://")) 386 + .ok_or_else(|| anyhow!("invalid base_url"))?; 387 + let audience = format!("did:web:{}", host); 388 + #[derive(Deserialize)] 389 + struct GetSARes { 390 + token: String, 391 + } 392 + let params = [ 393 + ("aud", audience), 394 + ("exp", (chrono::Utc::now().timestamp() + 600).to_string()), 395 + ]; 396 + let sa: GetSARes = pds_client 397 + .get_json( 398 + "com.atproto.server.getServiceAuth", 399 + &params, 400 + Some(access_jwt), 401 + ) 402 + .await?; 403 + 404 + #[derive(Serialize)] 405 + struct DeleteReq<'a> { 406 + did: &'a str, 407 + name: &'a str, 408 + rkey: &'a str, 409 + } 410 + let body = DeleteReq { 411 + did, 412 + name, 413 + rkey: &info.rkey, 414 + }; 415 + let _: serde_json::Value = self 416 + .post_json("sh.tangled.repo.delete", &body, Some(&sa.token)) 417 + .await?; 418 + Ok(()) 419 + } 420 + 421 + pub async fn update_repo_knot( 422 + &self, 423 + did: &str, 424 + rkey: &str, 425 + new_knot: &str, 426 + pds_base: &str, 427 + access_jwt: &str, 428 + ) -> Result<()> { 429 + let pds_client = TangledClient::new(pds_base); 430 + #[derive(Deserialize, Serialize, Clone)] 431 + struct Rec { 432 + name: String, 433 + knot: String, 434 + #[serde(skip_serializing_if = "Option::is_none")] 435 + description: Option<String>, 436 + #[serde(rename = "createdAt")] 437 + created_at: String, 438 + } 439 + #[derive(Deserialize)] 440 + struct GetRes { 441 + value: Rec, 442 + } 443 + let params = [ 444 + ("repo", did.to_string()), 445 + ("collection", "sh.tangled.repo".to_string()), 446 + ("rkey", rkey.to_string()), 447 + ]; 448 + let got: GetRes = pds_client 449 + .get_json("com.atproto.repo.getRecord", &params, Some(access_jwt)) 450 + .await?; 451 + let mut rec = got.value; 452 + rec.knot = new_knot.to_string(); 453 + #[derive(Serialize)] 454 + struct PutReq<'a> { 455 + repo: &'a str, 456 + collection: &'a str, 457 + rkey: &'a str, 458 + validate: bool, 459 + record: Rec, 460 + } 461 + let req = PutReq { 462 + repo: did, 463 + collection: "sh.tangled.repo", 464 + rkey, 465 + validate: true, 466 + record: rec, 467 + }; 468 + let _: serde_json::Value = pds_client 469 + .post_json("com.atproto.repo.putRecord", &req, Some(access_jwt)) 470 + .await?; 471 + Ok(()) 472 + } 473 + 474 + pub async fn get_default_branch( 475 + &self, 476 + knot_host: &str, 477 + did: &str, 478 + name: &str, 479 + ) -> Result<DefaultBranch> { 480 + #[derive(Deserialize)] 481 + struct Res { 482 + name: String, 483 + hash: String, 484 + #[serde(rename = "shortHash")] 485 + short_hash: Option<String>, 486 + when: String, 487 + message: Option<String>, 488 + } 489 + let knot_client = TangledClient::new(knot_host); 490 + let repo_param = format!("{}/{}", did, name); 491 + let params = [("repo", repo_param)]; 492 + let res: Res = knot_client 493 + .get_json("sh.tangled.repo.getDefaultBranch", &params, None) 494 + .await?; 495 + Ok(DefaultBranch { 496 + name: res.name, 497 + hash: res.hash, 498 + short_hash: res.short_hash, 499 + when: res.when, 500 + message: res.message, 501 + }) 502 + } 503 + 504 + pub async fn get_languages(&self, knot_host: &str, did: &str, name: &str) -> Result<Languages> { 505 + let knot_client = TangledClient::new(knot_host); 506 + let repo_param = format!("{}/{}", did, name); 507 + let params = [("repo", repo_param)]; 508 + let res: serde_json::Value = knot_client 509 + .get_json("sh.tangled.repo.languages", &params, None) 510 + .await?; 511 + let langs = res 512 + .get("languages") 513 + .cloned() 514 + .unwrap_or(serde_json::json!([])); 515 + let languages: Vec<Language> = serde_json::from_value(langs)?; 516 + let total_size = res.get("totalSize").and_then(|v| v.as_u64()); 517 + let total_files = res.get("totalFiles").and_then(|v| v.as_u64()); 518 + Ok(Languages { 519 + languages, 520 + total_size, 521 + total_files, 522 + }) 523 + } 524 + 525 + pub async fn star_repo( 526 + &self, 527 + pds_base: &str, 528 + access_jwt: &str, 529 + subject_at_uri: &str, 530 + user_did: &str, 531 + ) -> Result<String> { 532 + #[derive(Serialize)] 533 + struct Rec<'a> { 534 + subject: &'a str, 535 + #[serde(rename = "createdAt")] 536 + created_at: String, 537 + } 538 + #[derive(Serialize)] 539 + struct Req<'a> { 540 + repo: &'a str, 541 + collection: &'a str, 542 + validate: bool, 543 + record: Rec<'a>, 544 + } 545 + #[derive(Deserialize)] 546 + struct Res { 547 + uri: String, 548 + } 549 + let now = chrono::Utc::now().to_rfc3339(); 550 + let rec = Rec { 551 + subject: subject_at_uri, 552 + created_at: now, 553 + }; 554 + let req = Req { 555 + repo: user_did, 556 + collection: "sh.tangled.feed.star", 557 + validate: true, 558 + record: rec, 559 + }; 560 + let pds_client = TangledClient::new(pds_base); 561 + let res: Res = pds_client 562 + .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt)) 563 + .await?; 564 + let rkey = Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in star uri"))?; 565 + Ok(rkey) 566 + } 567 + 568 + pub async fn unstar_repo( 569 + &self, 570 + pds_base: &str, 571 + access_jwt: &str, 572 + subject_at_uri: &str, 573 + user_did: &str, 574 + ) -> Result<()> { 575 + #[derive(Deserialize)] 576 + struct Item { 577 + uri: String, 578 + value: StarRecord, 579 + } 580 + #[derive(Deserialize)] 581 + struct ListRes { 582 + #[serde(default)] 583 + records: Vec<Item>, 584 + } 585 + let pds_client = TangledClient::new(pds_base); 586 + let params = vec![ 587 + ("repo", user_did.to_string()), 588 + ("collection", "sh.tangled.feed.star".to_string()), 589 + ("limit", "100".to_string()), 590 + ]; 591 + let res: ListRes = pds_client 592 + .get_json("com.atproto.repo.listRecords", &params, Some(access_jwt)) 593 + .await?; 594 + let mut rkey = None; 595 + for item in res.records { 596 + if item.value.subject == subject_at_uri { 597 + rkey = Self::uri_rkey(&item.uri); 598 + if rkey.is_some() { 599 + break; 600 + } 601 + } 602 + } 603 + let rkey = rkey.ok_or_else(|| anyhow!("star record not found"))?; 604 + #[derive(Serialize)] 605 + struct Del<'a> { 606 + repo: &'a str, 607 + collection: &'a str, 608 + rkey: &'a str, 609 + } 610 + let del = Del { 611 + repo: user_did, 612 + collection: "sh.tangled.feed.star", 613 + rkey: &rkey, 614 + }; 615 + let _: serde_json::Value = pds_client 616 + .post_json("com.atproto.repo.deleteRecord", &del, Some(access_jwt)) 617 + .await?; 618 + Ok(()) 619 + } 620 + 621 + fn uri_rkey(uri: &str) -> Option<String> { 622 + uri.rsplit('/').next().map(|s| s.to_string()) 623 + } 624 + fn uri_did(uri: &str) -> Option<String> { 625 + let parts: Vec<&str> = uri.split('/').collect(); 626 + if parts.len() >= 3 { 627 + Some(parts[2].to_string()) 628 + } else { 629 + None 630 + } 631 + } 632 } 633 634 #[derive(Debug, Clone, Serialize, Deserialize, Default)] ··· 640 pub description: Option<String>, 641 #[serde(default)] 642 pub private: bool, 643 + } 644 + 645 + #[derive(Debug, Clone)] 646 + pub struct RepoRecord { 647 + pub did: String, 648 + pub name: String, 649 + pub rkey: String, 650 + pub knot: String, 651 + pub description: Option<String>, 652 + } 653 + 654 + #[derive(Debug, Clone, Serialize, Deserialize)] 655 + pub struct DefaultBranch { 656 + pub name: String, 657 + pub hash: String, 658 + #[serde(skip_serializing_if = "Option::is_none")] 659 + pub short_hash: Option<String>, 660 + pub when: String, 661 + #[serde(skip_serializing_if = "Option::is_none")] 662 + pub message: Option<String>, 663 + } 664 + 665 + #[derive(Debug, Clone, Serialize, Deserialize)] 666 + pub struct Language { 667 + pub name: String, 668 + pub size: u64, 669 + pub percentage: u64, 670 + } 671 + 672 + #[derive(Debug, Clone, Serialize, Deserialize)] 673 + pub struct Languages { 674 + pub languages: Vec<Language>, 675 + #[serde(skip_serializing_if = "Option::is_none")] 676 + pub total_size: Option<u64>, 677 + #[serde(skip_serializing_if = "Option::is_none")] 678 + pub total_files: Option<u64>, 679 + } 680 + 681 + #[derive(Debug, Clone, Serialize, Deserialize)] 682 + pub struct StarRecord { 683 + pub subject: String, 684 + #[serde(rename = "createdAt")] 685 + pub created_at: String, 686 } 687 688 #[derive(Debug, Clone)]
+2 -1
crates/tangled-cli/Cargo.toml
··· 14 serde = { workspace = true, features = ["derive"] } 15 serde_json = { workspace = true } 16 tokio = { workspace = true, features = ["full"] } 17 18 # Internal crates 19 tangled-config = { path = "../tangled-config" } 20 tangled-api = { path = "../tangled-api" } 21 tangled-git = { path = "../tangled-git" } 22 -
··· 14 serde = { workspace = true, features = ["derive"] } 15 serde_json = { workspace = true } 16 tokio = { workspace = true, features = ["full"] } 17 + git2 = { workspace = true } 18 + url = { workspace = true } 19 20 # Internal crates 21 tangled-config = { path = "../tangled-config" } 22 tangled-api = { path = "../tangled-api" } 23 tangled-git = { path = "../tangled-git" }
+18
crates/tangled-cli/src/cli.rs
··· 299 Verify(KnotVerifyArgs), 300 SetDefault(KnotRefArgs), 301 Remove(KnotRefArgs), 302 } 303 304 #[derive(Args, Debug, Clone)] ··· 328 #[derive(Args, Debug, Clone)] 329 pub struct KnotRefArgs { 330 pub url: String, 331 } 332 333 #[derive(Subcommand, Debug, Clone)]
··· 299 Verify(KnotVerifyArgs), 300 SetDefault(KnotRefArgs), 301 Remove(KnotRefArgs), 302 + /// Migrate a repository to another knot 303 + Migrate(KnotMigrateArgs), 304 } 305 306 #[derive(Args, Debug, Clone)] ··· 330 #[derive(Args, Debug, Clone)] 331 pub struct KnotRefArgs { 332 pub url: String, 333 + } 334 + 335 + #[derive(Args, Debug, Clone)] 336 + pub struct KnotMigrateArgs { 337 + /// Repo to migrate: <owner>/<name> (owner defaults to your handle) 338 + #[arg(long)] 339 + pub repo: String, 340 + /// Target knot hostname (e.g. knot1.tangled.sh) 341 + #[arg(long, value_name = "HOST")] 342 + pub to: String, 343 + /// Use HTTPS source when seeding new repo 344 + #[arg(long, default_value_t = true)] 345 + pub https: bool, 346 + /// Update PDS record knot field after seeding 347 + #[arg(long, default_value_t = true)] 348 + pub update_record: bool, 349 } 350 351 #[derive(Subcommand, Debug, Clone)]
+190 -1
crates/tangled-cli/src/commands/knot.rs
··· 1 - use crate::cli::{Cli, KnotAddArgs, KnotCommand, KnotListArgs, KnotRefArgs, KnotVerifyArgs}; 2 use anyhow::Result; 3 4 pub async fn run(_cli: &Cli, cmd: KnotCommand) -> Result<()> { 5 match cmd { ··· 8 KnotCommand::Verify(args) => verify(args).await, 9 KnotCommand::SetDefault(args) => set_default(args).await, 10 KnotCommand::Remove(args) => remove(args).await, 11 } 12 } 13 ··· 41 println!("Knot remove (stub) url={}", args.url); 42 Ok(()) 43 }
··· 1 + use crate::cli::{ 2 + Cli, KnotAddArgs, KnotCommand, KnotListArgs, KnotMigrateArgs, KnotRefArgs, KnotVerifyArgs, 3 + }; 4 + use anyhow::anyhow; 5 use anyhow::Result; 6 + use git2::{Direction, Repository as GitRepository, StatusOptions}; 7 + use std::path::Path; 8 + use tangled_config::session::SessionManager; 9 10 pub async fn run(_cli: &Cli, cmd: KnotCommand) -> Result<()> { 11 match cmd { ··· 14 KnotCommand::Verify(args) => verify(args).await, 15 KnotCommand::SetDefault(args) => set_default(args).await, 16 KnotCommand::Remove(args) => remove(args).await, 17 + KnotCommand::Migrate(args) => migrate(args).await, 18 } 19 } 20 ··· 48 println!("Knot remove (stub) url={}", args.url); 49 Ok(()) 50 } 51 + 52 + async fn migrate(args: KnotMigrateArgs) -> Result<()> { 53 + let mgr = SessionManager::default(); 54 + let session = mgr 55 + .load()? 56 + .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 57 + // 1) Ensure we're inside a git repository and working tree is clean 58 + let repo = GitRepository::discover(Path::new("."))?; 59 + let mut status_opts = StatusOptions::new(); 60 + status_opts.include_untracked(false).include_ignored(false); 61 + let statuses = repo.statuses(Some(&mut status_opts))?; 62 + if !statuses.is_empty() { 63 + return Err(anyhow!( 64 + "working tree has uncommitted changes; commit/push before migrating" 65 + )); 66 + } 67 + 68 + // 2) Derive current branch and ensure it's pushed to origin 69 + let head = match repo.head() { 70 + Ok(h) => h, 71 + Err(_) => return Err(anyhow!("repository does not have a HEAD")), 72 + }; 73 + let head_oid = head 74 + .target() 75 + .ok_or_else(|| anyhow!("failed to resolve HEAD OID"))?; 76 + let head_name = head.shorthand().unwrap_or(""); 77 + let full_ref = head.name().unwrap_or("").to_string(); 78 + if !full_ref.starts_with("refs/heads/") { 79 + return Err(anyhow!( 80 + "HEAD is detached; please checkout a branch before migrating" 81 + )); 82 + } 83 + let branch = head_name.to_string(); 84 + 85 + let origin = repo.find_remote("origin").or_else(|_| { 86 + repo.remotes().and_then(|rems| { 87 + rems.get(0) 88 + .ok_or(git2::Error::from_str("no remotes configured")) 89 + .and_then(|name| repo.find_remote(name)) 90 + }) 91 + })?; 92 + 93 + // Connect and list remote heads to find refs/heads/<branch> 94 + let mut remote = origin; 95 + remote.connect(Direction::Fetch)?; 96 + let remote_heads = remote.list()?; 97 + let remote_oid = remote_heads 98 + .iter() 99 + .find_map(|h| { 100 + if h.name() == format!("refs/heads/{}", branch) { 101 + Some(h.oid()) 102 + } else { 103 + None 104 + } 105 + }) 106 + .ok_or_else(|| anyhow!("origin does not have branch '{}' — push first", branch))?; 107 + if remote_oid != head_oid { 108 + return Err(anyhow!( 109 + "local {} ({}) != origin {} ({}); please push before migrating", 110 + branch, 111 + head_oid, 112 + branch, 113 + remote_oid 114 + )); 115 + } 116 + 117 + // 3) Parse origin URL to verify repo identity 118 + let origin_url = remote 119 + .url() 120 + .ok_or_else(|| anyhow!("origin has no URL"))? 121 + .to_string(); 122 + let (origin_owner, origin_name, _origin_host) = parse_remote_url(&origin_url) 123 + .ok_or_else(|| anyhow!("unsupported origin URL: {}", origin_url))?; 124 + 125 + let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 126 + if origin_owner.trim_start_matches('@') != owner.trim_start_matches('@') || origin_name != name 127 + { 128 + return Err(anyhow!( 129 + "repo mismatch: current checkout '{}'/{} != argument '{}'/{}", 130 + origin_owner, 131 + origin_name, 132 + owner, 133 + name 134 + )); 135 + } 136 + 137 + let pds = session 138 + .pds 139 + .clone() 140 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 141 + .unwrap_or_else(|| "https://bsky.social".into()); 142 + let pds_client = tangled_api::TangledClient::new(&pds); 143 + let info = pds_client 144 + .get_repo_info(owner, &name, Some(session.access_jwt.as_str())) 145 + .await?; 146 + 147 + // Build a publicly accessible source URL on tangled.org for the existing repo 148 + let owner_path = if owner.starts_with('@') { 149 + owner.to_string() 150 + } else { 151 + format!("@{}", owner) 152 + }; 153 + let source = if args.https { 154 + format!("https://tangled.org/{}/{}", owner_path, name) 155 + } else { 156 + format!( 157 + "git@{}:{}/{}", 158 + info.knot, 159 + owner.trim_start_matches('@'), 160 + name 161 + ) 162 + }; 163 + 164 + // Create the repo on the target knot, seeding from source 165 + let client = tangled_api::TangledClient::default(); 166 + let opts = tangled_api::client::CreateRepoOptions { 167 + did: &session.did, 168 + name: &name, 169 + knot: &args.to, 170 + description: info.description.as_deref(), 171 + default_branch: None, 172 + source: Some(&source), 173 + pds_base: &pds, 174 + access_jwt: &session.access_jwt, 175 + }; 176 + client.create_repo(opts).await?; 177 + 178 + // Update the PDS record to point to the new knot 179 + if args.update_record { 180 + client 181 + .update_repo_knot( 182 + &session.did, 183 + &info.rkey, 184 + &args.to, 185 + &pds, 186 + &session.access_jwt, 187 + ) 188 + .await?; 189 + } 190 + 191 + println!("Migrated repo '{}' to knot {}", name, args.to); 192 + println!( 193 + "Note: old repository on {} is not deleted automatically.", 194 + info.knot 195 + ); 196 + Ok(()) 197 + } 198 + 199 + fn parse_repo_ref<'a>(spec: &'a str, default_owner: &'a str) -> (&'a str, String) { 200 + if let Some((owner, name)) = spec.split_once('/') { 201 + (owner, name.to_string()) 202 + } else { 203 + (default_owner, spec.to_string()) 204 + } 205 + } 206 + 207 + fn parse_remote_url(url: &str) -> Option<(String, String, String)> { 208 + // Returns (owner, name, host) 209 + if let Some(rest) = url.strip_prefix("git@") { 210 + // git@host:owner/name(.git) 211 + let mut parts = rest.split(':'); 212 + let host = parts.next()?.to_string(); 213 + let path = parts.next()?; 214 + let mut segs = path.trim_end_matches(".git").split('/'); 215 + let owner = segs.next()?.to_string(); 216 + let name = segs.next()?.to_string(); 217 + return Some((owner, name, host)); 218 + } 219 + if url.starts_with("http://") || url.starts_with("https://") { 220 + if let Ok(parsed) = url::Url::parse(url) { 221 + let host = parsed.host_str().unwrap_or("").to_string(); 222 + let path = parsed.path().trim_matches('/'); 223 + // paths may be like '@owner/name' or 'owner/name' 224 + let mut segs = path.trim_end_matches(".git").split('/'); 225 + let first = segs.next()?; 226 + let owner = first.trim_start_matches('@').to_string(); 227 + let name = segs.next()?.to_string(); 228 + return Some((owner, name, host)); 229 + } 230 + } 231 + None 232 + }
+193 -12
crates/tangled-cli/src/commands/repo.rs
··· 1 use anyhow::{anyhow, Result}; 2 use serde_json; 3 use tangled_config::session::SessionManager; 4 5 use crate::cli::{ ··· 95 } 96 97 async fn clone(args: RepoCloneArgs) -> Result<()> { 98 - println!( 99 - "Cloning repo '{}' (stub) https={} depth={:?}", 100 - args.repo, args.https, args.depth 101 - ); 102 - Ok(()) 103 } 104 105 async fn info(args: RepoInfoArgs) -> Result<()> { 106 - println!( 107 - "Repository info '{}' (stub) stats={} contributors={}", 108 - args.repo, args.stats, args.contributors 109 - ); 110 Ok(()) 111 } 112 113 async fn delete(args: RepoDeleteArgs) -> Result<()> { 114 - println!("Deleting repo '{}' (stub) force={}", args.repo, args.force); 115 Ok(()) 116 } 117 118 async fn star(args: RepoRefArgs) -> Result<()> { 119 - println!("Starring repo '{}' (stub)", args.repo); 120 Ok(()) 121 } 122 123 async fn unstar(args: RepoRefArgs) -> Result<()> { 124 - println!("Unstarring repo '{}' (stub)", args.repo); 125 Ok(()) 126 }
··· 1 use anyhow::{anyhow, Result}; 2 + use git2::{build::RepoBuilder, Cred, FetchOptions, RemoteCallbacks}; 3 use serde_json; 4 + use std::path::PathBuf; 5 use tangled_config::session::SessionManager; 6 7 use crate::cli::{ ··· 97 } 98 99 async fn clone(args: RepoCloneArgs) -> Result<()> { 100 + let mgr = SessionManager::default(); 101 + let session = mgr 102 + .load()? 103 + .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 104 + 105 + let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 106 + let pds = session 107 + .pds 108 + .clone() 109 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 110 + .unwrap_or_else(|| "https://bsky.social".into()); 111 + let pds_client = tangled_api::TangledClient::new(&pds); 112 + let info = pds_client 113 + .get_repo_info(owner, &name, Some(session.access_jwt.as_str())) 114 + .await?; 115 + 116 + let remote = if args.https { 117 + let owner_path = if owner.starts_with('@') { 118 + owner.to_string() 119 + } else { 120 + format!("@{}", owner) 121 + }; 122 + format!("https://tangled.org/{}/{}", owner_path, name) 123 + } else { 124 + let knot = if info.knot == "knot1.tangled.sh" { 125 + "tangled.org".to_string() 126 + } else { 127 + info.knot.clone() 128 + }; 129 + format!("git@{}:{}/{}", knot, owner.trim_start_matches('@'), name) 130 + }; 131 + 132 + let target = PathBuf::from(&name); 133 + println!("Cloning {} -> {:?}", remote, target); 134 + 135 + let mut callbacks = RemoteCallbacks::new(); 136 + callbacks.credentials(|_url, username_from_url, _allowed| { 137 + if let Some(user) = username_from_url { 138 + Cred::ssh_key_from_agent(user) 139 + } else { 140 + Cred::default() 141 + } 142 + }); 143 + let mut fetch_opts = FetchOptions::new(); 144 + fetch_opts.remote_callbacks(callbacks); 145 + if let Some(d) = args.depth { 146 + fetch_opts.depth(d as i32); 147 + } 148 + let mut builder = RepoBuilder::new(); 149 + builder.fetch_options(fetch_opts); 150 + match builder.clone(&remote, &target) { 151 + Ok(_) => Ok(()), 152 + Err(e) => { 153 + println!("Failed to clone via libgit2: {}", e); 154 + println!( 155 + "Hint: try: git clone{} {}", 156 + args.depth 157 + .map(|d| format!(" --depth {}", d)) 158 + .unwrap_or_default(), 159 + remote 160 + ); 161 + Err(anyhow!(e.to_string())) 162 + } 163 + } 164 } 165 166 async fn info(args: RepoInfoArgs) -> Result<()> { 167 + let mgr = SessionManager::default(); 168 + let session = mgr 169 + .load()? 170 + .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 171 + let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 172 + let pds = session 173 + .pds 174 + .clone() 175 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 176 + .unwrap_or_else(|| "https://bsky.social".into()); 177 + let pds_client = tangled_api::TangledClient::new(&pds); 178 + let info = pds_client 179 + .get_repo_info(owner, &name, Some(session.access_jwt.as_str())) 180 + .await?; 181 + 182 + println!("NAME: {}", info.name); 183 + println!("OWNER DID: {}", info.did); 184 + println!("KNOT: {}", info.knot); 185 + if let Some(desc) = info.description.as_deref() { 186 + if !desc.is_empty() { 187 + println!("DESCRIPTION: {}", desc); 188 + } 189 + } 190 + 191 + let knot_host = if info.knot == "knot1.tangled.sh" { 192 + "tangled.org".to_string() 193 + } else { 194 + info.knot.clone() 195 + }; 196 + if args.stats { 197 + let client = tangled_api::TangledClient::default(); 198 + if let Ok(def) = client 199 + .get_default_branch(&knot_host, &info.did, &info.name) 200 + .await 201 + { 202 + println!( 203 + "DEFAULT BRANCH: {} ({})", 204 + def.name, 205 + def.short_hash.unwrap_or(def.hash) 206 + ); 207 + if let Some(msg) = def.message { 208 + if !msg.is_empty() { 209 + println!("LAST COMMIT: {}", msg); 210 + } 211 + } 212 + } 213 + if let Ok(langs) = client 214 + .get_languages(&knot_host, &info.did, &info.name) 215 + .await 216 + { 217 + if !langs.languages.is_empty() { 218 + println!("LANGUAGES:"); 219 + for l in langs.languages.iter().take(6) { 220 + println!(" - {} ({}%)", l.name, l.percentage); 221 + } 222 + } 223 + } 224 + } 225 + 226 + if args.contributors { 227 + println!("Contributors: not implemented yet"); 228 + } 229 Ok(()) 230 } 231 232 async fn delete(args: RepoDeleteArgs) -> Result<()> { 233 + let mgr = SessionManager::default(); 234 + let session = mgr 235 + .load()? 236 + .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 237 + let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 238 + let pds = session 239 + .pds 240 + .clone() 241 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 242 + .unwrap_or_else(|| "https://bsky.social".into()); 243 + let pds_client = tangled_api::TangledClient::new(&pds); 244 + let record = pds_client 245 + .get_repo_info(owner, &name, Some(session.access_jwt.as_str())) 246 + .await?; 247 + let did = record.did; 248 + let api = tangled_api::TangledClient::default(); 249 + api.delete_repo(&did, &name, &pds, &session.access_jwt) 250 + .await?; 251 + println!("Deleted repo '{}'", name); 252 Ok(()) 253 } 254 255 async fn star(args: RepoRefArgs) -> Result<()> { 256 + let mgr = SessionManager::default(); 257 + let session = mgr 258 + .load()? 259 + .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 260 + let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 261 + let pds = session 262 + .pds 263 + .clone() 264 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 265 + .unwrap_or_else(|| "https://bsky.social".into()); 266 + let pds_client = tangled_api::TangledClient::new(&pds); 267 + let info = pds_client 268 + .get_repo_info(owner, &name, Some(session.access_jwt.as_str())) 269 + .await?; 270 + let subject = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey); 271 + let api = tangled_api::TangledClient::default(); 272 + api.star_repo(&pds, &session.access_jwt, &subject, &session.did) 273 + .await?; 274 + println!("Starred {}/{}", owner, name); 275 Ok(()) 276 } 277 278 async fn unstar(args: RepoRefArgs) -> Result<()> { 279 + let mgr = SessionManager::default(); 280 + let session = mgr 281 + .load()? 282 + .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 283 + let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 284 + let pds = session 285 + .pds 286 + .clone() 287 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 288 + .unwrap_or_else(|| "https://bsky.social".into()); 289 + let pds_client = tangled_api::TangledClient::new(&pds); 290 + let info = pds_client 291 + .get_repo_info(owner, &name, Some(session.access_jwt.as_str())) 292 + .await?; 293 + let subject = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey); 294 + let api = tangled_api::TangledClient::default(); 295 + api.unstar_repo(&pds, &session.access_jwt, &subject, &session.did) 296 + .await?; 297 + println!("Unstarred {}/{}", owner, name); 298 Ok(()) 299 } 300 + 301 + fn parse_repo_ref<'a>(spec: &'a str, default_owner: &'a str) -> (&'a str, String) { 302 + if let Some((owner, name)) = spec.split_once('/') { 303 + (owner, name.to_string()) 304 + } else { 305 + (default_owner, spec.to_string()) 306 + } 307 + }