+1
Cargo.lock
+1
Cargo.lock
+1
-1
crates/tangled-api/Cargo.toml
+1
-1
crates/tangled-api/Cargo.toml
···
11
serde_json = { workspace = true }
12
reqwest = { workspace = true }
13
tokio = { workspace = true, features = ["full"] }
14
15
# Optionally depend on ATrium (wired later as endpoints solidify)
16
atrium-api = { workspace = true, optional = true }
···
21
[features]
22
default = []
23
atrium = ["dep:atrium-api", "dep:atrium-xrpc-client"]
24
-
···
11
serde_json = { workspace = true }
12
reqwest = { workspace = true }
13
tokio = { workspace = true, features = ["full"] }
14
+
chrono = { workspace = true }
15
16
# Optionally depend on ATrium (wired later as endpoints solidify)
17
atrium-api = { workspace = true, optional = true }
···
22
[features]
23
default = []
24
atrium = ["dep:atrium-api", "dep:atrium-xrpc-client"]
+184
-32
crates/tangled-api/src/client.rs
+184
-32
crates/tangled-api/src/client.rs
···
7
base_url: String,
8
}
9
10
impl TangledClient {
11
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")
17
}
18
19
fn xrpc_url(&self, method: &str) -> String {
20
-
format!(
21
-
"{}/xrpc/{}",
22
-
self.base_url.trim_end_matches('/'),
23
-
method
24
-
)
25
}
26
27
async fn post_json<TReq: Serialize, TRes: DeserializeOwned>(
···
36
.post(url)
37
.header(reqwest::header::CONTENT_TYPE, "application/json");
38
if let Some(token) = bearer {
39
-
reqb = reqb.header(
40
-
reqwest::header::AUTHORIZATION,
41
-
format!("Bearer {}", token),
42
-
);
43
}
44
let res = reqb.json(req).send().await?;
45
let status = res.status();
···
58
) -> Result<TRes> {
59
let url = self.xrpc_url(method);
60
let client = reqwest::Client::new();
61
-
let mut reqb = client.get(url).query(¶ms);
62
if let Some(token) = bearer {
63
-
reqb = reqb.header(
64
-
reqwest::header::AUTHORIZATION,
65
-
format!("Bearer {}", token),
66
-
);
67
}
68
let res = reqb.send().await?;
69
let status = res.status();
70
if !status.is_success() {
71
-
let body = res.text().await.unwrap_or_default();
72
-
return Err(anyhow!("{}: {}", status, body));
73
}
74
-
Ok(res.json::<TRes>().await?)
75
}
76
77
pub async fn login_with_password(
···
119
starred: bool,
120
bearer: Option<&str>,
121
) -> Result<Vec<Repository>> {
122
#[derive(Deserialize)]
123
-
struct Envelope {
124
-
repos: Vec<Repository>,
125
}
126
-
let mut q = vec![];
127
-
if let Some(u) = user {
128
-
q.push(("user", u.to_string()));
129
}
130
if let Some(k) = knot {
131
-
q.push(("knot", k.to_string()));
132
}
133
if starred {
134
-
q.push(("starred", true.to_string()));
135
}
136
-
let env: Envelope = self
137
-
.get_json("sh.tangled.repo.list", &q, bearer)
138
.await?;
139
-
Ok(env.repos)
140
}
141
}
142
···
147
pub name: String,
148
pub knot: Option<String>,
149
pub description: Option<String>,
150
pub private: bool,
151
}
···
7
base_url: String,
8
}
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
+
18
impl TangledClient {
19
pub fn new(base_url: impl Into<String>) -> Self {
20
+
Self {
21
+
base_url: base_url.into(),
22
+
}
23
}
24
25
fn xrpc_url(&self, method: &str) -> String {
26
+
format!("{}/xrpc/{}", self.base_url.trim_end_matches('/'), method)
27
}
28
29
async fn post_json<TReq: Serialize, TRes: DeserializeOwned>(
···
38
.post(url)
39
.header(reqwest::header::CONTENT_TYPE, "application/json");
40
if let Some(token) = bearer {
41
+
reqb = reqb.header(reqwest::header::AUTHORIZATION, format!("Bearer {}", token));
42
}
43
let res = reqb.json(req).send().await?;
44
let status = res.status();
···
57
) -> Result<TRes> {
58
let url = self.xrpc_url(method);
59
let client = reqwest::Client::new();
60
+
let mut reqb = client
61
+
.get(&url)
62
+
.query(¶ms)
63
+
.header(reqwest::header::ACCEPT, "application/json");
64
if let Some(token) = bearer {
65
+
reqb = reqb.header(reqwest::header::AUTHORIZATION, format!("Bearer {}", token));
66
}
67
let res = reqb.send().await?;
68
let status = res.status();
69
+
let body = res.text().await.unwrap_or_default();
70
if !status.is_success() {
71
+
return Err(anyhow!("GET {} -> {}: {}", url, status, body));
72
}
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
+
})
82
}
83
84
pub async fn login_with_password(
···
126
starred: bool,
127
bearer: Option<&str>,
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", ¶ms, 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
+
153
#[derive(Deserialize)]
154
+
struct RecordItem {
155
+
value: Repository,
156
}
157
+
#[derive(Deserialize)]
158
+
struct ListRes {
159
+
#[serde(default)]
160
+
records: Vec<RecordItem>,
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", ¶ms, 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);
176
}
177
if starred {
178
+
// TODO: implement starred filtering when API is available. For now, no-op.
179
}
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
+
)
227
.await?;
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
+
¶ms,
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(())
279
}
280
}
281
···
286
pub name: String,
287
pub knot: Option<String>,
288
pub description: Option<String>,
289
+
#[serde(default)]
290
pub private: bool,
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
crates/tangled-api/src/lib.rs
+3
crates/tangled-cli/src/cli.rs
+3
crates/tangled-cli/src/cli.rs
+5
-4
crates/tangled-cli/src/commands/auth.rs
+5
-4
crates/tangled-cli/src/commands/auth.rs
···
21
Some(p) => p,
22
None => Password::new().with_prompt("Password").interact()?,
23
};
24
-
let pds = args.pds.unwrap_or_else(|| "https://bsky.social".to_string());
25
26
let client = tangled_api::TangledClient::new(&pds);
27
-
let session = client
28
-
.login_with_password(&handle, &password, &pds)
29
-
.await?;
30
SessionManager::default().save(&session)?;
31
println!("Logged in as '{}' ({})", session.handle, session.did);
32
Ok(())
···
21
Some(p) => p,
22
None => Password::new().with_prompt("Password").interact()?,
23
};
24
+
let pds = args
25
+
.pds
26
+
.unwrap_or_else(|| "https://bsky.social".to_string());
27
28
let client = tangled_api::TangledClient::new(&pds);
29
+
let mut session = client.login_with_password(&handle, &password, &pds).await?;
30
+
session.pds = Some(pds.clone());
31
SessionManager::default().save(&session)?;
32
println!("Logged in as '{}' ({})", session.handle, session.did);
33
Ok(())
+24
-10
crates/tangled-cli/src/commands/issue.rs
+24
-10
crates/tangled-cli/src/commands/issue.rs
···
1
use anyhow::Result;
2
-
use crate::cli::{Cli, IssueCommand, IssueListArgs, IssueCreateArgs, IssueShowArgs, IssueEditArgs, IssueCommentArgs};
3
4
pub async fn run(_cli: &Cli, cmd: IssueCommand) -> Result<()> {
5
match cmd {
···
12
}
13
14
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);
17
Ok(())
18
}
19
20
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);
23
Ok(())
24
}
25
26
async fn show(args: IssueShowArgs) -> Result<()> {
27
-
println!("Issue show (stub) id={} comments={} json={}", args.id, args.comments, args.json);
28
Ok(())
29
}
30
31
async fn edit(args: IssueEditArgs) -> Result<()> {
32
-
println!("Issue edit (stub) id={} title={:?} body={:?} state={:?}",
33
-
args.id, args.title, args.body, args.state);
34
Ok(())
35
}
36
37
async fn comment(args: IssueCommentArgs) -> Result<()> {
38
-
println!("Issue comment (stub) id={} close={} body={:?}", args.id, args.close, args.body);
39
Ok(())
40
}
41
-
···
1
+
use crate::cli::{
2
+
Cli, IssueCommand, IssueCommentArgs, IssueCreateArgs, IssueEditArgs, IssueListArgs,
3
+
IssueShowArgs,
4
+
};
5
use anyhow::Result;
6
7
pub async fn run(_cli: &Cli, cmd: IssueCommand) -> Result<()> {
8
match cmd {
···
15
}
16
17
async fn list(args: IssueListArgs) -> Result<()> {
18
+
println!(
19
+
"Issue list (stub) repo={:?} state={:?} author={:?} label={:?} assigned={:?}",
20
+
args.repo, args.state, args.author, args.label, args.assigned
21
+
);
22
Ok(())
23
}
24
25
async fn create(args: IssueCreateArgs) -> Result<()> {
26
+
println!(
27
+
"Issue create (stub) repo={:?} title={:?} body={:?} labels={:?} assign={:?}",
28
+
args.repo, args.title, args.body, args.label, args.assign
29
+
);
30
Ok(())
31
}
32
33
async fn show(args: IssueShowArgs) -> Result<()> {
34
+
println!(
35
+
"Issue show (stub) id={} comments={} json={}",
36
+
args.id, args.comments, args.json
37
+
);
38
Ok(())
39
}
40
41
async fn edit(args: IssueEditArgs) -> Result<()> {
42
+
println!(
43
+
"Issue edit (stub) id={} title={:?} body={:?} state={:?}",
44
+
args.id, args.title, args.body, args.state
45
+
);
46
Ok(())
47
}
48
49
async fn comment(args: IssueCommentArgs) -> Result<()> {
50
+
println!(
51
+
"Issue comment (stub) id={} close={} body={:?}",
52
+
args.id, args.close, args.body
53
+
);
54
Ok(())
55
}
+9
-4
crates/tangled-cli/src/commands/knot.rs
+9
-4
crates/tangled-cli/src/commands/knot.rs
···
1
use anyhow::Result;
2
-
use crate::cli::{Cli, KnotCommand, KnotListArgs, KnotAddArgs, KnotVerifyArgs, KnotRefArgs};
3
4
pub async fn run(_cli: &Cli, cmd: KnotCommand) -> Result<()> {
5
match cmd {
···
12
}
13
14
async fn list(args: KnotListArgs) -> Result<()> {
15
-
println!("Knot list (stub) public={} owned={}", args.public, args.owned);
16
Ok(())
17
}
18
19
async fn add(args: KnotAddArgs) -> Result<()> {
20
-
println!("Knot add (stub) url={} did={:?} name={:?} verify={}", args.url, args.did, args.name, args.verify);
21
Ok(())
22
}
23
···
35
println!("Knot remove (stub) url={}", args.url);
36
Ok(())
37
}
38
-
···
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 {
···
12
}
13
14
async fn list(args: KnotListArgs) -> Result<()> {
15
+
println!(
16
+
"Knot list (stub) public={} owned={}",
17
+
args.public, args.owned
18
+
);
19
Ok(())
20
}
21
22
async fn add(args: KnotAddArgs) -> Result<()> {
23
+
println!(
24
+
"Knot add (stub) url={} did={:?} name={:?} verify={}",
25
+
args.url, args.did, args.name, args.verify
26
+
);
27
Ok(())
28
}
29
···
41
println!("Knot remove (stub) url={}", args.url);
42
Ok(())
43
}
+3
-7
crates/tangled-cli/src/commands/mod.rs
+3
-7
crates/tangled-cli/src/commands/mod.rs
···
1
pub mod auth;
2
-
pub mod repo;
3
pub mod issue;
4
pub mod pr;
5
-
pub mod knot;
6
pub mod spindle;
7
8
use anyhow::Result;
9
-
use colored::Colorize;
10
11
use crate::cli::{Cli, Command};
12
···
21
}
22
}
23
24
-
fn not_implemented(feature: &str) -> Result<()> {
25
-
eprintln!("{} {}", "[todo]".yellow().bold(), feature);
26
-
Ok(())
27
-
}
+21
-11
crates/tangled-cli/src/commands/pr.rs
+21
-11
crates/tangled-cli/src/commands/pr.rs
···
1
use anyhow::Result;
2
-
use crate::cli::{Cli, PrCommand, PrCreateArgs, PrListArgs, PrShowArgs, PrReviewArgs, PrMergeArgs};
3
4
pub async fn run(_cli: &Cli, cmd: PrCommand) -> Result<()> {
5
match cmd {
···
12
}
13
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);
17
Ok(())
18
}
19
20
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
Ok(())
24
}
25
26
async fn show(args: PrShowArgs) -> Result<()> {
27
-
println!("PR show (stub) id={} diff={} comments={} checks={}", args.id, args.diff, args.comments, args.checks);
28
Ok(())
29
}
30
31
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);
34
Ok(())
35
}
36
37
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);
40
Ok(())
41
}
42
-
···
1
+
use crate::cli::{Cli, PrCommand, PrCreateArgs, PrListArgs, PrMergeArgs, PrReviewArgs, PrShowArgs};
2
use anyhow::Result;
3
4
pub async fn run(_cli: &Cli, cmd: PrCommand) -> Result<()> {
5
match cmd {
···
12
}
13
14
async fn list(args: PrListArgs) -> Result<()> {
15
+
println!(
16
+
"PR list (stub) repo={:?} state={:?} author={:?} reviewer={:?}",
17
+
args.repo, args.state, args.author, args.reviewer
18
+
);
19
Ok(())
20
}
21
22
async fn create(args: PrCreateArgs) -> Result<()> {
23
+
println!(
24
+
"PR create (stub) repo={:?} base={:?} head={:?} title={:?} draft={}",
25
+
args.repo, args.base, args.head, args.title, args.draft
26
+
);
27
Ok(())
28
}
29
30
async fn show(args: PrShowArgs) -> Result<()> {
31
+
println!(
32
+
"PR show (stub) id={} diff={} comments={} checks={}",
33
+
args.id, args.diff, args.comments, args.checks
34
+
);
35
Ok(())
36
}
37
38
async fn review(args: PrReviewArgs) -> Result<()> {
39
+
println!(
40
+
"PR review (stub) id={} approve={} request_changes={} comment={:?}",
41
+
args.id, args.approve, args.request_changes, args.comment
42
+
);
43
Ok(())
44
}
45
46
async fn merge(args: PrMergeArgs) -> Result<()> {
47
+
println!(
48
+
"PR merge (stub) id={} squash={} rebase={} no_ff={}",
49
+
args.id, args.squash, args.rebase, args.no_ff
50
+
);
51
Ok(())
52
}
+82
-13
crates/tangled-cli/src/commands/repo.rs
+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};
3
4
-
pub async fn run(_cli: &Cli, cmd: RepoCommand) -> Result<()> {
5
match cmd {
6
-
RepoCommand::List(args) => list(args).await,
7
RepoCommand::Create(args) => create(args).await,
8
RepoCommand::Clone(args) => clone(args).await,
9
RepoCommand::Info(args) => info(args).await,
···
13
}
14
}
15
16
-
async fn list(args: RepoListArgs) -> Result<()> {
17
-
println!("Listing repositories (stub) knot={:?} user={:?} starred={}",
18
-
args.knot, args.user, args.starred);
19
Ok(())
20
}
21
22
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
-
);
27
Ok(())
28
}
29
30
async fn clone(args: RepoCloneArgs) -> Result<()> {
31
-
println!("Cloning repo '{}' (stub) https={} depth={:?}", args.repo, args.https, args.depth);
32
Ok(())
33
}
34
···
54
println!("Unstarring repo '{}' (stub)", args.repo);
55
Ok(())
56
}
57
-
···
1
+
use anyhow::{anyhow, Result};
2
+
use serde_json;
3
+
use tangled_config::session::SessionManager;
4
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<()> {
11
match cmd {
12
+
RepoCommand::List(args) => list(cli, args).await,
13
RepoCommand::Create(args) => create(args).await,
14
RepoCommand::Clone(args) => clone(args).await,
15
RepoCommand::Info(args) => info(args).await,
···
19
}
20
}
21
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
+
60
Ok(())
61
}
62
63
async fn create(args: RepoCreateArgs) -> Result<()> {
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);
94
Ok(())
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
···
124
println!("Unstarring repo '{}' (stub)", args.repo);
125
Ok(())
126
}
+11
-4
crates/tangled-cli/src/commands/spindle.rs
+11
-4
crates/tangled-cli/src/commands/spindle.rs
···
1
use anyhow::Result;
2
-
use crate::cli::{Cli, SpindleCommand, SpindleListArgs, SpindleConfigArgs, SpindleRunArgs, SpindleLogsArgs};
3
4
pub async fn run(_cli: &Cli, cmd: SpindleCommand) -> Result<()> {
5
match cmd {
···
24
}
25
26
async fn run_pipeline(args: SpindleRunArgs) -> Result<()> {
27
-
println!("Spindle run (stub) repo={:?} branch={:?} wait={}", args.repo, args.branch, args.wait);
28
Ok(())
29
}
30
31
async fn logs(args: SpindleLogsArgs) -> Result<()> {
32
-
println!("Spindle logs (stub) job_id={} follow={} lines={:?}", args.job_id, args.follow, args.lines);
33
Ok(())
34
}
35
-
···
1
+
use crate::cli::{
2
+
Cli, SpindleCommand, SpindleConfigArgs, SpindleListArgs, SpindleLogsArgs, SpindleRunArgs,
3
+
};
4
use anyhow::Result;
5
6
pub async fn run(_cli: &Cli, cmd: SpindleCommand) -> Result<()> {
7
match cmd {
···
26
}
27
28
async fn run_pipeline(args: SpindleRunArgs) -> Result<()> {
29
+
println!(
30
+
"Spindle run (stub) repo={:?} branch={:?} wait={}",
31
+
args.repo, args.branch, args.wait
32
+
);
33
Ok(())
34
}
35
36
async fn logs(args: SpindleLogsArgs) -> Result<()> {
37
+
println!(
38
+
"Spindle logs (stub) job_id={} follow={} lines={:?}",
39
+
args.job_id, args.follow, args.lines
40
+
);
41
Ok(())
42
}
+1
-1
crates/tangled-cli/src/main.rs
+1
-1
crates/tangled-cli/src/main.rs
+7
-4
crates/tangled-config/src/config.rs
+7
-4
crates/tangled-config/src/config.rs
···
22
pub knot: Option<String>,
23
pub editor: Option<String>,
24
pub pager: Option<String>,
25
-
#[serde(default = "default_format")]
26
pub format: String,
27
}
28
29
-
fn default_format() -> String { "table".to_string() }
30
31
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
32
pub struct AuthSection {
···
74
let path = path
75
.map(|p| p.to_path_buf())
76
.unwrap_or(default_config_path()?);
77
-
if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; }
78
let toml = toml::to_string_pretty(cfg)?;
79
fs::write(&path, toml)
80
.with_context(|| format!("Failed writing config file: {}", path.display()))?;
81
Ok(())
82
}
83
-
···
22
pub knot: Option<String>,
23
pub editor: Option<String>,
24
pub pager: Option<String>,
25
+
#[serde(default = "default_format")]
26
pub format: String,
27
}
28
29
+
fn default_format() -> String {
30
+
"table".to_string()
31
+
}
32
33
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
34
pub struct AuthSection {
···
76
let path = path
77
.map(|p| p.to_path_buf())
78
.unwrap_or(default_config_path()?);
79
+
if let Some(parent) = path.parent() {
80
+
std::fs::create_dir_all(parent)?;
81
+
}
82
let toml = toml::to_string_pretty(cfg)?;
83
fs::write(&path, toml)
84
.with_context(|| format!("Failed writing config file: {}", path.display()))?;
85
Ok(())
86
}
+13
-4
crates/tangled-config/src/keychain.rs
+13
-4
crates/tangled-config/src/keychain.rs
···
8
9
impl Keychain {
10
pub fn new(service: &str, account: &str) -> Self {
11
-
Self { service: service.into(), account: account.into() }
12
}
13
14
fn entry(&self) -> Result<Entry> {
···
16
}
17
18
pub fn set_password(&self, secret: &str) -> Result<()> {
19
-
self.entry()?.set_password(secret).map_err(|e| anyhow!("keyring error: {e}"))
20
}
21
22
pub fn get_password(&self) -> Result<String> {
23
-
self.entry()?.get_password().map_err(|e| anyhow!("keyring error: {e}"))
24
}
25
26
pub fn delete_password(&self) -> Result<()> {
27
-
self.entry()?.delete_credential().map_err(|e| anyhow!("keyring error: {e}"))
28
}
29
}
···
8
9
impl Keychain {
10
pub fn new(service: &str, account: &str) -> Self {
11
+
Self {
12
+
service: service.into(),
13
+
account: account.into(),
14
+
}
15
}
16
17
fn entry(&self) -> Result<Entry> {
···
19
}
20
21
pub fn set_password(&self, secret: &str) -> Result<()> {
22
+
self.entry()?
23
+
.set_password(secret)
24
+
.map_err(|e| anyhow!("keyring error: {e}"))
25
}
26
27
pub fn get_password(&self) -> Result<String> {
28
+
self.entry()?
29
+
.get_password()
30
+
.map_err(|e| anyhow!("keyring error: {e}"))
31
}
32
33
pub fn delete_password(&self) -> Result<()> {
34
+
self.entry()?
35
+
.delete_credential()
36
+
.map_err(|e| anyhow!("keyring error: {e}"))
37
}
38
}
+1
-2
crates/tangled-config/src/lib.rs
+1
-2
crates/tangled-config/src/lib.rs
+13
-3
crates/tangled-config/src/session.rs
+13
-3
crates/tangled-config/src/session.rs
···
11
pub did: String,
12
pub handle: String,
13
#[serde(default)]
14
pub created_at: DateTime<Utc>,
15
}
16
···
21
refresh_jwt: String::new(),
22
did: String::new(),
23
handle: String::new(),
24
created_at: Utc::now(),
25
}
26
}
···
33
34
impl Default for SessionManager {
35
fn default() -> Self {
36
-
Self { service: "tangled-cli".into(), account: "default".into() }
37
}
38
}
39
40
impl SessionManager {
41
-
pub fn new(service: &str, account: &str) -> Self { Self { service: service.into(), account: account.into() } }
42
43
pub fn save(&self, session: &Session) -> Result<()> {
44
let keychain = Keychain::new(&self.service, &self.account);
···
59
keychain.delete_password()
60
}
61
}
62
-
···
11
pub did: String,
12
pub handle: String,
13
#[serde(default)]
14
+
pub pds: Option<String>,
15
+
#[serde(default)]
16
pub created_at: DateTime<Utc>,
17
}
18
···
23
refresh_jwt: String::new(),
24
did: String::new(),
25
handle: String::new(),
26
+
pds: None,
27
created_at: Utc::now(),
28
}
29
}
···
36
37
impl Default for SessionManager {
38
fn default() -> Self {
39
+
Self {
40
+
service: "tangled-cli".into(),
41
+
account: "default".into(),
42
+
}
43
}
44
}
45
46
impl SessionManager {
47
+
pub fn new(service: &str, account: &str) -> Self {
48
+
Self {
49
+
service: service.into(),
50
+
account: account.into(),
51
+
}
52
+
}
53
54
pub fn save(&self, session: &Session) -> Result<()> {
55
let keychain = Keychain::new(&self.service, &self.account);
···
70
keychain.delete_password()
71
}
72
}