this repo has no description
0
fork

Configure Feed

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

goat: Go AT protocol CLI tool (#718)

I'm biased towards just merging this, and then iterating. The CLI isn't
totally stable yet.

tracking issue: https://github.com/bluesky-social/indigo/issues/719

authored by

bnewbold and committed by
GitHub
4006c0ec 92b2ec2f

+2317 -1
+167
cmd/goat/README.md
··· 1 + `goat`: Go AT protocol CLI tool 2 + =============================== 3 + 4 + This is a re-implementation of [adenosine-cli](https://gitlab.com/bnewbold/adenosine/-/tree/main/adenosine-cli?ref_type=heads) in golang. 5 + 6 + 7 + ## Install 8 + 9 + If you have the Go toolchain installed and configured correctly, you can directly build and install the tool for your local account: 10 + 11 + ```bash 12 + go install github.com/bluesky-social/indigo/cmd/goat@latest 13 + ``` 14 + 15 + A more manual way to install is: 16 + 17 + ```bash 18 + git clone https://github.com/bluesky-social/indigo 19 + go build ./cmd/goat 20 + sudo cp goat /usr/local/bin 21 + ``` 22 + 23 + The intention is to also provide a Homebrew "cask" and Debian/Ubuntu packages. 24 + 25 + 26 + ## Usage 27 + 28 + `goat` is relatively self-documenting via help pages: 29 + 30 + ```bash 31 + goat --help 32 + goat bsky -h 33 + goat help bsky 34 + # etc 35 + ``` 36 + 37 + Most commands use public APIs are don't require authentication. Some commands, like creating records, require an atproto account. You can log in using an "app password" with `goat account login -u <handle> -p <app-password>`. 38 + 39 + WARNING: `goat` will store both the app password and authentication tokens in the current users home directory, in cleartext. `goat logout` will wipe the file. Intention is to eventually support configuration via environment variables to keep sensitive state in a password manager or otherwise not-cleartext-on-disk. 40 + 41 + Some commands output JSON, and you can use tools like `jq` to process them. 42 + 43 + ## Examples 44 + 45 + Resolve an account's identity in the network: 46 + 47 + ```bash 48 + $ goat resolve wyden.senate.gov 49 + { 50 + "id": "did:plc:ydtsvzzsl6nlfkmnuooeqcmc", 51 + "alsoKnownAs": [ 52 + "at://wyden.senate.gov" 53 + ], 54 + "verificationMethod": [ 55 + { 56 + "id": "did:plc:ydtsvzzsl6nlfkmnuooeqcmc#atproto", 57 + "type": "Multikey", 58 + "controller": "did:plc:ydtsvzzsl6nlfkmnuooeqcmc", 59 + "publicKeyMultibase": "zQ3shuMW7q4KBdsFcdvebGi2EVv8KcqS24tF9Pg7Wh5NLB2NM" 60 + } 61 + ], 62 + "service": [ 63 + { 64 + "id": "#atproto_pds", 65 + "type": "AtprotoPersonalDataServer", 66 + "serviceEndpoint": "https://shimeji.us-east.host.bsky.network" 67 + } 68 + ] 69 + } 70 + ``` 71 + 72 + List record collection types for an account: 73 + 74 + ```bash 75 + $ goat ls -c dril.bsky.social 76 + app.bsky.actor.profile 77 + app.bsky.feed.post 78 + app.bsky.feed.repost 79 + app.bsky.graph.follow 80 + chat.bsky.actor.declaration 81 + ``` 82 + 83 + Fetch a record from the network as JSON: 84 + 85 + ```bash 86 + $ goat get at://dril.bsky.social/app.bsky.feed.post/3kkreaz3amd27 87 + { 88 + "$type": "app.bsky.feed.post", 89 + "createdAt": "2024-02-06T18:15:19.802Z", 90 + "langs": [ 91 + "en" 92 + ], 93 + "text": "I do not Fucking recall them asking the blue sky elders permission to open registration to commoners ." 94 + } 95 + ``` 96 + 97 + Make a public snapshot of your account: 98 + 99 + ```bash 100 + $ goat repo export jay.bsky.team 101 + downloading from https://morel.us-east.host.bsky.network to: jay.bsky.team.20240811183155.car 102 + 103 + $ downloading blobs to: jay.bsky.team_blobs 104 + jay.bsky.team_blobs/bafkreia2x4faux5y7v7v54yl5ebkbaek7z7nhmsd4cooubz3yj4zox34cq downloaded 105 + jay.bsky.team_blobs/bafkreia3qgbww7odprmysd6jcyxoh5sczkwoxinnmzpsp73gs623fqfm3a downloaded 106 + jay.bsky.team_blobs/bafkreia3rgnywdrysy65vid42ulyno2cybxhxrn3ragm7cw3smmsxzvbs4 downloaded 107 + [...] 108 + ``` 109 + 110 + Show PLC history for a single account, or make a snapshot of all PLC records (this takes a while), or monitor new ops: 111 + 112 + ```bash 113 + $ goat plc history atproto.com 114 + [...] 115 + 116 + $ goat plc dump | pv -l | gzip > plc_snapshot.json.gz 117 + [...] 118 + 119 + $ goat plc dump --cursor now --tail 120 + [...] 121 + ``` 122 + 123 + Verify syntax and generate TIDs: 124 + 125 + ```bash 126 + $ goat syntax handle check xn--fiqa61au8b7zsevnm8ak20mc4a87e.xn--fiqs8s 127 + valid 128 + 129 + $ goat syntax rkey check dHJ1ZQ== 130 + error: recordkey syntax didn't validate via regex 131 + 132 + $ goat syntax tid inspect 3kzifvcppte22 133 + Timestamp (UTC): 2024-08-12T02:08:03.29Z 134 + Timestamp (Local): 2024-08-11T19:08:03-07:00 135 + ClockID: 0 136 + uint64: 0x187dcbda2b5ca800 137 + ``` 138 + 139 + The `firehose` commands subscribes to the repo commit stream from a Relay. The default stream outputs event metadata, but doesn't include record blocks (bytes). The `--ops` variant will unpack records and output one line per record operation (instead of one line per commit event), and includes the record values themselves. Some example invocations: 140 + 141 + ```bash 142 + # possible handle updates 143 + $ goat firehose --account-events | jq .payload.handle 144 + [...] 145 + 146 + # text of posts (empty lines for post-deletions) 147 + $ goat firehose - app.bsky.feed.post --ops | jq .record.text 148 + [...] 149 + 150 + # sample ratio of languages in current posts 151 + $ goat firehose --ops -c app.bsky.feed.post | head -n100 | jq .record.langs[0] -c | sort | uniq -c | sort -nr 152 + 51 "en" 153 + 33 "ja" 154 + 7 null 155 + 3 "pt" 156 + 2 "ko" 157 + 1 "th" 158 + 1 "id" 159 + 1 "es" 160 + 1 "am" 161 + ``` 162 + 163 + A minimal bsky posting interface, requires account login: 164 + 165 + ```bash 166 + $ goat bsky post "hello from goat" 167 + ```
+124
cmd/goat/account.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + comatproto "github.com/bluesky-social/indigo/api/atproto" 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + "github.com/bluesky-social/indigo/xrpc" 10 + 11 + "github.com/urfave/cli/v2" 12 + ) 13 + 14 + var cmdAccount = &cli.Command{ 15 + Name: "account", 16 + Usage: "sub-commands for auth and account management", 17 + Flags: []cli.Flag{}, 18 + Subcommands: []*cli.Command{ 19 + &cli.Command{ 20 + Name: "check", 21 + Usage: "verifies current auth session is functional", 22 + Action: runAccountCheck, 23 + }, 24 + &cli.Command{ 25 + Name: "login", 26 + Usage: "create session with PDS instance", 27 + Flags: []cli.Flag{ 28 + &cli.StringFlag{ 29 + Name: "username", 30 + Aliases: []string{"u"}, 31 + Required: true, 32 + Usage: "account identifier (handle or DID)", 33 + EnvVars: []string{"ATP_AUTH_USERNAME"}, 34 + }, 35 + &cli.StringFlag{ 36 + Name: "app-password", 37 + Aliases: []string{"p"}, 38 + Required: true, 39 + Usage: "password (app password recommended)", 40 + EnvVars: []string{"ATP_AUTH_PASSWORD"}, 41 + }, 42 + }, 43 + Action: runAccountLogin, 44 + }, 45 + &cli.Command{ 46 + Name: "logout", 47 + Usage: "delete any current session", 48 + Action: runAccountLogout, 49 + }, 50 + &cli.Command{ 51 + Name: "status", 52 + Usage: "show account status at PDS", 53 + ArgsUsage: `<at-identifier>`, 54 + Action: runAccountStatus, 55 + }, 56 + }, 57 + } 58 + 59 + func runAccountCheck(cctx *cli.Context) error { 60 + ctx := context.Background() 61 + 62 + client, err := loadAuthClient(ctx) 63 + if err == ErrNoAuthSession { 64 + return fmt.Errorf("auth required, but not logged in") 65 + } else if err != nil { 66 + return err 67 + } 68 + // TODO: more explicit check? 69 + fmt.Printf("DID: %s\n", client.Auth.Did) 70 + fmt.Printf("PDS: %s\n", client.Host) 71 + 72 + return nil 73 + } 74 + 75 + func runAccountLogin(cctx *cli.Context) error { 76 + ctx := context.Background() 77 + 78 + username, err := syntax.ParseAtIdentifier(cctx.String("username")) 79 + if err != nil { 80 + return err 81 + } 82 + 83 + _, err = refreshAuthSession(ctx, *username, cctx.String("app-password")) 84 + return err 85 + } 86 + 87 + func runAccountLogout(cctx *cli.Context) error { 88 + return wipeAuthSession() 89 + } 90 + 91 + func runAccountStatus(cctx *cli.Context) error { 92 + ctx := context.Background() 93 + username := cctx.Args().First() 94 + if username == "" { 95 + return fmt.Errorf("need to provide username as an argument") 96 + } 97 + ident, err := resolveIdent(ctx, username) 98 + if err != nil { 99 + return err 100 + } 101 + 102 + // create a new API client to connect to the account's PDS 103 + xrpcc := xrpc.Client{ 104 + Host: ident.PDSEndpoint(), 105 + } 106 + if xrpcc.Host == "" { 107 + return fmt.Errorf("no PDS endpoint for identity") 108 + } 109 + 110 + status, err := comatproto.SyncGetRepoStatus(ctx, &xrpcc, ident.DID.String()) 111 + if err != nil { 112 + return err 113 + } 114 + 115 + fmt.Printf("DID: %s\n", status.Did) 116 + fmt.Printf("Active: %v\n", status.Active) 117 + if status.Status != nil { 118 + fmt.Printf("Status: %s\n", *status.Status) 119 + } 120 + if status.Rev != nil { 121 + fmt.Printf("Repo Rev: %s\n", *status.Rev) 122 + } 123 + return nil 124 + }
+145
cmd/goat/auth.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "errors" 7 + "fmt" 8 + "io/ioutil" 9 + "os" 10 + 11 + comatproto "github.com/bluesky-social/indigo/api/atproto" 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/adrg/xdg" 17 + ) 18 + 19 + var ErrNoAuthSession = errors.New("no auth session found") 20 + 21 + type AuthSession struct { 22 + DID syntax.DID `json:"did"` 23 + Password string `json:"password"` 24 + RefreshToken string `json:"session_token"` 25 + PDS string `json:"pds"` 26 + } 27 + 28 + func persistAuthSession(sess *AuthSession) error { 29 + 30 + fPath, err := xdg.StateFile("goat/auth-session.json") 31 + if err != nil { 32 + return err 33 + } 34 + 35 + f, err := os.OpenFile(fPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) 36 + if err != nil { 37 + return err 38 + } 39 + defer f.Close() 40 + 41 + authBytes, err := json.MarshalIndent(sess, "", " ") 42 + if err != nil { 43 + return err 44 + } 45 + _, err = f.Write(authBytes) 46 + return err 47 + } 48 + 49 + func loadAuthClient(ctx context.Context) (*xrpc.Client, error) { 50 + 51 + // TODO: could also load from env var / cctx 52 + 53 + fPath, err := xdg.SearchStateFile("goat/auth-session.json") 54 + if err != nil { 55 + return nil, ErrNoAuthSession 56 + } 57 + 58 + fBytes, err := ioutil.ReadFile(fPath) 59 + if err != nil { 60 + return nil, err 61 + } 62 + 63 + var sess AuthSession 64 + err = json.Unmarshal(fBytes, &sess) 65 + if err != nil { 66 + return nil, err 67 + } 68 + 69 + client := xrpc.Client{ 70 + Host: sess.PDS, 71 + Auth: &xrpc.AuthInfo{ 72 + Did: sess.DID.String(), 73 + // NOTE: using refresh in access location for "refreshSession" call 74 + AccessJwt: sess.RefreshToken, 75 + RefreshJwt: sess.RefreshToken, 76 + }, 77 + } 78 + resp, err := comatproto.ServerRefreshSession(ctx, &client) 79 + if err != nil { 80 + // TODO: if failure, try creating a new session from password 81 + fmt.Println("trying to refresh auth from password...") 82 + as, err := refreshAuthSession(ctx, sess.DID.AtIdentifier(), sess.Password) 83 + if err != nil { 84 + return nil, err 85 + } 86 + client.Auth.AccessJwt = as.RefreshToken 87 + client.Auth.RefreshJwt = as.RefreshToken 88 + resp, err = comatproto.ServerRefreshSession(ctx, &client) 89 + if err != nil { 90 + return nil, err 91 + } 92 + } 93 + client.Auth.AccessJwt = resp.AccessJwt 94 + client.Auth.RefreshJwt = resp.RefreshJwt 95 + 96 + return &client, nil 97 + } 98 + 99 + func refreshAuthSession(ctx context.Context, username syntax.AtIdentifier, password string) (*AuthSession, error) { 100 + dir := identity.DefaultDirectory() 101 + ident, err := dir.Lookup(ctx, username) 102 + if err != nil { 103 + return nil, err 104 + } 105 + 106 + pdsURL := ident.PDSEndpoint() 107 + if pdsURL == "" { 108 + return nil, fmt.Errorf("empty PDS URL") 109 + } 110 + 111 + client := xrpc.Client{ 112 + Host: pdsURL, 113 + } 114 + sess, err := comatproto.ServerCreateSession(ctx, &client, &comatproto.ServerCreateSession_Input{ 115 + Identifier: ident.DID.String(), 116 + Password: password, 117 + }) 118 + if err != nil { 119 + return nil, err 120 + } 121 + 122 + // TODO: check account status? 123 + // TODO: warn if email isn't verified? 124 + 125 + authSession := AuthSession{ 126 + DID: ident.DID, 127 + Password: password, 128 + PDS: pdsURL, 129 + RefreshToken: sess.RefreshJwt, 130 + } 131 + if err = persistAuthSession(&authSession); err != nil { 132 + return nil, err 133 + } 134 + return &authSession, nil 135 + } 136 + 137 + func wipeAuthSession() error { 138 + 139 + fPath, err := xdg.SearchStateFile("goat/auth-session.json") 140 + if err != nil { 141 + fmt.Printf("no auth session found (already logged out)") 142 + return nil 143 + } 144 + return os.Remove(fPath) 145 + }
+230
cmd/goat/blob.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "os" 9 + 10 + comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/xrpc" 12 + 13 + "github.com/urfave/cli/v2" 14 + ) 15 + 16 + var cmdBlob = &cli.Command{ 17 + Name: "blob", 18 + Usage: "sub-commands for blobs", 19 + Flags: []cli.Flag{}, 20 + Subcommands: []*cli.Command{ 21 + &cli.Command{ 22 + Name: "export", 23 + Usage: "download all blobs for given account", 24 + ArgsUsage: `<at-identifier>`, 25 + Flags: []cli.Flag{ 26 + &cli.StringFlag{ 27 + Name: "output", 28 + Aliases: []string{"o"}, 29 + Usage: "directory to store blobs in", 30 + }, 31 + }, 32 + Action: runBlobExport, 33 + }, 34 + &cli.Command{ 35 + Name: "ls", 36 + Aliases: []string{"list"}, 37 + Usage: "list all blobs for account", 38 + ArgsUsage: `<at-identifier>`, 39 + Flags: []cli.Flag{}, 40 + Action: runBlobList, 41 + }, 42 + &cli.Command{ 43 + Name: "download", 44 + Usage: "download a single blob from an account", 45 + ArgsUsage: `<at-identifier> <cid>`, 46 + Flags: []cli.Flag{ 47 + &cli.StringFlag{ 48 + Name: "output", 49 + Aliases: []string{"o"}, 50 + Usage: "file path to store blob at", 51 + }, 52 + }, 53 + Action: runBlobDownload, 54 + }, 55 + &cli.Command{ 56 + Name: "upload", 57 + Usage: "upload a file", 58 + ArgsUsage: `<file>`, 59 + Flags: []cli.Flag{}, 60 + Action: runBlobUpload, 61 + }, 62 + }, 63 + } 64 + 65 + func runBlobExport(cctx *cli.Context) error { 66 + ctx := context.Background() 67 + username := cctx.Args().First() 68 + if username == "" { 69 + return fmt.Errorf("need to provide username as an argument") 70 + } 71 + ident, err := resolveIdent(ctx, username) 72 + if err != nil { 73 + return err 74 + } 75 + 76 + // create a new API client to connect to the account's PDS 77 + xrpcc := xrpc.Client{ 78 + Host: ident.PDSEndpoint(), 79 + } 80 + if xrpcc.Host == "" { 81 + return fmt.Errorf("no PDS endpoint for identity") 82 + } 83 + 84 + topDir := cctx.String("output") 85 + if topDir == "" { 86 + topDir = fmt.Sprintf("%s_blobs", username) 87 + } 88 + 89 + fmt.Printf("downloading blobs to: %s\n", topDir) 90 + os.MkdirAll(topDir, os.ModePerm) 91 + 92 + cursor := "" 93 + for { 94 + resp, err := comatproto.SyncListBlobs(ctx, &xrpcc, cursor, ident.DID.String(), 500, "") 95 + if err != nil { 96 + return err 97 + } 98 + for _, cidStr := range resp.Cids { 99 + blobPath := topDir + "/" + cidStr 100 + if _, err := os.Stat(blobPath); err == nil { 101 + fmt.Printf("%s\texists\n", blobPath) 102 + continue 103 + } 104 + blobBytes, err := comatproto.SyncGetBlob(ctx, &xrpcc, cidStr, ident.DID.String()) 105 + if err != nil { 106 + return err 107 + } 108 + if err := os.WriteFile(blobPath, blobBytes, 0666); err != nil { 109 + return err 110 + } 111 + fmt.Printf("%s\tdownloaded\n", blobPath) 112 + } 113 + if resp.Cursor != nil && *resp.Cursor != "" { 114 + cursor = *resp.Cursor 115 + } else { 116 + break 117 + } 118 + } 119 + return nil 120 + } 121 + 122 + func runBlobList(cctx *cli.Context) error { 123 + ctx := context.Background() 124 + username := cctx.Args().First() 125 + if username == "" { 126 + return fmt.Errorf("need to provide username as an argument") 127 + } 128 + ident, err := resolveIdent(ctx, username) 129 + if err != nil { 130 + return err 131 + } 132 + 133 + // create a new API client to connect to the account's PDS 134 + xrpcc := xrpc.Client{ 135 + Host: ident.PDSEndpoint(), 136 + } 137 + if xrpcc.Host == "" { 138 + return fmt.Errorf("no PDS endpoint for identity") 139 + } 140 + 141 + cursor := "" 142 + for { 143 + resp, err := comatproto.SyncListBlobs(ctx, &xrpcc, cursor, ident.DID.String(), 500, "") 144 + if err != nil { 145 + return err 146 + } 147 + for _, cidStr := range resp.Cids { 148 + fmt.Println(cidStr) 149 + } 150 + if resp.Cursor != nil && *resp.Cursor != "" { 151 + cursor = *resp.Cursor 152 + } else { 153 + break 154 + } 155 + } 156 + return nil 157 + } 158 + 159 + func runBlobDownload(cctx *cli.Context) error { 160 + ctx := context.Background() 161 + username := cctx.Args().First() 162 + if username == "" { 163 + return fmt.Errorf("need to provide username as an argument") 164 + } 165 + if cctx.Args().Len() < 2 { 166 + return fmt.Errorf("need to provide blob CID as second argument") 167 + } 168 + blobCID := cctx.Args().Get(1) 169 + ident, err := resolveIdent(ctx, username) 170 + if err != nil { 171 + return err 172 + } 173 + 174 + // create a new API client to connect to the account's PDS 175 + xrpcc := xrpc.Client{ 176 + Host: ident.PDSEndpoint(), 177 + } 178 + if xrpcc.Host == "" { 179 + return fmt.Errorf("no PDS endpoint for identity") 180 + } 181 + 182 + blobPath := cctx.String("output") 183 + if blobPath == "" { 184 + blobPath = blobCID 185 + } 186 + 187 + fmt.Printf("downloading blob to: %s\n", blobCID) 188 + 189 + if _, err := os.Stat(blobPath); err == nil { 190 + return fmt.Errorf("file exists: %s", blobPath) 191 + } 192 + blobBytes, err := comatproto.SyncGetBlob(ctx, &xrpcc, blobCID, ident.DID.String()) 193 + if err != nil { 194 + return err 195 + } 196 + return os.WriteFile(blobPath, blobBytes, 0666) 197 + } 198 + 199 + func runBlobUpload(cctx *cli.Context) error { 200 + ctx := context.Background() 201 + blobPath := cctx.Args().First() 202 + if blobPath == "" { 203 + return fmt.Errorf("need to provide file path as an argument") 204 + } 205 + 206 + xrpcc, err := loadAuthClient(ctx) 207 + if err == ErrNoAuthSession { 208 + return fmt.Errorf("auth required, but not logged in") 209 + } else if err != nil { 210 + return err 211 + } 212 + 213 + fileBytes, err := os.ReadFile(blobPath) 214 + if err != nil { 215 + return err 216 + } 217 + 218 + resp, err := comatproto.RepoUploadBlob(ctx, xrpcc, bytes.NewReader(fileBytes)) 219 + if err != nil { 220 + return err 221 + } 222 + 223 + b, err := json.MarshalIndent(resp.Blob, "", " ") 224 + if err != nil { 225 + return err 226 + } 227 + 228 + fmt.Println(string(b)) 229 + return nil 230 + }
+63
cmd/goat/bsky.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + comatproto "github.com/bluesky-social/indigo/api/atproto" 8 + appbsky "github.com/bluesky-social/indigo/api/bsky" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 + 12 + "github.com/urfave/cli/v2" 13 + ) 14 + 15 + var cmdBsky = &cli.Command{ 16 + Name: "bsky", 17 + Usage: "sub-commands for bsky app", 18 + Flags: []cli.Flag{}, 19 + Subcommands: []*cli.Command{ 20 + &cli.Command{ 21 + Name: "post", 22 + Usage: "create a post", 23 + ArgsUsage: `<text>`, 24 + Action: runBskyPost, 25 + }, 26 + }, 27 + } 28 + 29 + func runBskyPost(cctx *cli.Context) error { 30 + ctx := context.Background() 31 + text := cctx.Args().First() 32 + if text == "" { 33 + return fmt.Errorf("need to provide post text as argument") 34 + } 35 + 36 + xrpcc, err := loadAuthClient(ctx) 37 + if err == ErrNoAuthSession { 38 + return fmt.Errorf("auth required, but not logged in") 39 + } else if err != nil { 40 + return err 41 + } 42 + 43 + post := appbsky.FeedPost{ 44 + Text: text, 45 + CreatedAt: syntax.DatetimeNow().String(), 46 + } 47 + resp, err := comatproto.RepoCreateRecord(ctx, xrpcc, &comatproto.RepoCreateRecord_Input{ 48 + Collection: "app.bsky.feed.post", 49 + Repo: xrpcc.Auth.Did, 50 + Record: &lexutil.LexiconTypeDecoder{Val: &post}, 51 + }) 52 + if err != nil { 53 + return err 54 + } 55 + 56 + fmt.Printf("%s\t%s\n", resp.Uri, resp.Cid) 57 + aturi, err := syntax.ParseATURI(resp.Uri) 58 + if err != nil { 59 + return err 60 + } 61 + fmt.Printf("view post at: https://bsky.app/profile/%s/post/%s\n", aturi.Authority(), aturi.RecordKey()) 62 + return nil 63 + }
+104
cmd/goat/crypto.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/bluesky-social/indigo/atproto/crypto" 7 + 8 + "github.com/urfave/cli/v2" 9 + ) 10 + 11 + var cmdCrypto = &cli.Command{ 12 + Name: "crypto", 13 + Usage: "sub-commands for cryptographic keys", 14 + Subcommands: []*cli.Command{ 15 + &cli.Command{ 16 + Name: "generate", 17 + Usage: "outputs a new secret key", 18 + Flags: []cli.Flag{ 19 + &cli.StringFlag{ 20 + Name: "type", 21 + Aliases: []string{"t"}, 22 + Usage: "indicate curve type (P-256 is default)", 23 + }, 24 + }, 25 + Action: runCryptoGenerate, 26 + }, 27 + &cli.Command{ 28 + Name: "inspect", 29 + Usage: "parses and outputs metadata about a public or secret key", 30 + Action: runCryptoInspect, 31 + }, 32 + }, 33 + } 34 + 35 + func runCryptoGenerate(cctx *cli.Context) error { 36 + switch cctx.String("type") { 37 + case "", "P-256", "p256", "ES256", "secp256r1": 38 + priv, err := crypto.GeneratePrivateKeyP256() 39 + if err != nil { 40 + return err 41 + } 42 + fmt.Println(priv.Multibase()) 43 + case "K-256", "k256", "ES256K", "secp256k1": 44 + priv, err := crypto.GeneratePrivateKeyK256() 45 + if err != nil { 46 + return err 47 + } 48 + fmt.Println(priv.Multibase()) 49 + default: 50 + return fmt.Errorf("unknown key type: %s", cctx.String("type")) 51 + } 52 + return nil 53 + } 54 + 55 + func descKeyType(val interface{}) string { 56 + switch val.(type) { 57 + case *crypto.PublicKeyP256, crypto.PublicKeyP256: 58 + return "P-256 / secp256r1 / ES256 public key" 59 + case *crypto.PrivateKeyP256, crypto.PrivateKeyP256: 60 + return "P-256 / secp256r1 / ES256 private key" 61 + case *crypto.PublicKeyK256, crypto.PublicKeyK256: 62 + return "K-256 / secp256k1 / ES256K public key" 63 + case *crypto.PrivateKeyK256, crypto.PrivateKeyK256: 64 + return "K-256 / secp256k1 / ES256K private key" 65 + default: 66 + return "unknown" 67 + } 68 + } 69 + 70 + func runCryptoInspect(cctx *cli.Context) error { 71 + s := cctx.Args().First() 72 + if s == "" { 73 + return fmt.Errorf("need to provide key as an argument") 74 + } 75 + 76 + sec, err := crypto.ParsePrivateMultibase(s) 77 + if nil == err { 78 + fmt.Printf("Type: %s\n", descKeyType(sec)) 79 + fmt.Printf("Encoding: multibase\n") 80 + pub, err := sec.PublicKey() 81 + if err != nil { 82 + return err 83 + } 84 + fmt.Printf("Public (DID Key): %s\n", pub.DIDKey()) 85 + return nil 86 + } 87 + 88 + pub, err := crypto.ParsePublicMultibase(s) 89 + if nil == err { 90 + fmt.Printf("Type: %s\n", descKeyType(pub)) 91 + fmt.Printf("Encoding: multibase\n") 92 + fmt.Printf("As DID Key: %s\n", pub.DIDKey()) 93 + return nil 94 + } 95 + 96 + pub, err = crypto.ParsePublicDIDKey(s) 97 + if nil == err { 98 + fmt.Printf("Type: %s\n", descKeyType(pub)) 99 + fmt.Printf("Encoding: DID Key\n") 100 + fmt.Printf("As Multibase: %s\n", pub.Multibase()) 101 + return nil 102 + } 103 + return fmt.Errorf("unknown key encoding or type") 104 + }
+306
cmd/goat/firehose.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "log/slog" 9 + "net/http" 10 + "net/url" 11 + "os" 12 + "strings" 13 + 14 + comatproto "github.com/bluesky-social/indigo/api/atproto" 15 + "github.com/bluesky-social/indigo/atproto/data" 16 + "github.com/bluesky-social/indigo/atproto/syntax" 17 + "github.com/bluesky-social/indigo/events" 18 + "github.com/bluesky-social/indigo/events/schedulers/parallel" 19 + lexutil "github.com/bluesky-social/indigo/lex/util" 20 + "github.com/bluesky-social/indigo/repo" 21 + "github.com/bluesky-social/indigo/repomgr" 22 + 23 + "github.com/carlmjohnson/versioninfo" 24 + "github.com/gorilla/websocket" 25 + "github.com/urfave/cli/v2" 26 + ) 27 + 28 + var cmdFirehose = &cli.Command{ 29 + Name: "firehose", 30 + Usage: "stream repo and identity events", 31 + Flags: []cli.Flag{ 32 + &cli.StringFlag{ 33 + Name: "relay-host", 34 + Usage: "method, hostname, and port of Relay instance (websocket)", 35 + Value: "wss://bsky.network", 36 + EnvVars: []string{"ATP_RELAY_HOST"}, 37 + }, 38 + &cli.IntFlag{ 39 + Name: "cursor", 40 + Usage: "cursor to consume at", 41 + }, 42 + &cli.StringSliceFlag{ 43 + Name: "collection", 44 + Aliases: []string{"c"}, 45 + Usage: "filter to specific record types (NSID)", 46 + }, 47 + &cli.BoolFlag{ 48 + Name: "account-events", 49 + Usage: "only print account and identity events", 50 + }, 51 + &cli.BoolFlag{ 52 + Name: "ops", 53 + Aliases: []string{"records"}, 54 + Usage: "instead of printing entire events, print individual record ops", 55 + }, 56 + }, 57 + Action: runFirehose, 58 + } 59 + 60 + type GoatFirehoseConsumer struct { 61 + // for pretty-printing events to stdout 62 + EventLogger *slog.Logger 63 + OpsMode bool 64 + AccountsOnly bool 65 + // filter to specified collections 66 + CollectionFilter []string 67 + } 68 + 69 + func runFirehose(cctx *cli.Context) error { 70 + ctx := context.Background() 71 + 72 + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stderr, nil))) 73 + 74 + gfc := GoatFirehoseConsumer{ 75 + EventLogger: slog.New(slog.NewJSONHandler(os.Stdout, nil)), 76 + OpsMode: cctx.Bool("ops"), 77 + AccountsOnly: cctx.Bool("account-events"), 78 + CollectionFilter: cctx.StringSlice("collection"), 79 + } 80 + 81 + relayHost := cctx.String("relay-host") 82 + cursor := cctx.Int("cursor") 83 + 84 + dialer := websocket.DefaultDialer 85 + u, err := url.Parse(relayHost) 86 + if err != nil { 87 + return fmt.Errorf("invalid relayHost URI: %w", err) 88 + } 89 + u.Path = "xrpc/com.atproto.sync.subscribeRepos" 90 + if cursor != 0 { 91 + u.RawQuery = fmt.Sprintf("cursor=%d", cursor) 92 + } 93 + con, _, err := dialer.Dial(u.String(), http.Header{ 94 + "User-Agent": []string{fmt.Sprintf("goat/%s", versioninfo.Short())}, 95 + }) 96 + if err != nil { 97 + return fmt.Errorf("subscribing to firehose failed (dialing): %w", err) 98 + } 99 + 100 + rsc := &events.RepoStreamCallbacks{ 101 + RepoCommit: func(evt *comatproto.SyncSubscribeRepos_Commit) error { 102 + slog.Debug("commit event", "did", evt.Repo, "seq", evt.Seq) 103 + if !gfc.AccountsOnly && !gfc.OpsMode { 104 + return gfc.handleCommitEvent(ctx, evt) 105 + } else if !gfc.AccountsOnly && gfc.OpsMode { 106 + return gfc.handleCommitEventOps(ctx, evt) 107 + } 108 + return nil 109 + }, 110 + RepoIdentity: func(evt *comatproto.SyncSubscribeRepos_Identity) error { 111 + slog.Debug("identity event", "did", evt.Did, "seq", evt.Seq) 112 + if !gfc.OpsMode { 113 + return gfc.handleIdentityEvent(ctx, evt) 114 + } 115 + return nil 116 + }, 117 + RepoAccount: func(evt *comatproto.SyncSubscribeRepos_Account) error { 118 + slog.Debug("account event", "did", evt.Did, "seq", evt.Seq) 119 + if !gfc.OpsMode { 120 + return gfc.handleAccountEvent(ctx, evt) 121 + } 122 + return nil 123 + }, 124 + } 125 + 126 + scheduler := parallel.NewScheduler( 127 + 1, 128 + 100, 129 + relayHost, 130 + rsc.EventHandler, 131 + ) 132 + slog.Info("starting firehose consumer", "relayHost", relayHost) 133 + return events.HandleRepoStream(ctx, con, scheduler) 134 + } 135 + 136 + // TODO: move this to a "ParsePath" helper in syntax package? 137 + func splitRepoPath(path string) (syntax.NSID, syntax.RecordKey, error) { 138 + parts := strings.SplitN(path, "/", 3) 139 + if len(parts) != 2 { 140 + return "", "", fmt.Errorf("invalid record path: %s", path) 141 + } 142 + collection, err := syntax.ParseNSID(parts[0]) 143 + if err != nil { 144 + return "", "", err 145 + } 146 + rkey, err := syntax.ParseRecordKey(parts[1]) 147 + if err != nil { 148 + return "", "", err 149 + } 150 + return collection, rkey, nil 151 + } 152 + 153 + func (gfc *GoatFirehoseConsumer) handleIdentityEvent(ctx context.Context, evt *comatproto.SyncSubscribeRepos_Identity) error { 154 + out := make(map[string]interface{}) 155 + out["type"] = "identity" 156 + out["payload"] = evt 157 + b, err := json.Marshal(out) 158 + if err != nil { 159 + return err 160 + } 161 + fmt.Println(string(b)) 162 + return nil 163 + } 164 + 165 + func (gfc *GoatFirehoseConsumer) handleAccountEvent(ctx context.Context, evt *comatproto.SyncSubscribeRepos_Account) error { 166 + out := make(map[string]interface{}) 167 + out["type"] = "account" 168 + out["payload"] = evt 169 + b, err := json.Marshal(out) 170 + if err != nil { 171 + return err 172 + } 173 + fmt.Println(string(b)) 174 + return nil 175 + } 176 + 177 + // this is the simple version, when not in "records" mode: print the event as JSON, but don't include blocks 178 + func (gfc *GoatFirehoseConsumer) handleCommitEvent(ctx context.Context, evt *comatproto.SyncSubscribeRepos_Commit) error { 179 + 180 + // apply collections filter 181 + if len(gfc.CollectionFilter) > 0 { 182 + keep := false 183 + for _, op := range evt.Ops { 184 + parts := strings.SplitN(op.Path, "/", 3) 185 + if len(parts) != 2 { 186 + slog.Error("invalid record path", "path", op.Path) 187 + return nil 188 + } 189 + collection := parts[0] 190 + for _, c := range gfc.CollectionFilter { 191 + if c == collection { 192 + keep = true 193 + break 194 + } 195 + } 196 + if keep == true { 197 + break 198 + } 199 + } 200 + if !keep { 201 + return nil 202 + } 203 + } 204 + 205 + evt.Blocks = nil 206 + out := make(map[string]interface{}) 207 + out["type"] = "commit" 208 + out["payload"] = evt 209 + b, err := json.Marshal(out) 210 + if err != nil { 211 + return err 212 + } 213 + fmt.Println(string(b)) 214 + return nil 215 + } 216 + 217 + func (gfc *GoatFirehoseConsumer) handleCommitEventOps(ctx context.Context, evt *comatproto.SyncSubscribeRepos_Commit) error { 218 + logger := slog.With("event", "commit", "did", evt.Repo, "rev", evt.Rev, "seq", evt.Seq) 219 + 220 + if evt.TooBig { 221 + logger.Warn("skipping tooBig events for now") 222 + return nil 223 + } 224 + 225 + rr, err := repo.ReadRepoFromCar(ctx, bytes.NewReader(evt.Blocks)) 226 + if err != nil { 227 + logger.Error("failed to read repo from car", "err", err) 228 + return nil 229 + } 230 + 231 + for _, op := range evt.Ops { 232 + collection, rkey, err := splitRepoPath(op.Path) 233 + if err != nil { 234 + logger.Error("invalid path in repo op", "eventKind", op.Action, "path", op.Path) 235 + return nil 236 + } 237 + logger = logger.With("eventKind", op.Action, "collection", collection, "rkey", rkey) 238 + 239 + if len(gfc.CollectionFilter) > 0 { 240 + keep := false 241 + for _, c := range gfc.CollectionFilter { 242 + if collection.String() == c { 243 + keep = true 244 + break 245 + } 246 + } 247 + if keep == false { 248 + continue 249 + } 250 + } 251 + 252 + out := make(map[string]interface{}) 253 + out["seq"] = evt.Seq 254 + out["rev"] = evt.Rev 255 + out["time"] = evt.Time 256 + out["collection"] = collection 257 + out["rkey"] = rkey 258 + 259 + ek := repomgr.EventKind(op.Action) 260 + switch ek { 261 + case repomgr.EvtKindCreateRecord, repomgr.EvtKindUpdateRecord: 262 + // read the record bytes from blocks, and verify CID 263 + rc, recCBOR, err := rr.GetRecordBytes(ctx, op.Path) 264 + if err != nil { 265 + logger.Error("reading record from event blocks (CAR)", "err", err) 266 + break 267 + } 268 + if op.Cid == nil || lexutil.LexLink(rc) != *op.Cid { 269 + logger.Error("mismatch between commit op CID and record block", "recordCID", rc, "opCID", op.Cid) 270 + break 271 + } 272 + 273 + switch ek { 274 + case repomgr.EvtKindCreateRecord: 275 + out["action"] = "create" 276 + case repomgr.EvtKindUpdateRecord: 277 + out["action"] = "update" 278 + default: 279 + logger.Error("impossible event kind", "kind", ek) 280 + break 281 + } 282 + d, err := data.UnmarshalCBOR(*recCBOR) 283 + if err != nil { 284 + slog.Warn("failed to parse record CBOR") 285 + continue 286 + } 287 + out["cid"] = op.Cid.String() 288 + out["record"] = d 289 + b, err := json.Marshal(out) 290 + if err != nil { 291 + return err 292 + } 293 + fmt.Println(string(b)) 294 + case repomgr.EvtKindDeleteRecord: 295 + out["action"] = "delete" 296 + b, err := json.Marshal(out) 297 + if err != nil { 298 + return err 299 + } 300 + fmt.Println(string(b)) 301 + default: 302 + logger.Error("unexpected record op kind") 303 + } 304 + } 305 + return nil 306 + }
+77
cmd/goat/identity.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + 8 + "github.com/bluesky-social/indigo/atproto/identity" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + 11 + "github.com/urfave/cli/v2" 12 + ) 13 + 14 + var cmdResolve = &cli.Command{ 15 + Name: "resolve", 16 + Usage: "lookup identity metadata", 17 + ArgsUsage: `<at-identifier>`, 18 + Flags: []cli.Flag{}, 19 + Action: runResolve, 20 + } 21 + 22 + func runResolve(cctx *cli.Context) error { 23 + ctx := context.Background() 24 + s := cctx.Args().First() 25 + if s == "" { 26 + return fmt.Errorf("need to provide account identifier as an argument") 27 + } 28 + 29 + atid, err := syntax.ParseAtIdentifier(s) 30 + if err != nil { 31 + return err 32 + } 33 + dir := identity.BaseDirectory{} 34 + var doc *identity.DIDDocument 35 + 36 + if atid.IsDID() { 37 + did, err := atid.AsDID() 38 + if err != nil { 39 + return err 40 + } 41 + doc, err = dir.ResolveDID(ctx, did) 42 + if err != nil { 43 + return err 44 + } 45 + } else { 46 + handle, err := atid.AsHandle() 47 + if err != nil { 48 + return err 49 + } 50 + did, err := dir.ResolveHandle(ctx, handle) 51 + if err != nil { 52 + return err 53 + } 54 + doc, err = dir.ResolveDID(ctx, did) 55 + if err != nil { 56 + return err 57 + } 58 + 59 + ident := identity.ParseIdentity(doc) 60 + decl, err := ident.DeclaredHandle() 61 + if err != nil { 62 + return err 63 + } 64 + if handle != decl { 65 + return fmt.Errorf("invalid handle") 66 + } 67 + } 68 + 69 + // TODO: actually print DID doc instead of JSON version of identity 70 + b, err := json.MarshalIndent(doc, "", " ") 71 + if err != nil { 72 + return err 73 + } 74 + 75 + fmt.Println(string(b)) 76 + return nil 77 + }
+42
cmd/goat/main.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + 7 + _ "github.com/joho/godotenv/autoload" 8 + 9 + "github.com/carlmjohnson/versioninfo" 10 + "github.com/urfave/cli/v2" 11 + ) 12 + 13 + func main() { 14 + if err := run(os.Args); err != nil { 15 + fmt.Fprintf(os.Stderr, "error: %v\n", err) 16 + os.Exit(-1) 17 + } 18 + } 19 + 20 + func run(args []string) error { 21 + 22 + app := cli.App{ 23 + Name: "goat", 24 + Usage: "Go AT protocol CLI tool", 25 + Version: versioninfo.Short(), 26 + } 27 + app.Commands = []*cli.Command{ 28 + cmdRecordGet, 29 + cmdRecordList, 30 + cmdFirehose, 31 + cmdResolve, 32 + cmdRepo, 33 + cmdBlob, 34 + cmdAccount, 35 + cmdPLC, 36 + cmdBsky, 37 + cmdRecord, 38 + cmdSyntax, 39 + cmdCrypto, 40 + } 41 + return app.Run(args) 42 + }
+42
cmd/goat/net.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "log/slog" 8 + "net/http" 9 + 10 + "github.com/bluesky-social/indigo/atproto/data" 11 + "github.com/bluesky-social/indigo/atproto/identity" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + ) 14 + 15 + func fetchRecord(ctx context.Context, ident identity.Identity, aturi syntax.ATURI) (any, error) { 16 + pdsURL := ident.PDSEndpoint() 17 + 18 + slog.Debug("fetching record", "did", ident.DID.String(), "collection", aturi.Collection().String(), "rkey", aturi.RecordKey().String()) 19 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", 20 + pdsURL, ident.DID, aturi.Collection(), aturi.RecordKey()) 21 + resp, err := http.Get(url) 22 + if err != nil { 23 + return nil, err 24 + } 25 + if resp.StatusCode != http.StatusOK { 26 + return nil, fmt.Errorf("fetch failed") 27 + } 28 + respBytes, err := io.ReadAll(resp.Body) 29 + if err != nil { 30 + return nil, err 31 + } 32 + 33 + body, err := data.UnmarshalJSON(respBytes) 34 + if err != nil { 35 + return nil, err 36 + } 37 + record, ok := body["value"].(map[string]any) 38 + if !ok { 39 + return nil, fmt.Errorf("fetched record was not an object") 40 + } 41 + return record, nil 42 + }
+205
cmd/goat/plc.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + "strings" 10 + "time" 11 + 12 + "github.com/bluesky-social/indigo/atproto/identity" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + 15 + "github.com/urfave/cli/v2" 16 + ) 17 + 18 + var cmdPLC = &cli.Command{ 19 + Name: "plc", 20 + Usage: "sub-commands for DID PLCs", 21 + Flags: []cli.Flag{ 22 + &cli.StringFlag{ 23 + Name: "plc-directory", 24 + Value: "https://plc.directory", 25 + }, 26 + }, 27 + Subcommands: []*cli.Command{ 28 + cmdPLCHistory, 29 + cmdPLCDump, 30 + }, 31 + } 32 + 33 + var cmdPLCHistory = &cli.Command{ 34 + Name: "history", 35 + Usage: "fetch operation log for individual DID", 36 + ArgsUsage: `<at-identifier>`, 37 + Flags: []cli.Flag{}, 38 + Action: runPLCHistory, 39 + } 40 + 41 + func runPLCHistory(cctx *cli.Context) error { 42 + ctx := context.Background() 43 + plcURL := cctx.String("plc-directory") 44 + s := cctx.Args().First() 45 + if s == "" { 46 + return fmt.Errorf("need to provide account identifier as an argument") 47 + } 48 + 49 + dir := identity.BaseDirectory{ 50 + PLCURL: plcURL, 51 + } 52 + 53 + id, err := syntax.ParseAtIdentifier(s) 54 + if err != nil { 55 + return err 56 + } 57 + var did syntax.DID 58 + if id.IsDID() { 59 + did, err = id.AsDID() 60 + if err != nil { 61 + return err 62 + } 63 + } else { 64 + hdl, err := id.AsHandle() 65 + if err != nil { 66 + return err 67 + } 68 + did, err = dir.ResolveHandle(ctx, hdl) 69 + if err != nil { 70 + return err 71 + } 72 + } 73 + 74 + if did.Method() != "plc" { 75 + return fmt.Errorf("non-PLC DID method: %s", did.Method()) 76 + } 77 + 78 + url := fmt.Sprintf("%s/%s/log", plcURL, did) 79 + resp, err := http.Get(url) 80 + if err != nil { 81 + return err 82 + } 83 + if resp.StatusCode != http.StatusOK { 84 + return fmt.Errorf("PLC HTTP request failed") 85 + } 86 + respBytes, err := io.ReadAll(resp.Body) 87 + if err != nil { 88 + return err 89 + } 90 + 91 + // parse JSON and reformat for printing 92 + var oplog []map[string]interface{} 93 + err = json.Unmarshal(respBytes, &oplog) 94 + if err != nil { 95 + return err 96 + } 97 + 98 + for _, op := range oplog { 99 + b, err := json.MarshalIndent(op, "", " ") 100 + if err != nil { 101 + return err 102 + } 103 + fmt.Println(string(b)) 104 + } 105 + 106 + return nil 107 + } 108 + 109 + var cmdPLCDump = &cli.Command{ 110 + Name: "dump", 111 + Usage: "output full operation log, as JSON lines", 112 + Flags: []cli.Flag{ 113 + &cli.StringFlag{ 114 + Name: "cursor", 115 + }, 116 + &cli.BoolFlag{ 117 + Name: "tail", 118 + }, 119 + }, 120 + Action: runPLCDump, 121 + } 122 + 123 + func runPLCDump(cctx *cli.Context) error { 124 + ctx := context.Background() 125 + plcURL := cctx.String("plc-directory") 126 + client := http.DefaultClient 127 + tailMode := cctx.Bool("tail") 128 + 129 + cursor := cctx.String("cursor") 130 + if cursor == "now" { 131 + cursor = syntax.DatetimeNow().String() 132 + } 133 + var lastCursor string 134 + 135 + req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/export", plcURL), nil) 136 + if err != nil { 137 + return err 138 + } 139 + q := req.URL.Query() 140 + q.Add("count", "1000") 141 + req.URL.RawQuery = q.Encode() 142 + 143 + for { 144 + q := req.URL.Query() 145 + if cursor != "" { 146 + q.Set("after", cursor) 147 + } 148 + req.URL.RawQuery = q.Encode() 149 + 150 + resp, err := client.Do(req) 151 + if err != nil { 152 + return err 153 + } 154 + if resp.StatusCode != http.StatusOK { 155 + return fmt.Errorf("PLC HTTP request failed status=%d", resp.StatusCode) 156 + } 157 + respBytes, err := io.ReadAll(resp.Body) 158 + if err != nil { 159 + return err 160 + } 161 + 162 + lines := strings.Split(string(respBytes), "\n") 163 + if len(lines) == 0 || (len(lines) == 1 && len(lines[0]) == 0) { 164 + if tailMode { 165 + time.Sleep(5 * time.Second) 166 + continue 167 + } 168 + break 169 + } 170 + for _, l := range lines { 171 + if len(l) < 2 { 172 + break 173 + } 174 + var op map[string]interface{} 175 + err = json.Unmarshal([]byte(l), &op) 176 + if err != nil { 177 + return err 178 + } 179 + var ok bool 180 + cursor, ok = op["createdAt"].(string) 181 + if !ok { 182 + return fmt.Errorf("missing createdAt in PLC op log") 183 + } 184 + if cursor == lastCursor { 185 + continue 186 + } 187 + 188 + b, err := json.Marshal(op) 189 + if err != nil { 190 + return err 191 + } 192 + fmt.Println(string(b)) 193 + } 194 + if cursor != "" && cursor == lastCursor { 195 + if tailMode { 196 + time.Sleep(5 * time.Second) 197 + continue 198 + } 199 + break 200 + } 201 + lastCursor = cursor 202 + } 203 + 204 + return nil 205 + }
+355
cmd/goat/record.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "os" 8 + 9 + comatproto "github.com/bluesky-social/indigo/api/atproto" 10 + "github.com/bluesky-social/indigo/atproto/data" 11 + "github.com/bluesky-social/indigo/atproto/identity" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + lexutil "github.com/bluesky-social/indigo/lex/util" 14 + "github.com/bluesky-social/indigo/xrpc" 15 + 16 + "github.com/urfave/cli/v2" 17 + ) 18 + 19 + var 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 + 85 + var 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 + 93 + var 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 + 112 + func 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 + 144 + func 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 + } 159 + if xrpcc.Host == "" { 160 + return fmt.Errorf("no PDS endpoint for identity") 161 + } 162 + 163 + desc, err := comatproto.RepoDescribeRepo(ctx, &xrpcc, ident.DID.String()) 164 + if err != nil { 165 + return err 166 + } 167 + if cctx.Bool("collections") { 168 + for _, nsid := range desc.Collections { 169 + fmt.Printf("%s\n", nsid) 170 + } 171 + return nil 172 + } 173 + collections := desc.Collections 174 + filter := cctx.String("collection") 175 + if filter != "" { 176 + collections = []string{filter} 177 + } 178 + 179 + for _, nsid := range collections { 180 + cursor := "" 181 + for { 182 + // collection string, cursor string, limit int64, repo string, reverse bool, rkeyEnd string, rkeyStart string 183 + resp, err := comatproto.RepoListRecords(ctx, &xrpcc, nsid, cursor, 100, ident.DID.String(), false, "", "") 184 + if err != nil { 185 + return err 186 + } 187 + for _, rec := range resp.Records { 188 + aturi, err := syntax.ParseATURI(rec.Uri) 189 + if err != nil { 190 + return err 191 + } 192 + fmt.Printf("%s\t%s\t%s\n", aturi.Collection(), aturi.RecordKey(), rec.Cid) 193 + } 194 + if resp.Cursor != nil && *resp.Cursor != "" { 195 + cursor = *resp.Cursor 196 + } else { 197 + break 198 + } 199 + } 200 + } 201 + 202 + return nil 203 + } 204 + 205 + func runRecordCreate(cctx *cli.Context) error { 206 + ctx := context.Background() 207 + recordPath := cctx.Args().First() 208 + if recordPath == "" { 209 + return fmt.Errorf("need to provide file path as an argument") 210 + } 211 + 212 + xrpcc, err := loadAuthClient(ctx) 213 + if err == ErrNoAuthSession { 214 + return fmt.Errorf("auth required, but not logged in") 215 + } else if err != nil { 216 + return err 217 + } 218 + 219 + recordBytes, err := os.ReadFile(recordPath) 220 + if err != nil { 221 + return err 222 + } 223 + 224 + _, err = data.UnmarshalJSON(recordBytes) 225 + if err != nil { 226 + return err 227 + } 228 + 229 + nsid, err := data.ExtractTypeJSON(recordBytes) 230 + if err != nil { 231 + return err 232 + } 233 + 234 + // TODO: replace this with something that allows arbitrary Lexicons, instead of needing registered types 235 + var recordVal lexutil.LexiconTypeDecoder 236 + if err = recordVal.UnmarshalJSON(recordBytes); err != nil { 237 + return err 238 + } 239 + 240 + var rkey *string 241 + if cctx.String("rkey") != "" { 242 + rk, err := syntax.ParseRecordKey(cctx.String("rkey")) 243 + if err != nil { 244 + return err 245 + } 246 + s := rk.String() 247 + rkey = &s 248 + } 249 + validate := !cctx.Bool("no-validate") 250 + 251 + resp, err := comatproto.RepoCreateRecord(ctx, xrpcc, &comatproto.RepoCreateRecord_Input{ 252 + Collection: nsid, 253 + Repo: xrpcc.Auth.Did, 254 + Record: &recordVal, 255 + Rkey: rkey, 256 + Validate: &validate, 257 + }) 258 + if err != nil { 259 + return err 260 + } 261 + 262 + fmt.Printf("%s\t%s\n", resp.Uri, resp.Cid) 263 + return nil 264 + } 265 + 266 + func runRecordUpdate(cctx *cli.Context) error { 267 + ctx := context.Background() 268 + recordPath := cctx.Args().First() 269 + if recordPath == "" { 270 + return fmt.Errorf("need to provide file path as an argument") 271 + } 272 + 273 + xrpcc, err := loadAuthClient(ctx) 274 + if err == ErrNoAuthSession { 275 + return fmt.Errorf("auth required, but not logged in") 276 + } else if err != nil { 277 + return err 278 + } 279 + 280 + recordBytes, err := os.ReadFile(recordPath) 281 + if err != nil { 282 + return err 283 + } 284 + 285 + _, err = data.UnmarshalJSON(recordBytes) 286 + if err != nil { 287 + return err 288 + } 289 + 290 + nsid, err := data.ExtractTypeJSON(recordBytes) 291 + if err != nil { 292 + return err 293 + } 294 + 295 + rkey := cctx.String("rkey") 296 + 297 + // 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 (?) 298 + existing, err := comatproto.RepoGetRecord(ctx, xrpcc, "", nsid, xrpcc.Auth.Did, rkey) 299 + if err != nil { 300 + return err 301 + } 302 + 303 + // TODO: replace this with something that allows arbitrary Lexicons, instead of needing registered types 304 + var recordVal lexutil.LexiconTypeDecoder 305 + if err = recordVal.UnmarshalJSON(recordBytes); err != nil { 306 + return err 307 + } 308 + 309 + validate := !cctx.Bool("no-validate") 310 + 311 + resp, err := comatproto.RepoPutRecord(ctx, xrpcc, &comatproto.RepoPutRecord_Input{ 312 + Collection: nsid, 313 + Repo: xrpcc.Auth.Did, 314 + Record: &recordVal, 315 + Rkey: rkey, 316 + Validate: &validate, 317 + SwapRecord: existing.Cid, 318 + }) 319 + if err != nil { 320 + return err 321 + } 322 + 323 + fmt.Printf("%s\t%s\n", resp.Uri, resp.Cid) 324 + return nil 325 + } 326 + 327 + func runRecordDelete(cctx *cli.Context) error { 328 + ctx := context.Background() 329 + 330 + xrpcc, err := loadAuthClient(ctx) 331 + if err == ErrNoAuthSession { 332 + return fmt.Errorf("auth required, but not logged in") 333 + } else if err != nil { 334 + return err 335 + } 336 + 337 + rkey, err := syntax.ParseRecordKey(cctx.String("rkey")) 338 + if err != nil { 339 + return err 340 + } 341 + collection, err := syntax.ParseNSID(cctx.String("collection")) 342 + if err != nil { 343 + return err 344 + } 345 + 346 + err = comatproto.RepoDeleteRecord(ctx, xrpcc, &comatproto.RepoDeleteRecord_Input{ 347 + Collection: collection.String(), 348 + Repo: xrpcc.Auth.Did, 349 + Rkey: rkey.String(), 350 + }) 351 + if err != nil { 352 + return err 353 + } 354 + return nil 355 + }
+234
cmd/goat/repo.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "os" 8 + "path/filepath" 9 + "time" 10 + 11 + comatproto "github.com/bluesky-social/indigo/api/atproto" 12 + "github.com/bluesky-social/indigo/atproto/data" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + "github.com/bluesky-social/indigo/repo" 15 + "github.com/bluesky-social/indigo/xrpc" 16 + 17 + "github.com/ipfs/go-cid" 18 + "github.com/urfave/cli/v2" 19 + ) 20 + 21 + var cmdRepo = &cli.Command{ 22 + Name: "repo", 23 + Usage: "sub-commands for repositories", 24 + Flags: []cli.Flag{}, 25 + Subcommands: []*cli.Command{ 26 + &cli.Command{ 27 + Name: "export", 28 + Usage: "download CAR file for given account", 29 + ArgsUsage: `<at-identifier>`, 30 + Flags: []cli.Flag{ 31 + &cli.StringFlag{ 32 + Name: "output", 33 + Aliases: []string{"o"}, 34 + Usage: "file path for CAR download", 35 + }, 36 + }, 37 + Action: runRepoExport, 38 + }, 39 + &cli.Command{ 40 + Name: "ls", 41 + Aliases: []string{"list"}, 42 + Usage: "list records in CAR file", 43 + ArgsUsage: `<car-file>`, 44 + Flags: []cli.Flag{}, 45 + Action: runRepoList, 46 + }, 47 + &cli.Command{ 48 + Name: "inspect", 49 + Usage: "show commit metadata from CAR file", 50 + ArgsUsage: `<car-file>`, 51 + Flags: []cli.Flag{}, 52 + Action: runRepoInspect, 53 + }, 54 + &cli.Command{ 55 + Name: "unpack", 56 + Usage: "extract records from CAR file as directory of JSON files", 57 + ArgsUsage: `<car-file>`, 58 + Flags: []cli.Flag{ 59 + &cli.StringFlag{ 60 + Name: "output", 61 + Aliases: []string{"o"}, 62 + Usage: "directory path for unpack", 63 + }, 64 + }, 65 + Action: runRepoUnpack, 66 + }, 67 + }, 68 + } 69 + 70 + func runRepoExport(cctx *cli.Context) error { 71 + ctx := context.Background() 72 + username := cctx.Args().First() 73 + if username == "" { 74 + return fmt.Errorf("need to provide username as an argument") 75 + } 76 + ident, err := resolveIdent(ctx, username) 77 + if err != nil { 78 + return err 79 + } 80 + 81 + // create a new API client to connect to the account's PDS 82 + xrpcc := xrpc.Client{ 83 + Host: ident.PDSEndpoint(), 84 + } 85 + if xrpcc.Host == "" { 86 + return fmt.Errorf("no PDS endpoint for identity") 87 + } 88 + 89 + carPath := cctx.String("output") 90 + if carPath == "" { 91 + // NOTE: having the rev in the the path might be nice 92 + now := time.Now().Format("20060102150405") 93 + carPath = fmt.Sprintf("%s.%s.car", username, now) 94 + } 95 + // NOTE: there is a race condition, but nice to give a friendly error earlier before downloading 96 + if _, err := os.Stat(carPath); err == nil { 97 + return fmt.Errorf("file already exists: %s", carPath) 98 + } 99 + fmt.Printf("downloading from %s to: %s\n", xrpcc.Host, carPath) 100 + repoBytes, err := comatproto.SyncGetRepo(ctx, &xrpcc, ident.DID.String(), "") 101 + if err != nil { 102 + return err 103 + } 104 + return os.WriteFile(carPath, repoBytes, 0666) 105 + } 106 + 107 + func runRepoList(cctx *cli.Context) error { 108 + ctx := context.Background() 109 + carPath := cctx.Args().First() 110 + if carPath == "" { 111 + return fmt.Errorf("need to provide path to CAR file as argument") 112 + } 113 + fi, err := os.Open(carPath) 114 + if err != nil { 115 + return err 116 + } 117 + 118 + // read repository tree in to memory 119 + r, err := repo.ReadRepoFromCar(ctx, fi) 120 + if err != nil { 121 + return err 122 + } 123 + 124 + err = r.ForEach(ctx, "", func(k string, v cid.Cid) error { 125 + fmt.Printf("%s\t%s\n", k, v.String()) 126 + return nil 127 + }) 128 + if err != nil { 129 + return err 130 + } 131 + return nil 132 + } 133 + 134 + func runRepoInspect(cctx *cli.Context) error { 135 + ctx := context.Background() 136 + carPath := cctx.Args().First() 137 + if carPath == "" { 138 + return fmt.Errorf("need to provide path to CAR file as argument") 139 + } 140 + fi, err := os.Open(carPath) 141 + if err != nil { 142 + return err 143 + } 144 + 145 + // read repository tree in to memory 146 + r, err := repo.ReadRepoFromCar(ctx, fi) 147 + if err != nil { 148 + return err 149 + } 150 + 151 + sc := r.SignedCommit() 152 + fmt.Printf("ATProto Repo Spec Version: %d\n", sc.Version) 153 + fmt.Printf("DID: %s\n", sc.Did) 154 + fmt.Printf("Data CID: %s\n", sc.Data) 155 + fmt.Printf("Prev CID: %s\n", sc.Prev) 156 + fmt.Printf("Revision: %s\n", sc.Rev) 157 + // TODO: Signature? 158 + 159 + return nil 160 + } 161 + 162 + func runRepoUnpack(cctx *cli.Context) error { 163 + ctx := context.Background() 164 + carPath := cctx.Args().First() 165 + if carPath == "" { 166 + return fmt.Errorf("need to provide path to CAR file as argument") 167 + } 168 + fi, err := os.Open(carPath) 169 + if err != nil { 170 + return err 171 + } 172 + 173 + r, err := repo.ReadRepoFromCar(ctx, fi) 174 + if err != nil { 175 + return err 176 + } 177 + 178 + // extract DID from repo commit 179 + sc := r.SignedCommit() 180 + did, err := syntax.ParseDID(sc.Did) 181 + if err != nil { 182 + return err 183 + } 184 + 185 + topDir := cctx.String("output") 186 + if topDir == "" { 187 + topDir = did.String() 188 + } 189 + fmt.Printf("writing output to: %s\n", topDir) 190 + 191 + // first the commit object as a meta file 192 + commitPath := topDir + "/_commit.json" 193 + os.MkdirAll(filepath.Dir(commitPath), os.ModePerm) 194 + commitJSON, err := json.MarshalIndent(sc, "", " ") 195 + if err != nil { 196 + return err 197 + } 198 + if err := os.WriteFile(commitPath, commitJSON, 0666); err != nil { 199 + return err 200 + } 201 + 202 + // then all the actual records 203 + err = r.ForEach(ctx, "", func(k string, v cid.Cid) error { 204 + _, recBytes, err := r.GetRecordBytes(ctx, k) 205 + if err != nil { 206 + return err 207 + } 208 + 209 + rec, err := data.UnmarshalCBOR(*recBytes) 210 + if err != nil { 211 + return err 212 + } 213 + 214 + recPath := topDir + "/" + k 215 + fmt.Printf("%s.json\n", recPath) 216 + os.MkdirAll(filepath.Dir(recPath), os.ModePerm) 217 + if err != nil { 218 + return err 219 + } 220 + recJSON, err := json.MarshalIndent(rec, "", " ") 221 + if err != nil { 222 + return err 223 + } 224 + if err := os.WriteFile(recPath+".json", recJSON, 0666); err != nil { 225 + return err 226 + } 227 + 228 + return nil 229 + }) 230 + if err != nil { 231 + return err 232 + } 233 + return nil 234 + }
+199
cmd/goat/syntax.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "time" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + 9 + "github.com/urfave/cli/v2" 10 + ) 11 + 12 + var cmdSyntax = &cli.Command{ 13 + Name: "syntax", 14 + Usage: "sub-commands for string syntax helpers", 15 + Subcommands: []*cli.Command{ 16 + &cli.Command{ 17 + Name: "tid", 18 + Usage: "sub-commands for TIDs", 19 + Subcommands: []*cli.Command{ 20 + &cli.Command{ 21 + Name: "check", 22 + Usage: "validates TID syntax", 23 + ArgsUsage: `<tid>`, 24 + Action: runSyntaxTIDCheck, 25 + }, 26 + &cli.Command{ 27 + Name: "inspect", 28 + Usage: "parses a TID to timestamp", 29 + ArgsUsage: `<tid>`, 30 + Action: runSyntaxTIDInspect, 31 + }, 32 + &cli.Command{ 33 + Name: "generate", 34 + Usage: "outputs a new TID", 35 + Aliases: []string{"now"}, 36 + Action: runSyntaxTIDGenerate, 37 + }, 38 + }, 39 + }, 40 + &cli.Command{ 41 + Name: "handle", 42 + Usage: "sub-commands for handle syntax", 43 + Subcommands: []*cli.Command{ 44 + &cli.Command{ 45 + Name: "check", 46 + Usage: "validates handle syntax", 47 + ArgsUsage: `<handle>`, 48 + Action: runSyntaxHandleCheck, 49 + }, 50 + }, 51 + }, 52 + &cli.Command{ 53 + Name: "did", 54 + Usage: "sub-commands for DID syntax", 55 + Subcommands: []*cli.Command{ 56 + &cli.Command{ 57 + Name: "check", 58 + Usage: "validates DID syntax", 59 + ArgsUsage: `<did>`, 60 + Action: runSyntaxDIDCheck, 61 + }, 62 + }, 63 + }, 64 + &cli.Command{ 65 + Name: "rkey", 66 + Usage: "sub-commands for record key syntax", 67 + Subcommands: []*cli.Command{ 68 + &cli.Command{ 69 + Name: "check", 70 + Usage: "validates record key syntax", 71 + ArgsUsage: `<rkey>`, 72 + Action: runSyntaxRecordKeyCheck, 73 + }, 74 + }, 75 + }, 76 + &cli.Command{ 77 + Name: "nsid", 78 + Usage: "sub-commands for NSID syntax", 79 + Subcommands: []*cli.Command{ 80 + &cli.Command{ 81 + Name: "check", 82 + Usage: "validates NSID syntax", 83 + ArgsUsage: `<nsid>`, 84 + Action: runSyntaxNSIDCheck, 85 + }, 86 + }, 87 + }, 88 + &cli.Command{ 89 + Name: "at-uri", 90 + Usage: "sub-commands for AT-URI syntax", 91 + Subcommands: []*cli.Command{ 92 + &cli.Command{ 93 + Name: "check", 94 + Usage: "validates AT-URI syntax", 95 + ArgsUsage: `<uri>`, 96 + Action: runSyntaxATURICheck, 97 + }, 98 + }, 99 + }, 100 + }, 101 + } 102 + 103 + func runSyntaxTIDCheck(cctx *cli.Context) error { 104 + s := cctx.Args().First() 105 + if s == "" { 106 + return fmt.Errorf("need to provide identifier as argument") 107 + } 108 + _, err := syntax.ParseTID(s) 109 + if err != nil { 110 + return err 111 + } 112 + fmt.Println("valid") 113 + return nil 114 + } 115 + 116 + func runSyntaxTIDGenerate(cctx *cli.Context) error { 117 + fmt.Printf("%s\n", syntax.NewTIDNow(0).String()) 118 + return nil 119 + } 120 + 121 + func runSyntaxTIDInspect(cctx *cli.Context) error { 122 + s := cctx.Args().First() 123 + if s == "" { 124 + return fmt.Errorf("need to provide identifier as argument") 125 + } 126 + tid, err := syntax.ParseTID(s) 127 + if err != nil { 128 + return err 129 + } 130 + fmt.Printf("Timestamp (UTC): %s\n", tid.Time().Format(syntax.AtprotoDatetimeLayout)) 131 + fmt.Printf("Timestamp (Local): %s\n", tid.Time().Local().Format(time.RFC3339)) 132 + fmt.Printf("ClockID: %d\n", tid.ClockID()) 133 + fmt.Printf("uint64: 0x%x\n", tid.Integer()) 134 + return nil 135 + } 136 + 137 + func runSyntaxRecordKeyCheck(cctx *cli.Context) error { 138 + s := cctx.Args().First() 139 + if s == "" { 140 + return fmt.Errorf("need to provide identifier as argument") 141 + } 142 + _, err := syntax.ParseRecordKey(s) 143 + if err != nil { 144 + return err 145 + } 146 + fmt.Println("valid") 147 + return nil 148 + } 149 + 150 + func runSyntaxDIDCheck(cctx *cli.Context) error { 151 + s := cctx.Args().First() 152 + if s == "" { 153 + return fmt.Errorf("need to provide identifier as argument") 154 + } 155 + _, err := syntax.ParseDID(s) 156 + if err != nil { 157 + return err 158 + } 159 + fmt.Println("valid") 160 + return nil 161 + } 162 + func runSyntaxHandleCheck(cctx *cli.Context) error { 163 + s := cctx.Args().First() 164 + if s == "" { 165 + return fmt.Errorf("need to provide identifier as argument") 166 + } 167 + _, err := syntax.ParseHandle(s) 168 + if err != nil { 169 + return err 170 + } 171 + fmt.Println("valid") 172 + return nil 173 + } 174 + 175 + func runSyntaxNSIDCheck(cctx *cli.Context) error { 176 + s := cctx.Args().First() 177 + if s == "" { 178 + return fmt.Errorf("need to provide identifier as argument") 179 + } 180 + _, err := syntax.ParseNSID(s) 181 + if err != nil { 182 + return err 183 + } 184 + fmt.Println("valid") 185 + return nil 186 + } 187 + 188 + func runSyntaxATURICheck(cctx *cli.Context) error { 189 + s := cctx.Args().First() 190 + if s == "" { 191 + return fmt.Errorf("need to provide identifier as argument") 192 + } 193 + _, err := syntax.ParseATURI(s) 194 + if err != nil { 195 + return err 196 + } 197 + fmt.Println("valid") 198 + return nil 199 + }
+18
cmd/goat/util.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + 6 + "github.com/bluesky-social/indigo/atproto/identity" 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + ) 9 + 10 + func resolveIdent(ctx context.Context, arg string) (*identity.Identity, error) { 11 + id, err := syntax.ParseAtIdentifier(arg) 12 + if err != nil { 13 + return nil, err 14 + } 15 + 16 + dir := identity.DefaultDirectory() 17 + return dir.Lookup(ctx, *id) 18 + }
+2 -1
go.mod
··· 76 76 ) 77 77 78 78 require ( 79 + github.com/adrg/xdg v0.5.0 // indirect 79 80 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 80 81 github.com/go-redis/redis v6.15.9+incompatible // indirect 81 82 github.com/hashicorp/golang-lru v1.0.2 // indirect ··· 163 164 go.uber.org/multierr v1.11.0 // indirect 164 165 golang.org/x/mod v0.14.0 // indirect 165 166 golang.org/x/net v0.21.0 // indirect 166 - golang.org/x/sys v0.18.0 // indirect 167 + golang.org/x/sys v0.22.0 // indirect 167 168 google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f // indirect 168 169 google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // indirect 169 170 google.golang.org/grpc v1.59.0 // indirect
+4
go.sum
··· 39 39 github.com/PuerkitoBio/purell v1.2.1/go.mod h1:ZwHcC/82TOaovDi//J/804umJFFmbOHPngi8iYYv/Eo= 40 40 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b h1:5/++qT1/z812ZqBvqQt6ToRswSuPZ/B33m6xVHRzADU= 41 41 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b/go.mod h1:4+EPqMRApwwE/6yo6CxiHoSnBzjRr3jsqer7frxP8y4= 42 + github.com/adrg/xdg v0.5.0 h1:dDaZvhMXatArP1NPHhnfaQUqWBLBsmx1h1HXQdMoFCY= 43 + github.com/adrg/xdg v0.5.0/go.mod h1:dDdY4M4DF9Rjy4kHPeNL+ilVF+p2lK8IdM9/rTSGcI4= 42 44 github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 43 45 github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 44 46 github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= ··· 873 875 golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 874 876 golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 875 877 golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 878 + golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= 879 + golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 876 880 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 877 881 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 878 882 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=