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