go scratch code for atproto

basic publish support

+216
+1
cmd/glot/main.go
··· 35 35 cmdDiff, 36 36 cmdCompat, 37 37 cmdNew, 38 + cmdPublish, 38 39 } 39 40 return app.Run(context.Background(), args) 40 41 }
+215
cmd/glot/publish.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "io/fs" 8 + "os" 9 + "path" 10 + "path/filepath" 11 + "reflect" 12 + "sort" 13 + "strings" 14 + 15 + "github.com/bluesky-social/indigo/api/agnostic" 16 + "github.com/bluesky-social/indigo/atproto/client" 17 + "github.com/bluesky-social/indigo/atproto/data" 18 + "github.com/bluesky-social/indigo/atproto/identity" 19 + "github.com/bluesky-social/indigo/atproto/syntax" 20 + 21 + "github.com/urfave/cli/v3" 22 + ) 23 + 24 + /* 25 + publish behavior: 26 + - credentials are required 27 + - load all relevant schemas 28 + - filter no-change schemas 29 + - optionally filter schemas where group DNS is not current account (control w/ arg) 30 + - publish remaining schemas 31 + */ 32 + 33 + var cmdPublish = &cli.Command{ 34 + Name: "publish", 35 + Usage: "upload any new or updated lexicons", 36 + ArgsUsage: `<file-or-dir>*`, 37 + Flags: []cli.Flag{ 38 + &cli.StringFlag{ 39 + Name: "lexicons-dir", 40 + Value: "./lexicons/", 41 + Usage: "base directory for project Lexicon files", 42 + Sources: cli.EnvVars("LEXICONS_DIR"), 43 + }, 44 + &cli.StringFlag{ 45 + Name: "username", 46 + Aliases: []string{"u"}, 47 + Usage: "account identifier (handle or DID) for login", 48 + Sources: cli.EnvVars("GLOT_USERNAME", "ATP_USERNAME"), 49 + }, 50 + &cli.StringFlag{ 51 + Name: "password", 52 + Aliases: []string{"p"}, 53 + Usage: "account password (app password) for login", 54 + Sources: cli.EnvVars("GLOT_PASSWORD", "ATP_PASSWORD", "PASSWORD"), 55 + }, 56 + }, 57 + Action: runPublish, 58 + } 59 + 60 + func runPublish(ctx context.Context, cmd *cli.Command) error { 61 + 62 + user := cmd.String("username") 63 + pass := cmd.String("password") 64 + 65 + if user == "" || pass == "" { 66 + return fmt.Errorf("requires account credentials") 67 + } 68 + atid, err := syntax.ParseAtIdentifier(user) 69 + if err != nil { 70 + return fmt.Errorf("invalid AT account identifier %s: %w", user, err) 71 + } 72 + cdir := identity.DefaultDirectory() 73 + // TODO: could defer actual login until later? 74 + c, err := client.LoginWithPassword(ctx, cdir, *atid, pass, "", nil) 75 + if err != nil { 76 + return nil 77 + } 78 + 79 + paths := cmd.Args().Slice() 80 + if !cmd.Args().Present() { 81 + paths = []string{cmd.String("lexicons-dir")} 82 + _, err := os.Stat(paths[0]) 83 + if err != nil { 84 + return fmt.Errorf("no path arguments specified and default lexicon directory not found\n%w", err) 85 + } 86 + } 87 + 88 + // collect all NSID/path mappings 89 + localSchemas := map[syntax.NSID]json.RawMessage{} 90 + remoteSchemas := map[syntax.NSID]json.RawMessage{} 91 + 92 + for _, p := range paths { 93 + finfo, err := os.Stat(p) 94 + if err != nil { 95 + return fmt.Errorf("failed loading %s: %w", p, err) 96 + } 97 + if finfo.IsDir() { 98 + if err := filepath.WalkDir(p, func(fp string, d fs.DirEntry, err error) error { 99 + if d.IsDir() || path.Ext(fp) != ".json" { 100 + return nil 101 + } 102 + nsid, rec, err := loadSchemaPath(fp) 103 + if err != nil { 104 + return err 105 + } 106 + localSchemas[nsid] = *rec 107 + return nil 108 + }); err != nil { 109 + return err 110 + } 111 + continue 112 + } 113 + nsid, rec, err := loadSchemaPath(p) 114 + if err != nil { 115 + return err 116 + } 117 + localSchemas[nsid] = *rec 118 + } 119 + 120 + localGroups := map[string]bool{} 121 + allNSIDMap := map[syntax.NSID]bool{} 122 + for k := range localSchemas { 123 + parts := strings.Split(string(k), ".") 124 + g := strings.Join(parts[0:len(parts)-1], ".") + "." 125 + localGroups[g] = true 126 + allNSIDMap[k] = true 127 + } 128 + 129 + for g := range localGroups { 130 + if err := fetchLexiconGroup(ctx, cmd, g, &remoteSchemas); err != nil { 131 + return err 132 + } 133 + } 134 + 135 + dir := identity.BaseDirectory{} 136 + groupResolution := map[string]syntax.DID{} 137 + for g := range localGroups { 138 + did, err := dir.ResolveNSID(ctx, syntax.NSID(g+"name")) 139 + if err != nil { 140 + continue 141 + } 142 + groupResolution[g] = did 143 + } 144 + 145 + for k := range remoteSchemas { 146 + allNSIDMap[k] = true 147 + } 148 + allNSID := []string{} 149 + for k := range allNSIDMap { 150 + allNSID = append(allNSID, string(k)) 151 + } 152 + sort.Strings(allNSID) 153 + 154 + for _, k := range allNSID { 155 + nsid := syntax.NSID(k) 156 + 157 + localJSON := localSchemas[nsid] 158 + remoteJSON := remoteSchemas[nsid] 159 + 160 + if localJSON == nil { 161 + continue 162 + } 163 + 164 + if remoteJSON == nil { 165 + if err := publishSchema(ctx, c, nsid, localJSON); err != nil { 166 + return err 167 + } 168 + continue 169 + } 170 + 171 + local, err := data.UnmarshalJSON(localJSON) 172 + if err != nil { 173 + return err 174 + } 175 + remote, err := data.UnmarshalJSON(remoteJSON) 176 + if err != nil { 177 + return err 178 + } 179 + delete(local, "$type") 180 + delete(remote, "$type") 181 + if !reflect.DeepEqual(local, remote) { 182 + if err := publishSchema(ctx, c, nsid, localJSON); err != nil { 183 + return err 184 + } 185 + continue 186 + } 187 + } 188 + 189 + return nil 190 + } 191 + 192 + func publishSchema(ctx context.Context, c *client.APIClient, nsid syntax.NSID, schemaJSON json.RawMessage) error { 193 + 194 + d, err := data.UnmarshalJSON(schemaJSON) 195 + if err != nil { 196 + return err 197 + } 198 + d["$type"] = schemaNSID 199 + 200 + if c.AccountDID == nil { 201 + return fmt.Errorf("require API client to have DID configured") 202 + } 203 + _, err = agnostic.RepoPutRecord(ctx, c, &agnostic.RepoPutRecord_Input{ 204 + Collection: "com.atproto.lexicon.schema", 205 + Repo: c.AccountDID.String(), 206 + Record: d, 207 + Rkey: nsid.String(), 208 + }) 209 + if err != nil { 210 + return err 211 + } 212 + fmt.Printf(" 🟣 %s\n", nsid) 213 + 214 + return nil 215 + }