1package main
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8
9 "github.com/bluesky-social/indigo/api/agnostic"
10 comatproto "github.com/bluesky-social/indigo/api/atproto"
11 "github.com/bluesky-social/indigo/atproto/data"
12 "github.com/bluesky-social/indigo/atproto/identity"
13 "github.com/bluesky-social/indigo/atproto/syntax"
14 "github.com/bluesky-social/indigo/xrpc"
15
16 "github.com/urfave/cli/v2"
17)
18
19var cmdRecord = &cli.Command{
20 Name: "record",
21 Usage: "sub-commands for repo records",
22 Flags: []cli.Flag{},
23 Subcommands: []*cli.Command{
24 cmdRecordGet,
25 cmdRecordList,
26 &cli.Command{
27 Name: "create",
28 Usage: "create record from JSON",
29 ArgsUsage: `<file>`,
30 Flags: []cli.Flag{
31 &cli.StringFlag{
32 Name: "rkey",
33 Aliases: []string{"r"},
34 Usage: "record key",
35 },
36 &cli.BoolFlag{
37 Name: "no-validate",
38 Aliases: []string{"n"},
39 Usage: "tells PDS not to validate record Lexicon schema",
40 },
41 },
42 Action: runRecordCreate,
43 },
44 &cli.Command{
45 Name: "update",
46 Usage: "replace existing record from JSON",
47 ArgsUsage: `<file>`,
48 Flags: []cli.Flag{
49 &cli.StringFlag{
50 Name: "rkey",
51 Aliases: []string{"r"},
52 Required: true,
53 Usage: "record key",
54 },
55 &cli.BoolFlag{
56 Name: "no-validate",
57 Aliases: []string{"n"},
58 Usage: "tells PDS not to validate record Lexicon schema",
59 },
60 },
61 Action: runRecordUpdate,
62 },
63 &cli.Command{
64 Name: "delete",
65 Usage: "delete an existing record",
66 Flags: []cli.Flag{
67 &cli.StringFlag{
68 Name: "collection",
69 Aliases: []string{"c"},
70 Required: true,
71 Usage: "collection (NSID)",
72 },
73 &cli.StringFlag{
74 Name: "rkey",
75 Aliases: []string{"r"},
76 Required: true,
77 Usage: "record key",
78 },
79 },
80 Action: runRecordDelete,
81 },
82 },
83}
84
85var cmdRecordGet = &cli.Command{
86 Name: "get",
87 Usage: "fetch record from the network",
88 ArgsUsage: `<at-uri>`,
89 Flags: []cli.Flag{},
90 Action: runRecordGet,
91}
92
93var cmdRecordList = &cli.Command{
94 Name: "ls",
95 Aliases: []string{"list"},
96 Usage: "list all records for an account",
97 ArgsUsage: `<at-identifier>`,
98 Flags: []cli.Flag{
99 &cli.StringFlag{
100 Name: "collection",
101 Usage: "only list records from a specific collection",
102 },
103 &cli.BoolFlag{
104 Name: "collections",
105 Aliases: []string{"c"},
106 Usage: "list collections, not individual record paths",
107 },
108 },
109 Action: runRecordList,
110}
111
112func runRecordGet(cctx *cli.Context) error {
113 ctx := context.Background()
114 dir := identity.DefaultDirectory()
115
116 uriArg := cctx.Args().First()
117 if uriArg == "" {
118 return fmt.Errorf("expected a single AT-URI argument")
119 }
120
121 aturi, err := syntax.ParseATURI(uriArg)
122 if err != nil {
123 return fmt.Errorf("not a valid AT-URI: %v", err)
124 }
125 ident, err := dir.Lookup(ctx, aturi.Authority())
126 if err != nil {
127 return err
128 }
129
130 record, err := fetchRecord(ctx, *ident, aturi)
131 if err != nil {
132 return err
133 }
134
135 b, err := json.MarshalIndent(record, "", " ")
136 if err != nil {
137 return err
138 }
139
140 fmt.Println(string(b))
141 return nil
142}
143
144func runRecordList(cctx *cli.Context) error {
145 ctx := context.Background()
146 username := cctx.Args().First()
147 if username == "" {
148 return fmt.Errorf("need to provide username as an argument")
149 }
150 ident, err := resolveIdent(ctx, username)
151 if err != nil {
152 return err
153 }
154
155 // create a new API client to connect to the account's PDS
156 xrpcc := xrpc.Client{
157 Host: ident.PDSEndpoint(),
158 UserAgent: userAgent(),
159 }
160 if xrpcc.Host == "" {
161 return fmt.Errorf("no PDS endpoint for identity")
162 }
163
164 desc, err := comatproto.RepoDescribeRepo(ctx, &xrpcc, ident.DID.String())
165 if err != nil {
166 return err
167 }
168 if cctx.Bool("collections") {
169 for _, nsid := range desc.Collections {
170 fmt.Printf("%s\n", nsid)
171 }
172 return nil
173 }
174 collections := desc.Collections
175 filter := cctx.String("collection")
176 if filter != "" {
177 collections = []string{filter}
178 }
179
180 for _, nsid := range collections {
181 cursor := ""
182 for {
183 // collection string, cursor string, limit int64, repo string, reverse bool
184 resp, err := agnostic.RepoListRecords(ctx, &xrpcc, nsid, cursor, 100, ident.DID.String(), false)
185 if err != nil {
186 return err
187 }
188 for _, rec := range resp.Records {
189 aturi, err := syntax.ParseATURI(rec.Uri)
190 if err != nil {
191 return err
192 }
193 fmt.Printf("%s\t%s\t%s\n", aturi.Collection(), aturi.RecordKey(), rec.Cid)
194 }
195 if resp.Cursor != nil && *resp.Cursor != "" {
196 cursor = *resp.Cursor
197 } else {
198 break
199 }
200 }
201 }
202
203 return nil
204}
205
206func runRecordCreate(cctx *cli.Context) error {
207 ctx := context.Background()
208 recordPath := cctx.Args().First()
209 if recordPath == "" {
210 return fmt.Errorf("need to provide file path as an argument")
211 }
212
213 xrpcc, err := loadAuthClient(ctx)
214 if err == ErrNoAuthSession {
215 return fmt.Errorf("auth required, but not logged in")
216 } else if err != nil {
217 return err
218 }
219
220 recordBytes, err := os.ReadFile(recordPath)
221 if err != nil {
222 return err
223 }
224
225 recordVal, err := data.UnmarshalJSON(recordBytes)
226 if err != nil {
227 return err
228 }
229
230 nsid, err := data.ExtractTypeJSON(recordBytes)
231 if err != nil {
232 return err
233 }
234
235 var rkey *string
236 if cctx.String("rkey") != "" {
237 rk, err := syntax.ParseRecordKey(cctx.String("rkey"))
238 if err != nil {
239 return err
240 }
241 s := rk.String()
242 rkey = &s
243 }
244 validate := !cctx.Bool("no-validate")
245
246 resp, err := agnostic.RepoCreateRecord(ctx, xrpcc, &agnostic.RepoCreateRecord_Input{
247 Collection: nsid,
248 Repo: xrpcc.Auth.Did,
249 Record: recordVal,
250 Rkey: rkey,
251 Validate: &validate,
252 })
253 if err != nil {
254 return err
255 }
256
257 fmt.Printf("%s\t%s\n", resp.Uri, resp.Cid)
258 return nil
259}
260
261func runRecordUpdate(cctx *cli.Context) error {
262 ctx := context.Background()
263 recordPath := cctx.Args().First()
264 if recordPath == "" {
265 return fmt.Errorf("need to provide file path as an argument")
266 }
267
268 xrpcc, err := loadAuthClient(ctx)
269 if err == ErrNoAuthSession {
270 return fmt.Errorf("auth required, but not logged in")
271 } else if err != nil {
272 return err
273 }
274
275 recordBytes, err := os.ReadFile(recordPath)
276 if err != nil {
277 return err
278 }
279
280 recordVal, err := data.UnmarshalJSON(recordBytes)
281 if err != nil {
282 return err
283 }
284
285 nsid, err := data.ExtractTypeJSON(recordBytes)
286 if err != nil {
287 return err
288 }
289
290 rkey := cctx.String("rkey")
291
292 // NOTE: need to fetch existing record CID to perform swap. this is optional in theory, but golang can't deal with "optional" and "nullable", so we always need to set this (?)
293 existing, err := agnostic.RepoGetRecord(ctx, xrpcc, "", nsid, xrpcc.Auth.Did, rkey)
294 if err != nil {
295 return err
296 }
297
298 validate := !cctx.Bool("no-validate")
299
300 resp, err := agnostic.RepoPutRecord(ctx, xrpcc, &agnostic.RepoPutRecord_Input{
301 Collection: nsid,
302 Repo: xrpcc.Auth.Did,
303 Record: recordVal,
304 Rkey: rkey,
305 Validate: &validate,
306 SwapRecord: existing.Cid,
307 })
308 if err != nil {
309 return err
310 }
311
312 fmt.Printf("%s\t%s\n", resp.Uri, resp.Cid)
313 return nil
314}
315
316func runRecordDelete(cctx *cli.Context) error {
317 ctx := context.Background()
318
319 xrpcc, err := loadAuthClient(ctx)
320 if err == ErrNoAuthSession {
321 return fmt.Errorf("auth required, but not logged in")
322 } else if err != nil {
323 return err
324 }
325
326 rkey, err := syntax.ParseRecordKey(cctx.String("rkey"))
327 if err != nil {
328 return err
329 }
330 collection, err := syntax.ParseNSID(cctx.String("collection"))
331 if err != nil {
332 return err
333 }
334
335 _, err = comatproto.RepoDeleteRecord(ctx, xrpcc, &comatproto.RepoDeleteRecord_Input{
336 Collection: collection.String(),
337 Repo: xrpcc.Auth.Did,
338 Rkey: rkey.String(),
339 })
340 if err != nil {
341 return err
342 }
343 return nil
344}