Small tool to make it convenient to deduplicate records in collections, use goat instead.
cli atproto go
at main 153 lines 3.5 kB view raw
1package main 2 3import ( 4 "bufio" 5 "context" 6 "encoding/json" 7 "fmt" 8 "os" 9 "strings" 10 11 "github.com/urfave/cli/v3" 12) 13 14var Version = "dev" 15 16func main() { 17 cmd := &cli.Command{ 18 Name: "record-deduper", 19 Version: Version, 20 Usage: "Tool for listing and deduplicating atproto records", 21 Commands: []*cli.Command{ 22 { 23 Name: "list", 24 Usage: "List atproto records for a specific handle", 25 Flags: []cli.Flag{ 26 &cli.StringFlag{ 27 Name: "handle", 28 Aliases: []string{"h"}, 29 Usage: "The atproto handle or DID", 30 Required: true, 31 }, 32 &cli.StringFlag{ 33 Name: "collection", 34 Aliases: []string{"c"}, 35 Usage: "The record collection (e.g., app.bsky.feed.post)", 36 Required: true, 37 }, 38 &cli.IntFlag{ 39 Name: "limit", 40 Aliases: []string{"l"}, 41 Usage: "Maximum number of records to fetch per page", 42 Value: 50, 43 }, 44 &cli.StringFlag{ 45 Name: "cursor", 46 Aliases: []string{"k"}, 47 Usage: "Cursor to resume from", 48 }, 49 }, 50 Action: listRecords, 51 }, 52 { 53 Name: "delete", 54 Usage: "Delete atproto records", 55 Flags: []cli.Flag{ 56 &cli.StringSliceFlag{ 57 Name: "uri", 58 Aliases: []string{"u"}, 59 Usage: "The full record URIs (e.g., at://did:plc:xxx/collection/rkey)", 60 }, 61 &cli.StringFlag{ 62 Name: "handle", 63 Aliases: []string{"h"}, 64 Usage: "Handle or DID for authentication", 65 Required: true, 66 }, 67 &cli.StringFlag{ 68 Name: "password", 69 Aliases: []string{"p"}, 70 Usage: "App password", 71 Required: true, 72 }, 73 &cli.BoolFlag{ 74 Name: "dry-run", 75 Aliases: []string{"d"}, 76 Usage: "Print what would be deleted without actually deleting", 77 Value: true, 78 }, 79 }, 80 Action: deleteRecord, 81 }, 82 }, 83 } 84 85 if err := cmd.Run(context.Background(), os.Args); err != nil { 86 fmt.Fprintf(os.Stderr, "Error: %v\n", err) 87 os.Exit(1) 88 } 89} 90 91func listRecords(ctx context.Context, cmd *cli.Command) error { 92 handle := cmd.String("handle") 93 collection := cmd.String("collection") 94 limit := cmd.Int("limit") 95 cursor := cmd.String("cursor") 96 97 recordCh, errCh := fetchRecords(ctx, handle, collection, limit, cursor) 98 99 for { 100 select { 101 case record, ok := <-recordCh: 102 if !ok { 103 return nil 104 } 105 line, err := json.Marshal(record) 106 if err != nil { 107 return fmt.Errorf("failed to marshal record: %w", err) 108 } 109 fmt.Println(string(line)) 110 case err := <-errCh: 111 return err 112 } 113 } 114} 115 116func deleteRecord(ctx context.Context, cmd *cli.Command) error { 117 uris := cmd.StringSlice("uri") 118 handle := cmd.String("handle") 119 password := cmd.String("password") 120 dryRun := cmd.Bool("dry-run") 121 122 stat, _ := os.Stdin.Stat() 123 if stat != nil && (stat.Mode()&os.ModeCharDevice) == 0 { 124 scanner := bufio.NewScanner(os.Stdin) 125 for scanner.Scan() { 126 line := strings.TrimSpace(scanner.Text()) 127 if line != "" { 128 uris = append(uris, line) 129 } 130 } 131 if err := scanner.Err(); err != nil { 132 return fmt.Errorf("failed to read stdin: %w", err) 133 } 134 } 135 136 if len(uris) == 0 { 137 return fmt.Errorf("no URIs provided (use --uri or pipe via stdin)") 138 } 139 140 if dryRun { 141 for _, uri := range uris { 142 fmt.Printf("[DRY RUN] Would delete: %s\n", uri) 143 } 144 return nil 145 } 146 147 client, err := createAuthenticatedClient(ctx, handle, password) 148 if err != nil { 149 return fmt.Errorf("failed to create client: %w", err) 150 } 151 152 return deleteRecords(ctx, client.client, uris) 153}