A terminal client for Tangled.
0

Configure Feed

Select the types of activity you want to include in your feed.

cli/repo: add delete and fork commands

Aly Raffauf (Jul 15, 2026, 12:13 PM EDT) f511f798 3c90e4ac

+200
+78
internal/cli/repo_delete.go
··· 1 + package cli 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/alyraffauf/tg/atproto" 7 + "github.com/alyraffauf/tg/knot" 8 + "github.com/spf13/cobra" 9 + ) 10 + 11 + var repoDeleteConfirm bool 12 + 13 + var repoDeleteCmd = &cobra.Command{ 14 + Use: "delete [handle/repo]", 15 + Short: "Delete a Tangled repository", 16 + Args: cobra.MaximumNArgs(1), 17 + RunE: func(cmd *cobra.Command, args []string) error { 18 + if !repoDeleteConfirm { 19 + return fmt.Errorf("refusing to delete without --yes") 20 + } 21 + ctx := cmd.Context() 22 + atClient, did, err := authenticatedATProto(ctx) 23 + if err != nil { 24 + return err 25 + } 26 + handle, name, err := resolveTarget(ctx, args) 27 + if err != nil { 28 + return err 29 + } 30 + repo, err := requireOwnedRepo(ctx, handle, name, did) 31 + if err != nil { 32 + return err 33 + } 34 + if repo.Value.Knot == "" { 35 + return fmt.Errorf("repo %q has no knot", handle+"/"+name) 36 + } 37 + rkey := extractRKey(repo.URI) 38 + existingRecord, err := atClient.GetRecord(ctx, did, "sh.tangled.repo", rkey) 39 + if err != nil { 40 + return fmt.Errorf("get repository record: %w", err) 41 + } 42 + 43 + token, err := atClient.GetServiceAuth(ctx, "did:web:"+repo.Value.Knot, "sh.tangled.repo.delete") 44 + if err != nil { 45 + return fmt.Errorf("get knot authorization: %w", err) 46 + } 47 + if err := atClient.DeleteRecord(ctx, atproto.DeleteRecordInput{ 48 + Repo: did, 49 + Collection: "sh.tangled.repo", 50 + Rkey: rkey, 51 + }); err != nil { 52 + return fmt.Errorf("delete repository record: %w", err) 53 + } 54 + if err := knot.New(repo.Value.Knot, token).DeleteRepo(ctx, knot.DeleteRepoInput{ 55 + DID: did, 56 + Name: name, 57 + Rkey: rkey, 58 + }); err != nil { 59 + if _, _, restoreErr := atClient.PutRecord(ctx, atproto.PutRecordInput{ 60 + Repo: did, Collection: "sh.tangled.repo", Rkey: rkey, Record: existingRecord.Value, 61 + }); restoreErr != nil { 62 + return fmt.Errorf("delete knot repository: %w; restore repository record: %v", err, restoreErr) 63 + } 64 + return err 65 + } 66 + return output(repoDeleteResult{URI: repo.URI}, func(result repoDeleteResult) { 67 + fmt.Printf("Deleted repository %s\n", result.URI) 68 + }) 69 + }, 70 + } 71 + 72 + func init() { 73 + repoDeleteCmd.Flags().BoolVar(&repoDeleteConfirm, "yes", false, "Confirm permanent repository deletion") 74 + } 75 + 76 + type repoDeleteResult struct { 77 + URI string `json:"uri"` 78 + }
+122
internal/cli/repo_fork.go
··· 1 + package cli 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "time" 7 + 8 + "github.com/alyraffauf/tg/atproto" 9 + "github.com/alyraffauf/tg/knot" 10 + "github.com/alyraffauf/tg/tangled" 11 + "github.com/spf13/cobra" 12 + ) 13 + 14 + var repoForkCmd = &cobra.Command{ 15 + Use: "fork <handle/repo> [name]", 16 + Short: "Fork a Tangled repository", 17 + Args: cobra.RangeArgs(1, 2), 18 + RunE: func(cmd *cobra.Command, args []string) error { 19 + if auth == nil || !auth.IsAuthenticated() { 20 + return fmt.Errorf("not logged in; run \"tg auth login\" first") 21 + } 22 + 23 + handle, sourceName, err := parseHandleRepo(args[0]) 24 + if err != nil { 25 + return err 26 + } 27 + name := sourceName 28 + if len(args) == 2 { 29 + name = args[1] 30 + } 31 + 32 + ctx := cmd.Context() 33 + source, err := getForkSource(ctx, handle, sourceName) 34 + if err != nil { 35 + return err 36 + } 37 + pds, err := auth.APIClient(ctx) 38 + if err != nil { 39 + return fmt.Errorf("get auth client: %w", err) 40 + } 41 + atClient := &atproto.ATProto{Client: pds} 42 + ownerDID := auth.CurrentDID().String() 43 + 44 + token, err := atClient.GetServiceAuth(ctx, "did:web:"+source.Knot, "sh.tangled.repo.create") 45 + if err != nil { 46 + return fmt.Errorf("get knot service auth: %w", err) 47 + } 48 + repoDID, err := knot.New(source.Knot, token).CreateRepo(ctx, knot.CreateRepoInput{ 49 + Name: name, 50 + Rkey: name, 51 + Source: source.URI, 52 + }) 53 + if err != nil { 54 + return err 55 + } 56 + uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{ 57 + Repo: ownerDID, 58 + Collection: "sh.tangled.repo", 59 + Rkey: name, 60 + Record: tangled.RepoRecord{ 61 + Type: "sh.tangled.repo", 62 + Name: name, 63 + Knot: source.Knot, 64 + CreatedAt: time.Now().UTC().Format(time.RFC3339), 65 + RepoDid: repoDID, 66 + }, 67 + }) 68 + if err != nil { 69 + cleanupErr := deleteFork(ctx, atClient, source.Knot, ownerDID, name) 70 + if cleanupErr != nil { 71 + return fmt.Errorf("write fork record: %w; delete orphaned fork: %v", err, cleanupErr) 72 + } 73 + return fmt.Errorf("write fork record: %w", err) 74 + } 75 + 76 + return output(repoForkResult{Handle: ownerHandle(ctx, ownerDID), Name: name, URI: uri, Knot: source.Knot}, func(fork repoForkResult) { 77 + fmt.Printf("Forked %s/%s as %s/%s\n", handle, sourceName, fork.Handle, fork.Name) 78 + }) 79 + }, 80 + } 81 + 82 + func deleteFork(ctx context.Context, atClient *atproto.ATProto, knotHost, did, name string) error { 83 + token, err := atClient.GetServiceAuth(ctx, "did:web:"+knotHost, "sh.tangled.repo.delete") 84 + if err != nil { 85 + return fmt.Errorf("get knot authorization: %w", err) 86 + } 87 + if err := knot.New(knotHost, token).DeleteRepo(ctx, knot.DeleteRepoInput{DID: did, Name: name, Rkey: name}); err != nil { 88 + return err 89 + } 90 + return nil 91 + } 92 + 93 + type forkSource struct { 94 + URI string 95 + Knot string 96 + } 97 + 98 + func getForkSource(ctx context.Context, handle, name string) (forkSource, error) { 99 + ident, err := resolver.ResolveHandle(ctx, handle) 100 + if err != nil { 101 + return forkSource{}, fmt.Errorf("resolve handle %q: %w", handle, err) 102 + } 103 + uri := fmt.Sprintf("at://%s/sh.tangled.repo/%s", ident.DID, name) 104 + repo, err := client.GetRepo(ctx, uri) 105 + if err != nil { 106 + return forkSource{}, fmt.Errorf("get source repository %s/%s: %w", handle, name, err) 107 + } 108 + if repo.Value.Knot == "" { 109 + return forkSource{}, fmt.Errorf("source repository %s/%s has no knot", handle, name) 110 + } 111 + if repo.URI != "" { 112 + uri = repo.URI 113 + } 114 + return forkSource{URI: uri, Knot: repo.Value.Knot}, nil 115 + } 116 + 117 + type repoForkResult struct { 118 + Handle string `json:"handle"` 119 + Name string `json:"name"` 120 + URI string `json:"uri"` 121 + Knot string `json:"knot"` 122 + }