Rust CLI for tangled

Fix repo list, starts repo create

Changed files
+379 -103
crates
+1
Cargo.lock
··· 1981 1981 "anyhow", 1982 1982 "atrium-api", 1983 1983 "atrium-xrpc-client", 1984 + "chrono", 1984 1985 "reqwest", 1985 1986 "serde", 1986 1987 "serde_json",
+1 -1
crates/tangled-api/Cargo.toml
··· 11 11 serde_json = { workspace = true } 12 12 reqwest = { workspace = true } 13 13 tokio = { workspace = true, features = ["full"] } 14 + chrono = { workspace = true } 14 15 15 16 # Optionally depend on ATrium (wired later as endpoints solidify) 16 17 atrium-api = { workspace = true, optional = true } ··· 21 22 [features] 22 23 default = [] 23 24 atrium = ["dep:atrium-api", "dep:atrium-xrpc-client"] 24 -
+184 -32
crates/tangled-api/src/client.rs
··· 7 7 base_url: String, 8 8 } 9 9 10 + const REPO_CREATE: &str = "sh.tangled.repo.create"; 11 + 12 + impl Default for TangledClient { 13 + fn default() -> Self { 14 + Self::new("https://tngl.sh") 15 + } 16 + } 17 + 10 18 impl TangledClient { 11 19 pub fn new(base_url: impl Into<String>) -> Self { 12 - Self { base_url: base_url.into() } 13 - } 14 - 15 - pub fn default() -> Self { 16 - Self::new("https://tangled.org") 20 + Self { 21 + base_url: base_url.into(), 22 + } 17 23 } 18 24 19 25 fn xrpc_url(&self, method: &str) -> String { 20 - format!( 21 - "{}/xrpc/{}", 22 - self.base_url.trim_end_matches('/'), 23 - method 24 - ) 26 + format!("{}/xrpc/{}", self.base_url.trim_end_matches('/'), method) 25 27 } 26 28 27 29 async fn post_json<TReq: Serialize, TRes: DeserializeOwned>( ··· 36 38 .post(url) 37 39 .header(reqwest::header::CONTENT_TYPE, "application/json"); 38 40 if let Some(token) = bearer { 39 - reqb = reqb.header( 40 - reqwest::header::AUTHORIZATION, 41 - format!("Bearer {}", token), 42 - ); 41 + reqb = reqb.header(reqwest::header::AUTHORIZATION, format!("Bearer {}", token)); 43 42 } 44 43 let res = reqb.json(req).send().await?; 45 44 let status = res.status(); ··· 58 57 ) -> Result<TRes> { 59 58 let url = self.xrpc_url(method); 60 59 let client = reqwest::Client::new(); 61 - let mut reqb = client.get(url).query(&params); 60 + let mut reqb = client 61 + .get(&url) 62 + .query(&params) 63 + .header(reqwest::header::ACCEPT, "application/json"); 62 64 if let Some(token) = bearer { 63 - reqb = reqb.header( 64 - reqwest::header::AUTHORIZATION, 65 - format!("Bearer {}", token), 66 - ); 65 + reqb = reqb.header(reqwest::header::AUTHORIZATION, format!("Bearer {}", token)); 67 66 } 68 67 let res = reqb.send().await?; 69 68 let status = res.status(); 69 + let body = res.text().await.unwrap_or_default(); 70 70 if !status.is_success() { 71 - let body = res.text().await.unwrap_or_default(); 72 - return Err(anyhow!("{}: {}", status, body)); 71 + return Err(anyhow!("GET {} -> {}: {}", url, status, body)); 73 72 } 74 - Ok(res.json::<TRes>().await?) 73 + serde_json::from_str::<TRes>(&body).map_err(|e| { 74 + let snippet = body.chars().take(300).collect::<String>(); 75 + anyhow!( 76 + "error decoding response from {}: {}\nBody (first 300 chars): {}", 77 + url, 78 + e, 79 + snippet 80 + ) 81 + }) 75 82 } 76 83 77 84 pub async fn login_with_password( ··· 119 126 starred: bool, 120 127 bearer: Option<&str>, 121 128 ) -> Result<Vec<Repository>> { 129 + // NOTE: Repo listing is done via the user's PDS using com.atproto.repo.listRecords 130 + // for the collection "sh.tangled.repo". This does not go through the Tangled API base. 131 + // Here, `self.base_url` must be the PDS base (e.g., https://bsky.social). 132 + // Resolve handle to DID if needed 133 + let did = match user { 134 + Some(u) if u.starts_with("did:") => u.to_string(), 135 + Some(handle) => { 136 + #[derive(Deserialize)] 137 + struct Res { 138 + did: String, 139 + } 140 + let params = [("handle", handle.to_string())]; 141 + let res: Res = self 142 + .get_json("com.atproto.identity.resolveHandle", &params, bearer) 143 + .await?; 144 + res.did 145 + } 146 + None => { 147 + return Err(anyhow!( 148 + "missing user for list_repos; provide handle or DID" 149 + )); 150 + } 151 + }; 152 + 122 153 #[derive(Deserialize)] 123 - struct Envelope { 124 - repos: Vec<Repository>, 154 + struct RecordItem { 155 + value: Repository, 125 156 } 126 - let mut q = vec![]; 127 - if let Some(u) = user { 128 - q.push(("user", u.to_string())); 157 + #[derive(Deserialize)] 158 + struct ListRes { 159 + #[serde(default)] 160 + records: Vec<RecordItem>, 129 161 } 162 + 163 + let params = vec![ 164 + ("repo", did), 165 + ("collection", "sh.tangled.repo".to_string()), 166 + ("limit", "100".to_string()), 167 + ]; 168 + 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 130 174 if let Some(k) = knot { 131 - q.push(("knot", k.to_string())); 175 + repos.retain(|r| r.knot.as_deref().unwrap_or("") == k); 132 176 } 133 177 if starred { 134 - q.push(("starred", true.to_string())); 178 + // TODO: implement starred filtering when API is available. For now, no-op. 135 179 } 136 - let env: Envelope = self 137 - .get_json("sh.tangled.repo.list", &q, bearer) 180 + Ok(repos) 181 + } 182 + 183 + pub async fn create_repo(&self, opts: CreateRepoOptions<'_>) -> Result<()> { 184 + // 1) Create the sh.tangled.repo record on the user's PDS 185 + #[derive(Serialize)] 186 + struct Record<'a> { 187 + name: &'a str, 188 + knot: &'a str, 189 + #[serde(skip_serializing_if = "Option::is_none")] 190 + description: Option<&'a str>, 191 + #[serde(rename = "createdAt")] 192 + created_at: String, 193 + } 194 + #[derive(Serialize)] 195 + struct CreateRecordReq<'a> { 196 + repo: &'a str, 197 + collection: &'a str, 198 + validate: bool, 199 + record: Record<'a>, 200 + } 201 + #[derive(Deserialize)] 202 + struct CreateRecordRes { 203 + uri: String, 204 + } 205 + 206 + let now = chrono::Utc::now().to_rfc3339(); 207 + let rec = Record { 208 + name: opts.name, 209 + knot: opts.knot, 210 + description: opts.description, 211 + created_at: now, 212 + }; 213 + let create_req = CreateRecordReq { 214 + repo: opts.did, 215 + collection: "sh.tangled.repo", 216 + validate: true, 217 + record: rec, 218 + }; 219 + 220 + let pds_client = TangledClient::new(opts.pds_base); 221 + let created: CreateRecordRes = pds_client 222 + .post_json( 223 + "com.atproto.repo.createRecord", 224 + &create_req, 225 + Some(opts.access_jwt), 226 + ) 138 227 .await?; 139 - Ok(env.repos) 228 + 229 + // Extract rkey from at-uri: at://did/collection/rkey 230 + let rkey = created 231 + .uri 232 + .rsplit('/') 233 + .next() 234 + .ok_or_else(|| anyhow!("failed to parse rkey from uri"))?; 235 + 236 + // 2) Obtain a service auth token for the Tangled server (aud = did:web:<host>) 237 + let host = self 238 + .base_url 239 + .trim_end_matches('/') 240 + .strip_prefix("https://") 241 + .or_else(|| self.base_url.trim_end_matches('/').strip_prefix("http://")) 242 + .ok_or_else(|| anyhow!("invalid base_url"))?; 243 + let audience = format!("did:web:{}", host); 244 + 245 + #[derive(Deserialize)] 246 + struct GetSARes { 247 + token: String, 248 + } 249 + let params = [ 250 + ("aud", audience), 251 + ("exp", (chrono::Utc::now().timestamp() + 600).to_string()), 252 + ]; 253 + let sa: GetSARes = pds_client 254 + .get_json( 255 + "com.atproto.server.getServiceAuth", 256 + &params, 257 + Some(opts.access_jwt), 258 + ) 259 + .await?; 260 + 261 + // 3) Call sh.tangled.repo.create with the rkey 262 + #[derive(Serialize)] 263 + struct CreateRepoReq<'a> { 264 + rkey: &'a str, 265 + #[serde(skip_serializing_if = "Option::is_none")] 266 + #[serde(rename = "defaultBranch")] 267 + default_branch: Option<&'a str>, 268 + #[serde(skip_serializing_if = "Option::is_none")] 269 + source: Option<&'a str>, 270 + } 271 + let req = CreateRepoReq { 272 + rkey, 273 + default_branch: opts.default_branch, 274 + source: opts.source, 275 + }; 276 + // No output expected on success 277 + let _: serde_json::Value = self.post_json(REPO_CREATE, &req, Some(&sa.token)).await?; 278 + Ok(()) 140 279 } 141 280 } 142 281 ··· 147 286 pub name: String, 148 287 pub knot: Option<String>, 149 288 pub description: Option<String>, 289 + #[serde(default)] 150 290 pub private: bool, 151 291 } 292 + 293 + #[derive(Debug, Clone)] 294 + pub struct CreateRepoOptions<'a> { 295 + pub did: &'a str, 296 + pub name: &'a str, 297 + pub knot: &'a str, 298 + pub description: Option<&'a str>, 299 + pub default_branch: Option<&'a str>, 300 + pub source: Option<&'a str>, 301 + pub pds_base: &'a str, 302 + pub access_jwt: &'a str, 303 + }
-1
crates/tangled-api/src/lib.rs
··· 1 1 pub mod client; 2 2 3 3 pub use client::TangledClient; 4 -
+3
crates/tangled-cli/src/cli.rs
··· 108 108 pub user: Option<String>, 109 109 #[arg(long, default_value_t = false)] 110 110 pub starred: bool, 111 + /// Tangled API base URL (overrides env) 112 + #[arg(long)] 113 + pub base: Option<String>, 111 114 } 112 115 113 116 #[derive(Args, Debug, Clone)]
+5 -4
crates/tangled-cli/src/commands/auth.rs
··· 21 21 Some(p) => p, 22 22 None => Password::new().with_prompt("Password").interact()?, 23 23 }; 24 - let pds = args.pds.unwrap_or_else(|| "https://bsky.social".to_string()); 24 + let pds = args 25 + .pds 26 + .unwrap_or_else(|| "https://bsky.social".to_string()); 25 27 26 28 let client = tangled_api::TangledClient::new(&pds); 27 - let session = client 28 - .login_with_password(&handle, &password, &pds) 29 - .await?; 29 + let mut session = client.login_with_password(&handle, &password, &pds).await?; 30 + session.pds = Some(pds.clone()); 30 31 SessionManager::default().save(&session)?; 31 32 println!("Logged in as '{}' ({})", session.handle, session.did); 32 33 Ok(())
+24 -10
crates/tangled-cli/src/commands/issue.rs
··· 1 + use crate::cli::{ 2 + Cli, IssueCommand, IssueCommentArgs, IssueCreateArgs, IssueEditArgs, IssueListArgs, 3 + IssueShowArgs, 4 + }; 1 5 use anyhow::Result; 2 - use crate::cli::{Cli, IssueCommand, IssueListArgs, IssueCreateArgs, IssueShowArgs, IssueEditArgs, IssueCommentArgs}; 3 6 4 7 pub async fn run(_cli: &Cli, cmd: IssueCommand) -> Result<()> { 5 8 match cmd { ··· 12 15 } 13 16 14 17 async fn list(args: IssueListArgs) -> Result<()> { 15 - println!("Issue list (stub) repo={:?} state={:?} author={:?} label={:?} assigned={:?}", 16 - args.repo, args.state, args.author, args.label, args.assigned); 18 + println!( 19 + "Issue list (stub) repo={:?} state={:?} author={:?} label={:?} assigned={:?}", 20 + args.repo, args.state, args.author, args.label, args.assigned 21 + ); 17 22 Ok(()) 18 23 } 19 24 20 25 async fn create(args: IssueCreateArgs) -> Result<()> { 21 - println!("Issue create (stub) repo={:?} title={:?} body={:?} labels={:?} assign={:?}", 22 - args.repo, args.title, args.body, args.label, args.assign); 26 + println!( 27 + "Issue create (stub) repo={:?} title={:?} body={:?} labels={:?} assign={:?}", 28 + args.repo, args.title, args.body, args.label, args.assign 29 + ); 23 30 Ok(()) 24 31 } 25 32 26 33 async fn show(args: IssueShowArgs) -> Result<()> { 27 - println!("Issue show (stub) id={} comments={} json={}", args.id, args.comments, args.json); 34 + println!( 35 + "Issue show (stub) id={} comments={} json={}", 36 + args.id, args.comments, args.json 37 + ); 28 38 Ok(()) 29 39 } 30 40 31 41 async fn edit(args: IssueEditArgs) -> Result<()> { 32 - println!("Issue edit (stub) id={} title={:?} body={:?} state={:?}", 33 - args.id, args.title, args.body, args.state); 42 + println!( 43 + "Issue edit (stub) id={} title={:?} body={:?} state={:?}", 44 + args.id, args.title, args.body, args.state 45 + ); 34 46 Ok(()) 35 47 } 36 48 37 49 async fn comment(args: IssueCommentArgs) -> Result<()> { 38 - println!("Issue comment (stub) id={} close={} body={:?}", args.id, args.close, args.body); 50 + println!( 51 + "Issue comment (stub) id={} close={} body={:?}", 52 + args.id, args.close, args.body 53 + ); 39 54 Ok(()) 40 55 } 41 -
+9 -4
crates/tangled-cli/src/commands/knot.rs
··· 1 + use crate::cli::{Cli, KnotAddArgs, KnotCommand, KnotListArgs, KnotRefArgs, KnotVerifyArgs}; 1 2 use anyhow::Result; 2 - use crate::cli::{Cli, KnotCommand, KnotListArgs, KnotAddArgs, KnotVerifyArgs, KnotRefArgs}; 3 3 4 4 pub async fn run(_cli: &Cli, cmd: KnotCommand) -> Result<()> { 5 5 match cmd { ··· 12 12 } 13 13 14 14 async fn list(args: KnotListArgs) -> Result<()> { 15 - println!("Knot list (stub) public={} owned={}", args.public, args.owned); 15 + println!( 16 + "Knot list (stub) public={} owned={}", 17 + args.public, args.owned 18 + ); 16 19 Ok(()) 17 20 } 18 21 19 22 async fn add(args: KnotAddArgs) -> Result<()> { 20 - println!("Knot add (stub) url={} did={:?} name={:?} verify={}", args.url, args.did, args.name, args.verify); 23 + println!( 24 + "Knot add (stub) url={} did={:?} name={:?} verify={}", 25 + args.url, args.did, args.name, args.verify 26 + ); 21 27 Ok(()) 22 28 } 23 29 ··· 35 41 println!("Knot remove (stub) url={}", args.url); 36 42 Ok(()) 37 43 } 38 -
+3 -7
crates/tangled-cli/src/commands/mod.rs
··· 1 1 pub mod auth; 2 - pub mod repo; 3 2 pub mod issue; 3 + pub mod knot; 4 4 pub mod pr; 5 - pub mod knot; 5 + pub mod repo; 6 6 pub mod spindle; 7 7 8 8 use anyhow::Result; 9 - use colored::Colorize; 10 9 11 10 use crate::cli::{Cli, Command}; 12 11 ··· 21 20 } 22 21 } 23 22 24 - fn not_implemented(feature: &str) -> Result<()> { 25 - eprintln!("{} {}", "[todo]".yellow().bold(), feature); 26 - Ok(()) 27 - } 23 + // All subcommands are currently implemented with stubs where needed.
+21 -11
crates/tangled-cli/src/commands/pr.rs
··· 1 + use crate::cli::{Cli, PrCommand, PrCreateArgs, PrListArgs, PrMergeArgs, PrReviewArgs, PrShowArgs}; 1 2 use anyhow::Result; 2 - use crate::cli::{Cli, PrCommand, PrCreateArgs, PrListArgs, PrShowArgs, PrReviewArgs, PrMergeArgs}; 3 3 4 4 pub async fn run(_cli: &Cli, cmd: PrCommand) -> Result<()> { 5 5 match cmd { ··· 12 12 } 13 13 14 14 async fn list(args: PrListArgs) -> Result<()> { 15 - println!("PR list (stub) repo={:?} state={:?} author={:?} reviewer={:?}", 16 - args.repo, args.state, args.author, args.reviewer); 15 + println!( 16 + "PR list (stub) repo={:?} state={:?} author={:?} reviewer={:?}", 17 + args.repo, args.state, args.author, args.reviewer 18 + ); 17 19 Ok(()) 18 20 } 19 21 20 22 async fn create(args: PrCreateArgs) -> Result<()> { 21 - println!("PR create (stub) repo={:?} base={:?} head={:?} title={:?} draft={}", 22 - args.repo, args.base, args.head, args.title, args.draft); 23 + println!( 24 + "PR create (stub) repo={:?} base={:?} head={:?} title={:?} draft={}", 25 + args.repo, args.base, args.head, args.title, args.draft 26 + ); 23 27 Ok(()) 24 28 } 25 29 26 30 async fn show(args: PrShowArgs) -> Result<()> { 27 - println!("PR show (stub) id={} diff={} comments={} checks={}", args.id, args.diff, args.comments, args.checks); 31 + println!( 32 + "PR show (stub) id={} diff={} comments={} checks={}", 33 + args.id, args.diff, args.comments, args.checks 34 + ); 28 35 Ok(()) 29 36 } 30 37 31 38 async fn review(args: PrReviewArgs) -> Result<()> { 32 - println!("PR review (stub) id={} approve={} request_changes={} comment={:?}", 33 - args.id, args.approve, args.request_changes, args.comment); 39 + println!( 40 + "PR review (stub) id={} approve={} request_changes={} comment={:?}", 41 + args.id, args.approve, args.request_changes, args.comment 42 + ); 34 43 Ok(()) 35 44 } 36 45 37 46 async fn merge(args: PrMergeArgs) -> Result<()> { 38 - println!("PR merge (stub) id={} squash={} rebase={} no_ff={}", 39 - args.id, args.squash, args.rebase, args.no_ff); 47 + println!( 48 + "PR merge (stub) id={} squash={} rebase={} no_ff={}", 49 + args.id, args.squash, args.rebase, args.no_ff 50 + ); 40 51 Ok(()) 41 52 } 42 -
+82 -13
crates/tangled-cli/src/commands/repo.rs
··· 1 - use anyhow::Result; 2 - use crate::cli::{Cli, RepoCommand, RepoCreateArgs, RepoInfoArgs, RepoListArgs, RepoCloneArgs, RepoDeleteArgs, RepoRefArgs}; 1 + use anyhow::{anyhow, Result}; 2 + use serde_json; 3 + use tangled_config::session::SessionManager; 3 4 4 - pub async fn run(_cli: &Cli, cmd: RepoCommand) -> Result<()> { 5 + use crate::cli::{ 6 + Cli, OutputFormat, RepoCloneArgs, RepoCommand, RepoCreateArgs, RepoDeleteArgs, RepoInfoArgs, 7 + RepoListArgs, RepoRefArgs, 8 + }; 9 + 10 + pub async fn run(cli: &Cli, cmd: RepoCommand) -> Result<()> { 5 11 match cmd { 6 - RepoCommand::List(args) => list(args).await, 12 + RepoCommand::List(args) => list(cli, args).await, 7 13 RepoCommand::Create(args) => create(args).await, 8 14 RepoCommand::Clone(args) => clone(args).await, 9 15 RepoCommand::Info(args) => info(args).await, ··· 13 19 } 14 20 } 15 21 16 - async fn list(args: RepoListArgs) -> Result<()> { 17 - println!("Listing repositories (stub) knot={:?} user={:?} starred={}", 18 - args.knot, args.user, args.starred); 22 + async fn list(cli: &Cli, args: RepoListArgs) -> Result<()> { 23 + let mgr = SessionManager::default(); 24 + let session = match mgr.load()? { 25 + Some(s) => s, 26 + None => return Err(anyhow!("Please login first: tangled auth login")), 27 + }; 28 + 29 + // Use the PDS to list repo records for the user 30 + let pds = session 31 + .pds 32 + .clone() 33 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 34 + .unwrap_or_else(|| "https://bsky.social".into()); 35 + let pds_client = tangled_api::TangledClient::new(pds); 36 + // Default to the logged-in user handle if --user is not provided 37 + let effective_user = args.user.as_deref().unwrap_or(session.handle.as_str()); 38 + let repos = pds_client 39 + .list_repos( 40 + Some(effective_user), 41 + args.knot.as_deref(), 42 + args.starred, 43 + Some(session.access_jwt.as_str()), 44 + ) 45 + .await?; 46 + 47 + match cli.format { 48 + OutputFormat::Json => { 49 + let json = serde_json::to_string_pretty(&repos)?; 50 + println!("{}", json); 51 + } 52 + OutputFormat::Table => { 53 + println!("NAME\tKNOT\tPRIVATE"); 54 + for r in repos { 55 + println!("{}\t{}\t{}", r.name, r.knot.unwrap_or_default(), r.private); 56 + } 57 + } 58 + } 59 + 19 60 Ok(()) 20 61 } 21 62 22 63 async fn create(args: RepoCreateArgs) -> Result<()> { 23 - println!( 24 - "Creating repo '{}' (stub) knot={:?} private={} init={} desc={:?}", 25 - args.name, args.knot, args.private, args.init, args.description 26 - ); 64 + let mgr = SessionManager::default(); 65 + let session = match mgr.load()? { 66 + Some(s) => s, 67 + None => return Err(anyhow!("Please login first: tangled auth login")), 68 + }; 69 + 70 + let base = std::env::var("TANGLED_API_BASE").unwrap_or_else(|_| "https://tngl.sh".into()); 71 + let client = tangled_api::TangledClient::new(base); 72 + 73 + // Determine PDS base and target knot hostname 74 + let pds = session 75 + .pds 76 + .clone() 77 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 78 + .unwrap_or_else(|| "https://bsky.social".into()); 79 + let knot = args.knot.unwrap_or_else(|| "tngl.sh".to_string()); 80 + 81 + let opts = tangled_api::client::CreateRepoOptions { 82 + did: &session.did, 83 + name: &args.name, 84 + knot: &knot, 85 + description: args.description.as_deref(), 86 + default_branch: None, 87 + source: None, 88 + pds_base: &pds, 89 + access_jwt: &session.access_jwt, 90 + }; 91 + client.create_repo(opts).await?; 92 + 93 + println!("Created repo '{}' (knot: {})", args.name, knot); 27 94 Ok(()) 28 95 } 29 96 30 97 async fn clone(args: RepoCloneArgs) -> Result<()> { 31 - println!("Cloning repo '{}' (stub) https={} depth={:?}", args.repo, args.https, args.depth); 98 + println!( 99 + "Cloning repo '{}' (stub) https={} depth={:?}", 100 + args.repo, args.https, args.depth 101 + ); 32 102 Ok(()) 33 103 } 34 104 ··· 54 124 println!("Unstarring repo '{}' (stub)", args.repo); 55 125 Ok(()) 56 126 } 57 -
+11 -4
crates/tangled-cli/src/commands/spindle.rs
··· 1 + use crate::cli::{ 2 + Cli, SpindleCommand, SpindleConfigArgs, SpindleListArgs, SpindleLogsArgs, SpindleRunArgs, 3 + }; 1 4 use anyhow::Result; 2 - use crate::cli::{Cli, SpindleCommand, SpindleListArgs, SpindleConfigArgs, SpindleRunArgs, SpindleLogsArgs}; 3 5 4 6 pub async fn run(_cli: &Cli, cmd: SpindleCommand) -> Result<()> { 5 7 match cmd { ··· 24 26 } 25 27 26 28 async fn run_pipeline(args: SpindleRunArgs) -> Result<()> { 27 - println!("Spindle run (stub) repo={:?} branch={:?} wait={}", args.repo, args.branch, args.wait); 29 + println!( 30 + "Spindle run (stub) repo={:?} branch={:?} wait={}", 31 + args.repo, args.branch, args.wait 32 + ); 28 33 Ok(()) 29 34 } 30 35 31 36 async fn logs(args: SpindleLogsArgs) -> Result<()> { 32 - println!("Spindle logs (stub) job_id={} follow={} lines={:?}", args.job_id, args.follow, args.lines); 37 + println!( 38 + "Spindle logs (stub) job_id={} follow={} lines={:?}", 39 + args.job_id, args.follow, args.lines 40 + ); 33 41 Ok(()) 34 42 } 35 -
+1 -1
crates/tangled-cli/src/main.rs
··· 2 2 mod commands; 3 3 4 4 use anyhow::Result; 5 - use cli::Cli; 6 5 use clap::Parser; 6 + use cli::Cli; 7 7 8 8 #[tokio::main] 9 9 async fn main() -> Result<()> {
+7 -4
crates/tangled-config/src/config.rs
··· 22 22 pub knot: Option<String>, 23 23 pub editor: Option<String>, 24 24 pub pager: Option<String>, 25 - #[serde(default = "default_format")] 25 + #[serde(default = "default_format")] 26 26 pub format: String, 27 27 } 28 28 29 - fn default_format() -> String { "table".to_string() } 29 + fn default_format() -> String { 30 + "table".to_string() 31 + } 30 32 31 33 #[derive(Debug, Clone, Serialize, Deserialize, Default)] 32 34 pub struct AuthSection { ··· 74 76 let path = path 75 77 .map(|p| p.to_path_buf()) 76 78 .unwrap_or(default_config_path()?); 77 - if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } 79 + if let Some(parent) = path.parent() { 80 + std::fs::create_dir_all(parent)?; 81 + } 78 82 let toml = toml::to_string_pretty(cfg)?; 79 83 fs::write(&path, toml) 80 84 .with_context(|| format!("Failed writing config file: {}", path.display()))?; 81 85 Ok(()) 82 86 } 83 -
+13 -4
crates/tangled-config/src/keychain.rs
··· 8 8 9 9 impl Keychain { 10 10 pub fn new(service: &str, account: &str) -> Self { 11 - Self { service: service.into(), account: account.into() } 11 + Self { 12 + service: service.into(), 13 + account: account.into(), 14 + } 12 15 } 13 16 14 17 fn entry(&self) -> Result<Entry> { ··· 16 19 } 17 20 18 21 pub fn set_password(&self, secret: &str) -> Result<()> { 19 - self.entry()?.set_password(secret).map_err(|e| anyhow!("keyring error: {e}")) 22 + self.entry()? 23 + .set_password(secret) 24 + .map_err(|e| anyhow!("keyring error: {e}")) 20 25 } 21 26 22 27 pub fn get_password(&self) -> Result<String> { 23 - self.entry()?.get_password().map_err(|e| anyhow!("keyring error: {e}")) 28 + self.entry()? 29 + .get_password() 30 + .map_err(|e| anyhow!("keyring error: {e}")) 24 31 } 25 32 26 33 pub fn delete_password(&self) -> Result<()> { 27 - self.entry()?.delete_credential().map_err(|e| anyhow!("keyring error: {e}")) 34 + self.entry()? 35 + .delete_credential() 36 + .map_err(|e| anyhow!("keyring error: {e}")) 28 37 } 29 38 }
+1 -2
crates/tangled-config/src/lib.rs
··· 1 1 pub mod config; 2 - pub mod session; 3 2 pub mod keychain; 4 - 3 + pub mod session;
+13 -3
crates/tangled-config/src/session.rs
··· 11 11 pub did: String, 12 12 pub handle: String, 13 13 #[serde(default)] 14 + pub pds: Option<String>, 15 + #[serde(default)] 14 16 pub created_at: DateTime<Utc>, 15 17 } 16 18 ··· 21 23 refresh_jwt: String::new(), 22 24 did: String::new(), 23 25 handle: String::new(), 26 + pds: None, 24 27 created_at: Utc::now(), 25 28 } 26 29 } ··· 33 36 34 37 impl Default for SessionManager { 35 38 fn default() -> Self { 36 - Self { service: "tangled-cli".into(), account: "default".into() } 39 + Self { 40 + service: "tangled-cli".into(), 41 + account: "default".into(), 42 + } 37 43 } 38 44 } 39 45 40 46 impl SessionManager { 41 - pub fn new(service: &str, account: &str) -> Self { Self { service: service.into(), account: account.into() } } 47 + pub fn new(service: &str, account: &str) -> Self { 48 + Self { 49 + service: service.into(), 50 + account: account.into(), 51 + } 52 + } 42 53 43 54 pub fn save(&self, session: &Session) -> Result<()> { 44 55 let keychain = Keychain::new(&self.service, &self.account); ··· 59 70 keychain.delete_password() 60 71 } 61 72 } 62 -
-1
crates/tangled-git/src/lib.rs
··· 1 1 pub mod operations; 2 -
-1
crates/tangled-git/src/operations.rs
··· 5 5 // TODO: support ssh/https and depth 6 6 bail!("clone_repo not implemented") 7 7 } 8 -