Rust CLI for tangled

Compare changes

Choose any two refs to compare.

Changed files
+534 -151
crates
+1
Cargo.lock
··· 2089 2089 version = "0.1.0" 2090 2090 dependencies = [ 2091 2091 "anyhow", 2092 + "chrono", 2092 2093 "clap", 2093 2094 "colored", 2094 2095 "dialoguer",
+87
crates/tangled-api/src/client.rs
··· 149 149 }) 150 150 } 151 151 152 + pub async fn refresh_session(&self, refresh_jwt: &str) -> Result<Session> { 153 + #[derive(Deserialize)] 154 + struct Res { 155 + #[serde(rename = "accessJwt")] 156 + access_jwt: String, 157 + #[serde(rename = "refreshJwt")] 158 + refresh_jwt: String, 159 + did: String, 160 + handle: String, 161 + } 162 + let url = self.xrpc_url("com.atproto.server.refreshSession"); 163 + let client = reqwest::Client::new(); 164 + let res = client 165 + .post(url) 166 + .header(reqwest::header::AUTHORIZATION, format!("Bearer {}", refresh_jwt)) 167 + .send() 168 + .await?; 169 + let status = res.status(); 170 + if !status.is_success() { 171 + let body = res.text().await.unwrap_or_default(); 172 + return Err(anyhow!("{}: {}", status, body)); 173 + } 174 + let res_data: Res = res.json().await?; 175 + Ok(Session { 176 + access_jwt: res_data.access_jwt, 177 + refresh_jwt: res_data.refresh_jwt, 178 + did: res_data.did, 179 + handle: res_data.handle, 180 + ..Default::default() 181 + }) 182 + } 183 + 152 184 pub async fn list_repos( 153 185 &self, 154 186 user: Option<&str>, ··· 1205 1237 Ok(()) 1206 1238 } 1207 1239 1240 + pub async fn merge_check( 1241 + &self, 1242 + repo_did: &str, 1243 + repo_name: &str, 1244 + branch: &str, 1245 + patch: &str, 1246 + pds_base: &str, 1247 + access_jwt: &str, 1248 + ) -> Result<MergeCheckResponse> { 1249 + let sa = self.service_auth_token(pds_base, access_jwt).await?; 1250 + 1251 + let req = MergeCheckRequest { 1252 + did: repo_did.to_string(), 1253 + name: repo_name.to_string(), 1254 + branch: branch.to_string(), 1255 + patch: patch.to_string(), 1256 + }; 1257 + 1258 + self.post_json("sh.tangled.repo.mergeCheck", &req, Some(&sa)) 1259 + .await 1260 + } 1261 + 1208 1262 pub async fn update_repo_spindle( 1209 1263 &self, 1210 1264 did: &str, ··· 1341 1395 pub patch: String, 1342 1396 #[serde(rename = "createdAt")] 1343 1397 pub created_at: String, 1398 + // Stack support fields 1399 + #[serde(skip_serializing_if = "Option::is_none")] 1400 + pub stack_id: Option<String>, 1401 + #[serde(skip_serializing_if = "Option::is_none")] 1402 + pub change_id: Option<String>, 1403 + #[serde(skip_serializing_if = "Option::is_none")] 1404 + pub parent_change_id: Option<String>, 1344 1405 } 1345 1406 1346 1407 #[derive(Debug, Clone)] ··· 1348 1409 pub author_did: String, 1349 1410 pub rkey: String, 1350 1411 pub pull: Pull, 1412 + } 1413 + 1414 + // Merge check types for stacked diff conflict detection 1415 + #[derive(Debug, Clone, Serialize, Deserialize)] 1416 + pub struct MergeCheckRequest { 1417 + pub did: String, 1418 + pub name: String, 1419 + pub branch: String, 1420 + pub patch: String, 1421 + } 1422 + 1423 + #[derive(Debug, Clone, Serialize, Deserialize)] 1424 + pub struct MergeCheckResponse { 1425 + pub is_conflicted: bool, 1426 + #[serde(default)] 1427 + pub conflicts: Vec<ConflictInfo>, 1428 + #[serde(skip_serializing_if = "Option::is_none")] 1429 + pub message: Option<String>, 1430 + #[serde(skip_serializing_if = "Option::is_none")] 1431 + pub error: Option<String>, 1432 + } 1433 + 1434 + #[derive(Debug, Clone, Serialize, Deserialize)] 1435 + pub struct ConflictInfo { 1436 + pub filename: String, 1437 + pub reason: String, 1351 1438 } 1352 1439 1353 1440 #[derive(Debug, Clone)]
+2 -2
crates/tangled-api/src/lib.rs
··· 2 2 3 3 pub use client::TangledClient; 4 4 pub use client::{ 5 - CreateRepoOptions, DefaultBranch, Issue, IssueRecord, Language, Languages, Pull, PullRecord, 6 - RepoRecord, Repository, Secret, 5 + ConflictInfo, CreateRepoOptions, DefaultBranch, Issue, IssueRecord, Language, Languages, 6 + MergeCheckRequest, MergeCheckResponse, Pull, PullRecord, RepoRecord, Repository, Secret, 7 7 };
+1
crates/tangled-cli/Cargo.toml
··· 18 18 url = { workspace = true } 19 19 tokio-tungstenite = { workspace = true } 20 20 futures-util = { workspace = true } 21 + chrono = { workspace = true } 21 22 22 23 # Internal crates 23 24 tangled-config = { path = "../tangled-config" }
+1 -1
crates/tangled-cli/src/cli.rs
··· 381 381 /// Secret key 382 382 #[arg(long)] 383 383 pub key: String, 384 - /// Secret value 384 + /// Secret value (use '@filename' to read from file, '-' to read from stdin) 385 385 #[arg(long)] 386 386 pub value: String, 387 387 }
+5 -21
crates/tangled-cli/src/commands/issue.rs
··· 4 4 }; 5 5 use anyhow::{anyhow, Result}; 6 6 use tangled_api::Issue; 7 - use tangled_config::session::SessionManager; 8 7 9 8 pub async fn run(_cli: &Cli, cmd: IssueCommand) -> Result<()> { 10 9 match cmd { ··· 17 16 } 18 17 19 18 async fn list(args: IssueListArgs) -> Result<()> { 20 - let mgr = SessionManager::default(); 21 - let session = mgr 22 - .load()? 23 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 19 + let session = crate::util::load_session_with_refresh().await?; 24 20 let pds = session 25 21 .pds 26 22 .clone() ··· 57 53 } 58 54 59 55 async fn create(args: IssueCreateArgs) -> Result<()> { 60 - let mgr = SessionManager::default(); 61 - let session = mgr 62 - .load()? 63 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 56 + let session = crate::util::load_session_with_refresh().await?; 64 57 let pds = session 65 58 .pds 66 59 .clone() ··· 97 90 98 91 async fn show(args: IssueShowArgs) -> Result<()> { 99 92 // For now, show only accepts at-uri or did:rkey or rkey (for your DID) 100 - let mgr = SessionManager::default(); 101 - let session = mgr 102 - .load()? 103 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 93 + let session = crate::util::load_session_with_refresh().await?; 104 94 let id = args.id; 105 95 let (did, rkey) = parse_record_id(&id, &session.did)?; 106 96 let pds = session ··· 129 119 130 120 async fn edit(args: IssueEditArgs) -> Result<()> { 131 121 // Simple edit: fetch existing record and putRecord with new title/body 132 - let mgr = SessionManager::default(); 133 - let session = mgr 134 - .load()? 135 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 122 + let session = crate::util::load_session_with_refresh().await?; 136 123 let (did, rkey) = parse_record_id(&args.id, &session.did)?; 137 124 let pds = session 138 125 .pds ··· 183 170 } 184 171 185 172 async fn comment(args: IssueCommentArgs) -> Result<()> { 186 - let mgr = SessionManager::default(); 187 - let session = mgr 188 - .load()? 189 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 173 + let session = crate::util::load_session_with_refresh().await?; 190 174 let (did, rkey) = parse_record_id(&args.id, &session.did)?; 191 175 let pds = session 192 176 .pds
+1 -5
crates/tangled-cli/src/commands/knot.rs
··· 3 3 use anyhow::Result; 4 4 use git2::{Direction, Repository as GitRepository, StatusOptions}; 5 5 use std::path::Path; 6 - use tangled_config::session::SessionManager; 7 6 8 7 pub async fn run(_cli: &Cli, cmd: KnotCommand) -> Result<()> { 9 8 match cmd { ··· 12 11 } 13 12 14 13 async fn migrate(args: KnotMigrateArgs) -> Result<()> { 15 - let mgr = SessionManager::default(); 16 - let session = mgr 17 - .load()? 18 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 14 + let session = crate::util::load_session_with_refresh().await?; 19 15 // 1) Ensure we're inside a git repository and working tree is clean 20 16 let repo = GitRepository::discover(Path::new("."))?; 21 17 let mut status_opts = StatusOptions::new();
+341 -65
crates/tangled-cli/src/commands/pr.rs
··· 2 2 use anyhow::{anyhow, Result}; 3 3 use std::path::Path; 4 4 use std::process::Command; 5 - use tangled_config::session::SessionManager; 6 5 7 6 pub async fn run(_cli: &Cli, cmd: PrCommand) -> Result<()> { 8 7 match cmd { ··· 15 14 } 16 15 17 16 async fn list(args: PrListArgs) -> Result<()> { 18 - let mgr = SessionManager::default(); 19 - let session = mgr 20 - .load()? 21 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 17 + let session = crate::util::load_session_with_refresh().await?; 22 18 let pds = session 23 19 .pds 24 20 .clone() ··· 54 50 55 51 async fn create(args: PrCreateArgs) -> Result<()> { 56 52 // Must be run inside the repo checkout; we will use git format-patch to build the patch 57 - let mgr = SessionManager::default(); 58 - let session = mgr 59 - .load()? 60 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 53 + let session = crate::util::load_session_with_refresh().await?; 61 54 let pds = session 62 55 .pds 63 56 .clone() ··· 126 119 } 127 120 128 121 async fn show(args: PrShowArgs) -> Result<()> { 129 - let mgr = SessionManager::default(); 130 - let session = mgr 131 - .load()? 132 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 122 + let session = crate::util::load_session_with_refresh().await?; 133 123 let (did, rkey) = parse_record_id(&args.id, &session.did)?; 134 124 let pds = session 135 125 .pds ··· 152 142 } 153 143 154 144 async fn review(args: PrReviewArgs) -> Result<()> { 155 - let mgr = SessionManager::default(); 156 - let session = mgr 157 - .load()? 158 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 145 + let session = crate::util::load_session_with_refresh().await?; 159 146 let (did, rkey) = parse_record_id(&args.id, &session.did)?; 160 147 let pds = session 161 148 .pds ··· 184 171 } 185 172 186 173 async fn merge(args: PrMergeArgs) -> Result<()> { 187 - let mgr = SessionManager::default(); 188 - let session = mgr 189 - .load()? 190 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 174 + let session = crate::util::load_session_with_refresh().await?; 191 175 let (did, rkey) = parse_record_id(&args.id, &session.did)?; 192 176 let pds = session 193 177 .pds ··· 195 179 .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 196 180 .unwrap_or_else(|| "https://bsky.social".into()); 197 181 198 - // Get the PR to find the target repo 182 + // Get the PR 199 183 let pds_client = tangled_api::TangledClient::new(&pds); 200 184 let pull = pds_client 201 185 .get_pull_record(&did, &rkey, Some(session.access_jwt.as_str())) 202 186 .await?; 203 187 204 - // Parse the target repo AT-URI to get did and name 205 - let target_repo = &pull.target.repo; 206 - // Format: at://did:plc:.../sh.tangled.repo/rkey 207 - let parts: Vec<&str> = target_repo.strip_prefix("at://").unwrap_or(target_repo).split('/').collect(); 208 - if parts.len() < 2 { 209 - return Err(anyhow!("Invalid target repo AT-URI: {}", target_repo)); 210 - } 211 - let repo_did = parts[0]; 188 + // Parse target repo info 189 + let (repo_did, repo_name) = parse_target_repo_info(&pull, &pds_client, &session).await?; 212 190 213 - // Get repo info to find the name 214 - // Parse rkey from target repo AT-URI 215 - let repo_rkey = if parts.len() >= 4 { 216 - parts[3] 191 + // Check if PR is part of a stack 192 + if let Some(stack_id) = &pull.stack_id { 193 + merge_stacked_pr( 194 + &pds_client, 195 + &session, 196 + &pull, 197 + &did, 198 + &rkey, 199 + &repo_did, 200 + &repo_name, 201 + stack_id, 202 + &pds, 203 + ) 204 + .await?; 217 205 } else { 218 - return Err(anyhow!("Invalid target repo AT-URI: {}", target_repo)); 219 - }; 220 - 221 - #[derive(serde::Deserialize)] 222 - struct Rec { 223 - name: String, 224 - } 225 - #[derive(serde::Deserialize)] 226 - struct GetRes { 227 - value: Rec, 206 + // Single PR merge (existing logic) 207 + merge_single_pr(&session, &did, &rkey, &repo_did, &repo_name, &pds).await?; 228 208 } 229 - let params = [ 230 - ("repo", repo_did.to_string()), 231 - ("collection", "sh.tangled.repo".to_string()), 232 - ("rkey", repo_rkey.to_string()), 233 - ]; 234 - let repo_rec: GetRes = pds_client 235 - .get_json("com.atproto.repo.getRecord", &params, Some(session.access_jwt.as_str())) 236 - .await?; 237 209 238 - // Call merge on the default Tangled API base (tngl.sh) 239 - let api = tangled_api::TangledClient::default(); 240 - api.merge_pull( 241 - &did, 242 - &rkey, 243 - repo_did, 244 - &repo_rec.value.name, 245 - &pds, 246 - &session.access_jwt, 247 - ) 248 - .await?; 249 - 250 - println!("Merged PR {}:{}", did, rkey); 251 210 Ok(()) 252 211 } 253 212 ··· 275 234 } 276 235 Ok((default_did.to_string(), id.to_string())) 277 236 } 237 + 238 + // Helper functions for stacked PR merge support 239 + 240 + async fn merge_single_pr( 241 + session: &tangled_config::session::Session, 242 + did: &str, 243 + rkey: &str, 244 + repo_did: &str, 245 + repo_name: &str, 246 + pds: &str, 247 + ) -> Result<()> { 248 + let api = tangled_api::TangledClient::default(); 249 + api.merge_pull(did, rkey, repo_did, repo_name, pds, &session.access_jwt) 250 + .await?; 251 + 252 + println!("Merged PR {}:{}", did, rkey); 253 + Ok(()) 254 + } 255 + 256 + async fn merge_stacked_pr( 257 + pds_client: &tangled_api::TangledClient, 258 + session: &tangled_config::session::Session, 259 + current_pull: &tangled_api::Pull, 260 + current_did: &str, 261 + current_rkey: &str, 262 + repo_did: &str, 263 + repo_name: &str, 264 + stack_id: &str, 265 + pds: &str, 266 + ) -> Result<()> { 267 + // Step 1: Get full stack 268 + println!("๐Ÿ” Detecting stack..."); 269 + let stack = get_stack_pulls(pds_client, &session.did, stack_id, &session.access_jwt).await?; 270 + 271 + if stack.is_empty() { 272 + return Err(anyhow!("Stack is empty")); 273 + } 274 + 275 + // Step 2: Find substack (current PR and all below it) 276 + let substack = find_substack(&stack, current_pull.change_id.as_deref())?; 277 + 278 + println!( 279 + "โœ“ Detected PR is part of stack (stack has {} total PRs)", 280 + stack.len() 281 + ); 282 + println!(); 283 + println!("The following {} PR(s) will be merged:", substack.len()); 284 + 285 + for (idx, pr) in substack.iter().enumerate() { 286 + let marker = if pr.rkey == current_rkey { 287 + " (current)" 288 + } else { 289 + "" 290 + }; 291 + println!(" [{}] {}: {}{}", idx + 1, pr.rkey, pr.pull.title, marker); 292 + } 293 + println!(); 294 + 295 + // Step 3: Check for conflicts 296 + println!("โœ“ Checking for conflicts..."); 297 + let api = tangled_api::TangledClient::default(); 298 + let conflicts = check_stack_conflicts( 299 + &api, 300 + repo_did, 301 + repo_name, 302 + &current_pull.target.branch, 303 + &substack, 304 + pds, 305 + &session.access_jwt, 306 + ) 307 + .await?; 308 + 309 + if !conflicts.is_empty() { 310 + println!("โœ— Cannot merge: conflicts detected"); 311 + println!(); 312 + for (pr_rkey, conflict_resp) in conflicts { 313 + println!( 314 + " PR {}: Conflicts in {} file(s)", 315 + pr_rkey, 316 + conflict_resp.conflicts.len() 317 + ); 318 + for conflict in conflict_resp.conflicts { 319 + println!(" - {}: {}", conflict.filename, conflict.reason); 320 + } 321 + } 322 + return Err(anyhow!("Stack has merge conflicts")); 323 + } 324 + 325 + println!("โœ“ All PRs can be merged cleanly"); 326 + println!(); 327 + 328 + // Step 4: Confirmation prompt 329 + if !prompt_confirmation(&format!("Merge {} pull request(s)?", substack.len()))? { 330 + println!("Merge cancelled."); 331 + return Ok(()); 332 + } 333 + 334 + // Step 5: Merge the stack (backend handles combined patch) 335 + println!("Merging {} PR(s)...", substack.len()); 336 + 337 + // Use the current PR's merge endpoint - backend will handle the stack 338 + api.merge_pull( 339 + current_did, 340 + current_rkey, 341 + repo_did, 342 + repo_name, 343 + pds, 344 + &session.access_jwt, 345 + ) 346 + .await?; 347 + 348 + println!("โœ“ Successfully merged {} pull request(s)", substack.len()); 349 + 350 + Ok(()) 351 + } 352 + 353 + async fn get_stack_pulls( 354 + client: &tangled_api::TangledClient, 355 + user_did: &str, 356 + stack_id: &str, 357 + bearer: &str, 358 + ) -> Result<Vec<tangled_api::PullRecord>> { 359 + // List all user's PRs and filter by stack_id 360 + let all_pulls = client.list_pulls(user_did, None, Some(bearer)).await?; 361 + 362 + let mut stack_pulls: Vec<_> = all_pulls 363 + .into_iter() 364 + .filter(|p| p.pull.stack_id.as_deref() == Some(stack_id)) 365 + .collect(); 366 + 367 + // Order by parent relationships (top to bottom) 368 + order_stack(&mut stack_pulls)?; 369 + 370 + Ok(stack_pulls) 371 + } 372 + 373 + fn order_stack(pulls: &mut Vec<tangled_api::PullRecord>) -> Result<()> { 374 + if pulls.is_empty() { 375 + return Ok(()); 376 + } 377 + 378 + // Build parent map: parent_change_id -> pull 379 + let mut change_id_map: std::collections::HashMap<String, usize> = 380 + std::collections::HashMap::new(); 381 + let mut parent_map: std::collections::HashMap<String, usize> = 382 + std::collections::HashMap::new(); 383 + 384 + for (idx, pr) in pulls.iter().enumerate() { 385 + if let Some(cid) = &pr.pull.change_id { 386 + change_id_map.insert(cid.clone(), idx); 387 + } 388 + if let Some(pcid) = &pr.pull.parent_change_id { 389 + parent_map.insert(pcid.clone(), idx); 390 + } 391 + } 392 + 393 + // Find top of stack (not a parent of any other PR) 394 + let mut top_idx = None; 395 + for (idx, pr) in pulls.iter().enumerate() { 396 + if let Some(cid) = &pr.pull.change_id { 397 + if !parent_map.contains_key(cid) { 398 + top_idx = Some(idx); 399 + break; 400 + } 401 + } 402 + } 403 + 404 + let top_idx = top_idx.ok_or_else(|| anyhow!("Could not find top of stack"))?; 405 + 406 + // Walk down the stack to build ordered list 407 + let mut ordered = Vec::new(); 408 + let mut current_idx = top_idx; 409 + let mut visited = std::collections::HashSet::new(); 410 + 411 + loop { 412 + if visited.contains(&current_idx) { 413 + return Err(anyhow!("Circular dependency in stack")); 414 + } 415 + visited.insert(current_idx); 416 + ordered.push(current_idx); 417 + 418 + // Find child (PR that has this PR as parent) 419 + let current_parent = &pulls[current_idx].pull.parent_change_id; 420 + if current_parent.is_none() { 421 + break; 422 + } 423 + 424 + let next_idx = change_id_map.get(current_parent.as_ref().unwrap()); 425 + 426 + if let Some(&next) = next_idx { 427 + current_idx = next; 428 + } else { 429 + break; 430 + } 431 + } 432 + 433 + // Reorder pulls based on ordered indices 434 + let original = pulls.clone(); 435 + pulls.clear(); 436 + for idx in ordered { 437 + pulls.push(original[idx].clone()); 438 + } 439 + 440 + Ok(()) 441 + } 442 + 443 + fn find_substack<'a>( 444 + stack: &'a [tangled_api::PullRecord], 445 + current_change_id: Option<&str>, 446 + ) -> Result<Vec<&'a tangled_api::PullRecord>> { 447 + let change_id = current_change_id.ok_or_else(|| anyhow!("PR has no change_id"))?; 448 + 449 + let position = stack 450 + .iter() 451 + .position(|p| p.pull.change_id.as_deref() == Some(change_id)) 452 + .ok_or_else(|| anyhow!("PR not found in stack"))?; 453 + 454 + // Return from current position to end (including current) 455 + Ok(stack[position..].iter().collect()) 456 + } 457 + 458 + async fn check_stack_conflicts( 459 + api: &tangled_api::TangledClient, 460 + repo_did: &str, 461 + repo_name: &str, 462 + target_branch: &str, 463 + substack: &[&tangled_api::PullRecord], 464 + pds: &str, 465 + access_jwt: &str, 466 + ) -> Result<Vec<(String, tangled_api::MergeCheckResponse)>> { 467 + let mut conflicts = Vec::new(); 468 + let mut cumulative_patch = String::new(); 469 + 470 + // Check each PR in order (bottom to top of substack) 471 + for pr in substack.iter().rev() { 472 + cumulative_patch.push_str(&pr.pull.patch); 473 + cumulative_patch.push('\n'); 474 + 475 + let check = api 476 + .merge_check( 477 + repo_did, 478 + repo_name, 479 + target_branch, 480 + &cumulative_patch, 481 + pds, 482 + access_jwt, 483 + ) 484 + .await?; 485 + 486 + if check.is_conflicted { 487 + conflicts.push((pr.rkey.clone(), check)); 488 + } 489 + } 490 + 491 + Ok(conflicts) 492 + } 493 + 494 + fn prompt_confirmation(message: &str) -> Result<bool> { 495 + use std::io::{self, Write}; 496 + 497 + print!("{} [y/N]: ", message); 498 + io::stdout().flush()?; 499 + 500 + let mut input = String::new(); 501 + io::stdin().read_line(&mut input)?; 502 + 503 + Ok(matches!( 504 + input.trim().to_lowercase().as_str(), 505 + "y" | "yes" 506 + )) 507 + } 508 + 509 + async fn parse_target_repo_info( 510 + pull: &tangled_api::Pull, 511 + pds_client: &tangled_api::TangledClient, 512 + session: &tangled_config::session::Session, 513 + ) -> Result<(String, String)> { 514 + let target_repo = &pull.target.repo; 515 + let parts: Vec<&str> = target_repo 516 + .strip_prefix("at://") 517 + .unwrap_or(target_repo) 518 + .split('/') 519 + .collect(); 520 + 521 + if parts.len() < 4 { 522 + return Err(anyhow!("Invalid target repo AT-URI: {}", target_repo)); 523 + } 524 + 525 + let repo_did = parts[0].to_string(); 526 + let repo_rkey = parts[3]; 527 + 528 + // Get repo name 529 + #[derive(serde::Deserialize)] 530 + struct Rec { 531 + name: String, 532 + } 533 + #[derive(serde::Deserialize)] 534 + struct GetRes { 535 + value: Rec, 536 + } 537 + 538 + let params = [ 539 + ("repo", repo_did.clone()), 540 + ("collection", "sh.tangled.repo".to_string()), 541 + ("rkey", repo_rkey.to_string()), 542 + ]; 543 + 544 + let repo_rec: GetRes = pds_client 545 + .get_json( 546 + "com.atproto.repo.getRecord", 547 + &params, 548 + Some(&session.access_jwt), 549 + ) 550 + .await?; 551 + 552 + Ok((repo_did, repo_rec.value.name)) 553 + }
+7 -31
crates/tangled-cli/src/commands/repo.rs
··· 2 2 use git2::{build::RepoBuilder, Cred, FetchOptions, RemoteCallbacks}; 3 3 use serde_json; 4 4 use std::path::PathBuf; 5 - use tangled_config::session::SessionManager; 6 5 7 6 use crate::cli::{ 8 7 Cli, OutputFormat, RepoCloneArgs, RepoCommand, RepoCreateArgs, RepoDeleteArgs, RepoInfoArgs, ··· 22 21 } 23 22 24 23 async fn list(cli: &Cli, args: RepoListArgs) -> Result<()> { 25 - let mgr = SessionManager::default(); 26 - let session = match mgr.load()? { 27 - Some(s) => s, 28 - None => return Err(anyhow!("Please login first: tangled auth login")), 29 - }; 24 + let session = crate::util::load_session_with_refresh().await?; 30 25 31 26 // Use the PDS to list repo records for the user 32 27 let pds = session ··· 63 58 } 64 59 65 60 async fn create(args: RepoCreateArgs) -> Result<()> { 66 - let mgr = SessionManager::default(); 67 - let session = match mgr.load()? { 68 - Some(s) => s, 69 - None => return Err(anyhow!("Please login first: tangled auth login")), 70 - }; 61 + let session = crate::util::load_session_with_refresh().await?; 71 62 72 63 let base = std::env::var("TANGLED_API_BASE").unwrap_or_else(|_| "https://tngl.sh".into()); 73 64 let client = tangled_api::TangledClient::new(base); ··· 97 88 } 98 89 99 90 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"))?; 91 + let session = crate::util::load_session_with_refresh().await?; 104 92 105 93 let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 106 94 let pds = session ··· 164 152 } 165 153 166 154 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"))?; 155 + let session = crate::util::load_session_with_refresh().await?; 171 156 let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 172 157 let pds = session 173 158 .pds ··· 235 220 } 236 221 237 222 async fn delete(args: RepoDeleteArgs) -> Result<()> { 238 - let mgr = SessionManager::default(); 239 - let session = mgr 240 - .load()? 241 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 223 + let session = crate::util::load_session_with_refresh().await?; 242 224 let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 243 225 let pds = session 244 226 .pds ··· 258 240 } 259 241 260 242 async fn star(args: RepoRefArgs) -> Result<()> { 261 - let mgr = SessionManager::default(); 262 - let session = mgr 263 - .load()? 264 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 243 + let session = crate::util::load_session_with_refresh().await?; 265 244 let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 266 245 let pds = session 267 246 .pds ··· 281 260 } 282 261 283 262 async fn unstar(args: RepoRefArgs) -> Result<()> { 284 - let mgr = SessionManager::default(); 285 - let session = mgr 286 - .load()? 287 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 263 + let session = crate::util::load_session_with_refresh().await?; 288 264 let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 289 265 let pds = session 290 266 .pds
+32 -26
crates/tangled-cli/src/commands/spindle.rs
··· 4 4 }; 5 5 use anyhow::{anyhow, Result}; 6 6 use futures_util::StreamExt; 7 - use tangled_config::session::SessionManager; 8 7 use tokio_tungstenite::{connect_async, tungstenite::Message}; 9 8 10 9 pub async fn run(_cli: &Cli, cmd: SpindleCommand) -> Result<()> { ··· 18 17 } 19 18 20 19 async fn list(args: SpindleListArgs) -> Result<()> { 21 - let mgr = SessionManager::default(); 22 - let session = mgr 23 - .load()? 24 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 20 + let session = crate::util::load_session_with_refresh().await?; 25 21 26 22 let pds = session 27 23 .pds ··· 65 61 } 66 62 67 63 async fn config(args: SpindleConfigArgs) -> Result<()> { 68 - let mgr = SessionManager::default(); 69 - let session = mgr 70 - .load()? 71 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 64 + let session = crate::util::load_session_with_refresh().await?; 72 65 73 66 if args.enable && args.disable { 74 67 return Err(anyhow!("Cannot use --enable and --disable together")); ··· 138 131 (parts[0].to_string(), parts[1].to_string(), parts[2].to_string()) 139 132 } else if parts.len() == 1 { 140 133 // 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"))?; 134 + let session = crate::util::load_session_with_refresh().await?; 145 135 let pds = session 146 136 .pds 147 137 .clone() ··· 205 195 } 206 196 207 197 async fn secret_list(args: SpindleSecretListArgs) -> Result<()> { 208 - let mgr = SessionManager::default(); 209 - let session = mgr 210 - .load()? 211 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 198 + let session = crate::util::load_session_with_refresh().await?; 212 199 let pds = session 213 200 .pds 214 201 .clone() ··· 243 230 } 244 231 245 232 async fn secret_add(args: SpindleSecretAddArgs) -> Result<()> { 246 - let mgr = SessionManager::default(); 247 - let session = mgr 248 - .load()? 249 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 233 + let session = crate::util::load_session_with_refresh().await?; 250 234 let pds = session 251 235 .pds 252 236 .clone() ··· 266 250 .unwrap_or_else(|| "https://spindle.tangled.sh".to_string()); 267 251 let api = tangled_api::TangledClient::new(&spindle_base); 268 252 269 - api.add_repo_secret(&pds, &session.access_jwt, &repo_at, &args.key, &args.value) 253 + // Handle special value patterns: @file or - (stdin) 254 + let value = if args.value == "-" { 255 + // Read from stdin 256 + use std::io::Read; 257 + let mut buffer = String::new(); 258 + std::io::stdin().read_to_string(&mut buffer)?; 259 + buffer 260 + } else if let Some(path) = args.value.strip_prefix('@') { 261 + // Read from file, expand ~ if needed 262 + let expanded_path = if path.starts_with("~/") { 263 + if let Some(home) = std::env::var("HOME").ok() { 264 + path.replacen("~/", &format!("{}/", home), 1) 265 + } else { 266 + path.to_string() 267 + } 268 + } else { 269 + path.to_string() 270 + }; 271 + std::fs::read_to_string(&expanded_path) 272 + .map_err(|e| anyhow!("Failed to read file '{}': {}", expanded_path, e))? 273 + } else { 274 + // Use value as-is 275 + args.value 276 + }; 277 + 278 + api.add_repo_secret(&pds, &session.access_jwt, &repo_at, &args.key, &value) 270 279 .await?; 271 280 println!("Added secret '{}' to {}", args.key, args.repo); 272 281 Ok(()) 273 282 } 274 283 275 284 async fn secret_remove(args: SpindleSecretRemoveArgs) -> Result<()> { 276 - let mgr = SessionManager::default(); 277 - let session = mgr 278 - .load()? 279 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 285 + let session = crate::util::load_session_with_refresh().await?; 280 286 let pds = session 281 287 .pds 282 288 .clone()
+1
crates/tangled-cli/src/main.rs
··· 1 1 mod cli; 2 2 mod commands; 3 + mod util; 3 4 4 5 use anyhow::Result; 5 6 use clap::Parser;
+55
crates/tangled-cli/src/util.rs
··· 1 + use anyhow::{anyhow, Result}; 2 + use tangled_config::session::{Session, SessionManager}; 3 + 4 + /// Load session and automatically refresh if expired 5 + pub async fn load_session() -> Result<Session> { 6 + let mgr = SessionManager::default(); 7 + let session = mgr 8 + .load()? 9 + .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 10 + 11 + Ok(session) 12 + } 13 + 14 + /// Refresh the session using the refresh token 15 + pub async fn refresh_session(session: &Session) -> Result<Session> { 16 + let pds = session 17 + .pds 18 + .clone() 19 + .unwrap_or_else(|| "https://bsky.social".to_string()); 20 + 21 + let client = tangled_api::TangledClient::new(&pds); 22 + let mut new_session = client.refresh_session(&session.refresh_jwt).await?; 23 + 24 + // Preserve PDS from old session 25 + new_session.pds = session.pds.clone(); 26 + 27 + // Save the refreshed session 28 + let mgr = SessionManager::default(); 29 + mgr.save(&new_session)?; 30 + 31 + Ok(new_session) 32 + } 33 + 34 + /// Load session with automatic refresh on ExpiredToken 35 + pub async fn load_session_with_refresh() -> Result<Session> { 36 + let session = load_session().await?; 37 + 38 + // Check if session is older than 30 minutes - if so, proactively refresh 39 + let age = chrono::Utc::now() 40 + .signed_duration_since(session.created_at) 41 + .num_minutes(); 42 + 43 + if age > 30 { 44 + // Session is old, proactively refresh 45 + match refresh_session(&session).await { 46 + Ok(new_session) => return Ok(new_session), 47 + Err(_) => { 48 + // If refresh fails, try with the old session anyway 49 + // It might still work 50 + } 51 + } 52 + } 53 + 54 + Ok(session) 55 + }