demo CLI tool for grain.social

bit more progress

+217 -38
+83
auth.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "errors" 7 + "fmt" 8 + "io/ioutil" 9 + "log/slog" 10 + "os" 11 + 12 + "github.com/adrg/xdg" 13 + "github.com/bluesky-social/indigo/atproto/client" 14 + ) 15 + 16 + var ErrNoAuthSession = errors.New("no auth session found") 17 + 18 + var SESSION_DATA_PATH = "halide/auth.json" 19 + 20 + func persistAuthSession(d client.PasswordSessionData) error { 21 + 22 + fPath, err := xdg.StateFile(SESSION_DATA_PATH) 23 + if err != nil { 24 + return err 25 + } 26 + 27 + f, err := os.OpenFile(fPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) 28 + if err != nil { 29 + return err 30 + } 31 + defer f.Close() 32 + 33 + authBytes, err := json.MarshalIndent(d, "", " ") 34 + if err != nil { 35 + return err 36 + } 37 + _, err = f.Write(authBytes) 38 + return err 39 + } 40 + 41 + func wipeAuthSession() error { 42 + 43 + fPath, err := xdg.SearchStateFile(SESSION_DATA_PATH) 44 + if err != nil { 45 + fmt.Printf("no auth session found (already logged out)") 46 + return nil 47 + } 48 + return os.Remove(fPath) 49 + } 50 + 51 + func loadAuthSession() (*client.PasswordSessionData, error) { 52 + 53 + fPath, err := xdg.SearchStateFile(SESSION_DATA_PATH) 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 client.PasswordSessionData 64 + err = json.Unmarshal(fBytes, &sess) 65 + if err != nil { 66 + return nil, err 67 + } 68 + return &sess, nil 69 + } 70 + 71 + func loadClient() (*client.APIClient, error) { 72 + d, err := loadAuthSession() 73 + if err != nil { 74 + return nil, err 75 + } 76 + 77 + c := client.ResumePasswordSession(*d, func(ctx context.Context, data client.PasswordSessionData) { 78 + if err := persistAuthSession(data); err != nil { 79 + slog.Error("saving refreshed auth session", "err", err) 80 + } 81 + }) 82 + return c, nil 83 + }
+1
go.mod
··· 3 3 go 1.24.0 4 4 5 5 require ( 6 + github.com/adrg/xdg v0.5.0 6 7 github.com/bluesky-social/indigo v0.0.0-20250621010046-488d1b91889b 7 8 github.com/carlmjohnson/versioninfo v0.22.5 8 9 github.com/joho/godotenv v1.5.1
+2
go.sum
··· 1 + github.com/adrg/xdg v0.5.0 h1:dDaZvhMXatArP1NPHhnfaQUqWBLBsmx1h1HXQdMoFCY= 2 + github.com/adrg/xdg v0.5.0/go.mod h1:dDdY4M4DF9Rjy4kHPeNL+ilVF+p2lK8IdM9/rTSGcI4= 1 3 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 2 4 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 3 5 github.com/bluesky-social/indigo v0.0.0-20250621010046-488d1b91889b h1:QniihTdfvYFr8oJZgltN0VyWSWa28v/0DiIVFHy6nfg=
+131 -38
main.go
··· 1 1 package main 2 2 3 3 import ( 4 - "fmt" 5 - "os" 6 4 "bytes" 7 - "log" 8 5 "context" 9 - "encoding/json" 6 + "fmt" 7 + "log" 8 + "os" 10 9 11 10 _ "github.com/joho/godotenv/autoload" 12 11 12 + agnostic "github.com/bluesky-social/indigo/api/agnostic" 13 13 comatproto "github.com/bluesky-social/indigo/api/atproto" 14 14 "github.com/bluesky-social/indigo/atproto/client" 15 15 "github.com/bluesky-social/indigo/atproto/identity" ··· 19 19 ) 20 20 21 21 func main() { 22 + 22 23 cmd := &cli.Command{ 23 - Name: "halide", 24 - Usage: "CLI tool for for grain.social (atproto)", 24 + Name: "halide", 25 + Usage: "CLI tool for for grain.social (atproto)", 25 26 Version: versioninfo.Short(), 26 - Flags: []cli.Flag{ 27 - &cli.StringFlag{ 28 - Name: "username", 29 - Aliases: []string{"u"}, 30 - Required: true, 31 - Sources: cli.EnvVars("ATP_USERNAME"), 32 - }, 33 - &cli.StringFlag{ 34 - Name: "password", 35 - Aliases: []string{"p"}, 36 - Required: true, 37 - Sources: cli.EnvVars("ATP_PASSWORD"), 38 - }, 27 + Commands: []*cli.Command{ 28 + { 29 + Name: "login", 30 + Usage: "create auth session", 31 + Action: runLogin, 32 + Flags: []cli.Flag{ 33 + &cli.StringFlag{ 34 + Name: "username", 35 + Aliases: []string{"u"}, 36 + Required: true, 37 + Sources: cli.EnvVars("ATP_USERNAME"), 38 + }, 39 + &cli.StringFlag{ 40 + Name: "password", 41 + Aliases: []string{"p"}, 42 + Required: true, 43 + Sources: cli.EnvVars("ATP_PASSWORD"), 44 + }, 45 + }, 46 + }, 47 + { 48 + Name: "photo", 49 + Commands: []*cli.Command{ 50 + { 51 + Name: "list", 52 + Aliases: []string{"ls"}, 53 + Usage: "enumerate photos", 54 + Action: runPhotoList, 55 + }, 56 + { 57 + Name: "upload", 58 + Usage: "upload a photo", 59 + ArgsUsage: `<file>`, 60 + Flags: []cli.Flag{ 61 + &cli.StringFlag{ 62 + Name: "alt", 63 + Usage: "image alt text (for accessibility)", 64 + }, 65 + }, 66 + Action: runPhotoUpload, 67 + }, 68 + }, 69 + }, 70 + { 71 + Name: "gallery", 72 + Commands: []*cli.Command{ 73 + { 74 + Name: "list", 75 + Aliases: []string{"ls"}, 76 + Usage: "enumerate galleries", 77 + Action: runGalleryList, 78 + }, 79 + }, 80 + }, 39 81 }, 40 82 } 41 - cmd.Commands = []*cli.Command{ 42 - { 43 - Name: "upload", 44 - Usage: "upload a photo", 45 - ArgsUsage: `<file>`, 46 - Flags: []cli.Flag{}, 47 - Action: runUpload, 48 - }, 83 + 84 + if err := cmd.Run(context.Background(), os.Args); err != nil { 85 + log.Fatal(err) 86 + } 87 + } 88 + 89 + func runLogin(ctx context.Context, cmd *cli.Command) error { 90 + atid, err := syntax.ParseAtIdentifier(cmd.String("username")) 91 + if err != nil { 92 + return err 49 93 } 50 94 51 - if err := cmd.Run(context.Background(), os.Args); err != nil { 52 - log.Fatal(err) 53 - } 95 + dir := identity.DefaultDirectory() 96 + c, err := client.LoginWithPassword(ctx, dir, *atid, cmd.String("password"), "", nil) 97 + if err != nil { 98 + return err 99 + } 100 + 101 + pa := c.Auth.(*client.PasswordAuth) 102 + return persistAuthSession(pa.Session) 54 103 } 55 104 56 - func runUpload(ctx context.Context, cmd *cli.Command) error { 105 + func runPhotoUpload(ctx context.Context, cmd *cli.Command) error { 57 106 photoPath := cmd.Args().First() 58 107 if photoPath == "" { 59 108 return fmt.Errorf("need to provide file path as an argument") 60 109 } 61 110 62 - atid, err := syntax.ParseAtIdentifier(cmd.String("username")) 111 + c, err := loadClient() 63 112 if err != nil { 64 113 return err 65 114 } 66 115 67 - dir := identity.DefaultDirectory() 68 - c, err := client.LoginWithPassword(ctx, dir, *atid, cmd.String("password"), "", nil) 116 + // TODO: refactor to do upload with c.Do(), streaming from file 117 + fileBytes, err := os.ReadFile(photoPath) 69 118 if err != nil { 70 119 return err 71 120 } 72 121 73 - fileBytes, err := os.ReadFile(photoPath) 122 + resp, err := comatproto.RepoUploadBlob(ctx, c, bytes.NewReader(fileBytes)) 74 123 if err != nil { 75 124 return err 76 125 } 77 126 78 - resp, err := comatproto.RepoUploadBlob(ctx, c, bytes.NewReader(fileBytes)) 127 + photo := make(map[string]any) 128 + photo["$type"] = "social.grain.photo" 129 + photo["createdAt"] = syntax.DatetimeNow() 130 + photo["photo"] = resp.Blob 131 + // TODO: if alt was not required, this would be behind an 'if' 132 + photo["alt"] = cmd.String("alt") 133 + 134 + res, err := agnostic.RepoCreateRecord(ctx, c, &agnostic.RepoCreateRecord_Input{ 135 + Collection: "social.grain.photo", 136 + Record: photo, 137 + Repo: c.AccountDID.String(), 138 + }) 79 139 if err != nil { 80 140 return err 81 141 } 82 142 83 - b, err := json.MarshalIndent(resp.Blob, "", " ") 143 + fmt.Printf("%s\t%s\n", res.Uri, res.Cid) 144 + return nil 145 + } 146 + 147 + func runPhotoList(ctx context.Context, cmd *cli.Command) error { 148 + c, err := loadClient() 84 149 if err != nil { 85 150 return err 86 151 } 87 152 88 - fmt.Println(string(b)) 153 + out, err := agnostic.RepoListRecords(ctx, c, "social.grain.photo", "", 100, c.AccountDID.String(), false) 154 + if err != nil { 155 + return err 156 + } 157 + 158 + for _, rec := range out.Records { 159 + // TODO: parse rec.Value to lexgen struct type 160 + fmt.Printf("%s\n", rec.Uri) 161 + } 162 + 163 + return nil 164 + } 165 + 166 + func runGalleryList(ctx context.Context, cmd *cli.Command) error { 167 + c, err := loadClient() 168 + if err != nil { 169 + return err 170 + } 171 + 172 + out, err := agnostic.RepoListRecords(ctx, c, "social.grain.gallery", "", 100, c.AccountDID.String(), false) 173 + if err != nil { 174 + return err 175 + } 176 + 177 + for _, rec := range out.Records { 178 + // TODO: parse rec.Value to lexgen struct type 179 + fmt.Printf("%s\n", rec.Uri) 180 + } 181 + 89 182 return nil 90 183 }