cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐Ÿƒ
charm leaflet readability golang

feat: atproto auth & persistence

* added TUI (form) for auth

+1700 -42
+15 -11
cmd/publication_commands.go
··· 1 - // TODO: implement prompt for password 2 - // 3 - // See: https://github.com/charmbracelet/bubbletea/blob/main/examples/textinputs/main.go 4 1 package main 5 2 6 3 import ( ··· 8 5 9 6 "github.com/spf13/cobra" 10 7 "github.com/stormlightlabs/noteleaf/internal/handlers" 8 + "github.com/stormlightlabs/noteleaf/internal/ui" 11 9 ) 12 10 13 11 // PublicationCommand implements [CommandGroup] for leaflet publication commands ··· 52 50 2. Create a new app password named "noteleaf" 53 51 3. Use that password here 54 52 55 - The password will be prompted securely if not provided via flag.`, 53 + If credentials are not provided via flags, use the interactive input.`, 56 54 Args: cobra.MaximumNArgs(1), 57 55 RunE: func(cmd *cobra.Command, args []string) error { 58 56 var handle string ··· 62 60 63 61 password, _ := cmd.Flags().GetString("password") 64 62 65 - if handle == "" { 66 - return fmt.Errorf("handle is required") 63 + if handle != "" && password != "" { 64 + defer c.handler.Close() 65 + return c.handler.Auth(cmd.Context(), handle, password) 67 66 } 68 67 69 - if password == "" { 70 - return fmt.Errorf("password is required (use --password flag)") 68 + form := ui.NewAuthForm(handle, ui.AuthFormOptions{}) 69 + result, err := form.Run() 70 + if err != nil { 71 + return fmt.Errorf("failed to display auth form: %w", err) 72 + } 73 + 74 + if result.Canceled { 75 + return fmt.Errorf("authentication canceled") 71 76 } 72 77 73 78 defer c.handler.Close() 74 - return c.handler.Auth(cmd.Context(), handle, password) 79 + return c.handler.Auth(cmd.Context(), result.Handle, result.Password) 75 80 }, 76 81 } 77 - authCmd.Flags().StringP("password", "p", "", "App password (will be prompted if not provided)") 82 + authCmd.Flags().StringP("password", "p", "", "App password (will prompt if not provided)") 78 83 root.AddCommand(authCmd) 79 84 80 85 pullCmd := &cobra.Command{ ··· 96 101 } 97 102 root.AddCommand(pullCmd) 98 103 99 - // List command 100 104 listCmd := &cobra.Command{ 101 105 Use: "list [--published|--draft|--all]", 102 106 Short: "List notes synced with leaflet",
+45 -1
go.mod
··· 16 16 ) 17 17 18 18 require ( 19 + github.com/bluesky-social/indigo v0.0.0-20251031012455-0b4bd2478a61 19 20 github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a 20 21 github.com/jaswdr/faker/v2 v2.8.0 21 22 ) 22 23 23 24 require ( 24 25 github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect 26 + github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect 27 + github.com/felixge/httpsnoop v1.0.4 // indirect 28 + github.com/go-logr/logr v1.4.1 // indirect 29 + github.com/go-logr/stdr v1.2.2 // indirect 30 + github.com/gogo/protobuf v1.3.2 // indirect 31 + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 32 + github.com/hashicorp/go-retryablehttp v0.7.5 // indirect 33 + github.com/hashicorp/golang-lru v1.0.2 // indirect 34 + github.com/ipfs/bbloom v0.0.4 // indirect 35 + github.com/ipfs/go-block-format v0.2.0 // indirect 36 + github.com/ipfs/go-cid v0.4.1 // indirect 37 + github.com/ipfs/go-datastore v0.6.0 // indirect 38 + github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect 39 + github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect 40 + github.com/ipfs/go-ipfs-util v0.0.3 // indirect 41 + github.com/ipfs/go-ipld-cbor v0.1.0 // indirect 42 + github.com/ipfs/go-ipld-format v0.6.0 // indirect 43 + github.com/ipfs/go-log v1.0.5 // indirect 44 + github.com/ipfs/go-log/v2 v2.5.1 // indirect 45 + github.com/ipfs/go-metrics-interface v0.0.1 // indirect 46 + github.com/jbenet/goprocess v0.1.4 // indirect 47 + github.com/klauspost/cpuid/v2 v2.2.7 // indirect 48 + github.com/minio/sha256-simd v1.0.1 // indirect 49 + github.com/mr-tron/base58 v1.2.0 // indirect 50 + github.com/multiformats/go-base32 v0.1.0 // indirect 51 + github.com/multiformats/go-base36 v0.2.0 // indirect 52 + github.com/multiformats/go-multibase v0.2.0 // indirect 53 + github.com/multiformats/go-multihash v0.2.3 // indirect 54 + github.com/multiformats/go-varint v0.0.7 // indirect 55 + github.com/opentracing/opentracing-go v1.2.0 // indirect 56 + github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 25 57 github.com/russross/blackfriday/v2 v2.1.0 // indirect 58 + github.com/spaolacci/murmur3 v1.1.0 // indirect 59 + github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect 60 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect 61 + go.opentelemetry.io/otel v1.21.0 // indirect 62 + go.opentelemetry.io/otel/metric v1.21.0 // indirect 63 + go.opentelemetry.io/otel/trace v1.21.0 // indirect 64 + go.uber.org/atomic v1.11.0 // indirect 65 + go.uber.org/multierr v1.11.0 // indirect 66 + go.uber.org/zap v1.26.0 // indirect 67 + golang.org/x/crypto v0.43.0 // indirect 68 + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 26 69 gopkg.in/yaml.v3 v3.0.1 // indirect 70 + lukechampine.com/blake3 v1.2.1 // indirect 27 71 ) 28 72 29 73 require ( ··· 90 134 github.com/rivo/uniseg v0.4.7 // indirect 91 135 github.com/spf13/pflag v1.0.6 // indirect 92 136 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 93 - golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect 137 + golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect 94 138 golang.org/x/sys v0.37.0 // indirect 95 139 golang.org/x/text v0.30.0 96 140 )
+193 -3
go.sum
··· 1 + al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= 2 + al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= 3 + github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 1 4 github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= 2 5 github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 3 6 github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= ··· 24 27 github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 25 28 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 26 29 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 30 + github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 27 31 github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= 28 32 github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4= 29 33 github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= 34 + github.com/bluesky-social/indigo v0.0.0-20251031012455-0b4bd2478a61 h1:lU2NnyuvevVWtE35sb4xWBp1AQxa1Sv4XhexiWlrWng= 35 + github.com/bluesky-social/indigo v0.0.0-20251031012455-0b4bd2478a61/go.mod h1:GuGAU33qKulpZCZNPcUeIQ4RW6KzNvOy7s8MSUXbAng= 30 36 github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 31 37 github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 32 38 github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= ··· 55 61 github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= 56 62 github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 57 63 github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 64 + github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 58 65 github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= 59 66 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 67 + github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= 68 + github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= 60 69 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 61 70 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 62 71 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 63 72 github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= 64 73 github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 74 + github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= 75 + github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw= 65 76 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 66 77 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 78 + github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 79 + github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 67 80 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 68 81 github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 82 + github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 83 + github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= 84 + github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 85 + github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 86 + github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 87 + github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 69 88 github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= 70 89 github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= 71 90 github.com/gocolly/colly/v2 v2.2.0 h1:FQGxcqvTdFAvOpMRhk52o20Qsf6KtRU5HSf0bITS38I= 72 91 github.com/gocolly/colly/v2 v2.2.0/go.mod h1:YOQwv1ofoQOzJiELnkThDd6ObOfl6odUk2i6Czbx3Ws= 92 + github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= 93 + github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 94 + github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 95 + github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 73 96 github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 74 97 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 75 98 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= ··· 82 105 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 83 106 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 84 107 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 108 + github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 85 109 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 86 110 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 111 + github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 112 + github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 87 113 github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 88 114 github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 115 + github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 116 + github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 117 + github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= 118 + github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= 119 + github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M= 120 + github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= 121 + github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 122 + github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 89 123 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 90 124 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 91 125 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 92 126 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 127 + github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= 128 + github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= 129 + github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs= 130 + github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM= 131 + github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= 132 + github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= 133 + github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk= 134 + github.com/ipfs/go-datastore v0.6.0/go.mod h1:rt5M3nNbSO/8q1t4LNkLyUwRs8HupMeN/8O4Vn9YAT8= 135 + github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk= 136 + github.com/ipfs/go-detect-race v0.0.1/go.mod h1:8BNT7shDZPo99Q74BpGMK+4D8Mn4j46UU0LZ723meps= 137 + github.com/ipfs/go-ipfs-blockstore v1.3.1 h1:cEI9ci7V0sRNivqaOr0elDsamxXFxJMMMy7PTTDQNsQ= 138 + github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE= 139 + github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw= 140 + github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo= 141 + github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= 142 + github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs= 143 + github.com/ipfs/go-ipld-cbor v0.1.0 h1:dx0nS0kILVivGhfWuB6dUpMa/LAwElHPw1yOGYopoYs= 144 + github.com/ipfs/go-ipld-cbor v0.1.0/go.mod h1:U2aYlmVrJr2wsUBU67K4KgepApSZddGRDWBYR0H4sCk= 145 + github.com/ipfs/go-ipld-format v0.6.0 h1:VEJlA2kQ3LqFSIm5Vu6eIlSxD/Ze90xtc4Meten1F5U= 146 + github.com/ipfs/go-ipld-format v0.6.0/go.mod h1:g4QVMTn3marU3qXchwjpKPKgJv+zF+OlaKMyhJ4LHPg= 147 + github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= 148 + github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= 149 + github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= 150 + github.com/ipfs/go-log/v2 v2.5.1 h1:1XdUzF7048prq4aBjDQQ4SL5RxftpRGdXhNRwKSAlcY= 151 + github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI= 152 + github.com/ipfs/go-metrics-interface v0.0.1 h1:j+cpbjYvu4R8zbleSs36gvB7jR+wsL2fGD6n0jO4kdg= 153 + github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY= 93 154 github.com/jaswdr/faker/v2 v2.8.0 h1:3AxdXW9U7dJmWckh/P0YgRbNlCcVsTyrUNUnLVP9b3Q= 94 155 github.com/jaswdr/faker/v2 v2.8.0/go.mod h1:jZq+qzNQr8/P+5fHd9t3txe2GNPnthrTfohtnJ7B+68= 156 + github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA= 157 + github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o= 158 + github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= 159 + github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 160 + github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 95 161 github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o= 96 162 github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak= 163 + github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 164 + github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 165 + github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= 166 + github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 167 + github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 168 + github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 169 + github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 170 + github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 171 + github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 172 + github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 173 + github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 97 174 github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= 98 175 github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 176 + github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 99 177 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 100 178 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 101 179 github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= ··· 107 185 github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 108 186 github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= 109 187 github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 188 + github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 189 + github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 190 + github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 191 + github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 110 192 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 111 193 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 112 194 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= ··· 123 205 github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= 124 206 github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 125 207 github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 208 + github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= 209 + github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 210 + github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= 211 + github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= 212 + github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= 213 + github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= 214 + github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= 215 + github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 216 + github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= 217 + github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= 126 218 github.com/nlnwa/whatwg-url v0.6.1 h1:Zlefa3aglQFHF/jku45VxbEJwPicDnOz64Ra3F7npqQ= 127 219 github.com/nlnwa/whatwg-url v0.6.1/go.mod h1:x0FPXJzzOEieQtsBT/AKvbiBbQ46YlL6Xa7m02M1ECk= 220 + github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= 221 + github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 222 + github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 128 223 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 129 224 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 225 + github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= 226 + github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= 130 227 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 131 228 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 132 229 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 133 230 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 231 + github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 232 + github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 233 + github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 234 + github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 134 235 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 135 236 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 136 237 github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA= 137 238 github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= 239 + github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 240 + github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= 241 + github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= 242 + github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg= 243 + github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= 244 + github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 245 + github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 138 246 github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 139 247 github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 140 248 github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 141 249 github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 142 250 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 251 + github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 143 252 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 253 + github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 254 + github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 144 255 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 145 256 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 146 257 github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg= 147 258 github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo= 259 + github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 260 + github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= 261 + github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 262 + github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= 263 + github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 148 264 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 149 265 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 266 + github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 267 + github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 268 + github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 150 269 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 151 270 github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 152 271 github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= 153 272 github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 154 273 github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= 155 274 github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= 275 + github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= 276 + github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= 277 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24= 278 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo= 279 + go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc= 280 + go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= 281 + go.opentelemetry.io/otel/metric v1.21.0 h1:tlYWfeo+Bocx5kLEloTjbcDwBuELRrIFxwdQ36PlJu4= 282 + go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM= 283 + go.opentelemetry.io/otel/trace v1.21.0 h1:WD9i5gzvoUPuXIXH24ZNBudiarZDKuekPqi/E8fpfLc= 284 + go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ= 285 + go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 286 + go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 287 + go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 288 + go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 289 + go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= 290 + go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= 291 + go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= 292 + go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= 293 + go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= 294 + go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 295 + go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 296 + go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= 297 + go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= 298 + go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= 299 + go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= 300 + go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= 156 301 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 302 + golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 303 + golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 304 + golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 157 305 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 158 306 golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 159 307 golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 160 308 golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 161 309 golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 162 310 golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= 163 - golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= 164 - golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 311 + golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= 312 + golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= 313 + golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= 314 + golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= 315 + golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 316 + golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 317 + golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 318 + golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 319 + golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 165 320 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 166 321 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 167 322 golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 168 323 golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 169 324 golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 325 + golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 326 + golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 170 327 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 328 + golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 329 + golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 171 330 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 331 + golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 172 332 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 173 333 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 174 334 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= ··· 180 340 golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= 181 341 golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= 182 342 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 343 + golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 344 + golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 345 + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 183 346 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 184 347 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 185 348 golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= ··· 189 352 golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= 190 353 golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 191 354 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 355 + golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 356 + golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 192 357 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 358 + golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 359 + golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 193 360 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 361 + golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 194 362 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 195 363 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 196 364 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= ··· 231 399 golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 232 400 golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 233 401 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 402 + golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 403 + golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 404 + golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 405 + golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 406 + golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 234 407 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 408 + golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 409 + golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 410 + golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 235 411 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 236 412 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 237 413 golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 238 414 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 239 415 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 416 + golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 240 417 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 418 + golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 419 + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= 420 + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 241 421 google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= 242 422 google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= 243 423 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 244 424 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 245 425 google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 246 426 google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 247 - gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 248 427 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 428 + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 429 + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 430 + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 431 + gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 432 + gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 433 + gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 434 + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 435 + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 249 436 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 250 437 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 438 + honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 439 + lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= 440 + lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=
+55 -9
internal/handlers/publication.go
··· 1 - // TODO: Store credentials securely in [PublicationHandler.Auth] 2 - // Options: 3 - // 1. Use system keyring (go-keyring) 4 - // 2. Store encrypted in config file 5 - // 3. Store in environment variables 6 - // 7 1 // TODO: Implement document processing 8 2 // For each document: 9 3 // 1. Check if note with this leaflet_rkey exists ··· 28 22 import ( 29 23 "context" 30 24 "fmt" 25 + "time" 31 26 32 27 "github.com/stormlightlabs/noteleaf/internal/repo" 33 28 "github.com/stormlightlabs/noteleaf/internal/services" ··· 57 52 repos := repo.NewRepositories(db.DB) 58 53 atproto := services.NewATProtoService() 59 54 55 + if config.ATProtoDID != "" && config.ATProtoAccessJWT != "" && config.ATProtoRefreshJWT != "" { 56 + session, err := sessionFromConfig(config) 57 + if err == nil { 58 + _ = atproto.RestoreSession(session) 59 + } 60 + } 61 + 60 62 return &PublicationHandler{ 61 63 db: db, 62 64 config: config, ··· 94 96 return fmt.Errorf("authentication failed: %w", err) 95 97 } 96 98 99 + session, err := h.atproto.GetSession() 100 + if err != nil { 101 + return fmt.Errorf("failed to get session after authentication: %w", err) 102 + } 103 + 104 + h.config.ATProtoDID = session.DID 105 + h.config.ATProtoHandle = session.Handle 106 + h.config.ATProtoAccessJWT = session.AccessJWT 107 + h.config.ATProtoRefreshJWT = session.RefreshJWT 108 + h.config.ATProtoPDSURL = session.PDSURL 109 + h.config.ATProtoExpiresAt = session.ExpiresAt.Format("2006-01-02T15:04:05Z07:00") 110 + 111 + if err := store.SaveConfig(h.config); err != nil { 112 + return fmt.Errorf("authentication successful but failed to save credentials: %w", err) 113 + } 114 + 97 115 fmt.Println("โœ“ Authentication successful!") 98 - fmt.Println("TODO: Implement persistent credential storage") 116 + fmt.Println("โœ“ Credentials saved") 99 117 return nil 100 118 } 101 119 ··· 105 123 return nil 106 124 } 107 125 108 - // List displays notes with leaflet publication metadata, showing all notes that 109 - // have been pulled from or pushed to leaflet 126 + // List displays notes with leaflet publication metadata, showing all notes that have been pulled from or pushed to leaflet 110 127 func (h *PublicationHandler) List(ctx context.Context, filter string) error { 111 128 fmt.Println("TODO: Implement leaflet document listing") 112 129 return nil ··· 123 140 } 124 141 return "Not authenticated" 125 142 } 143 + 144 + // sessionFromConfig converts config AT Protocol fields to a Session 145 + func sessionFromConfig(config *store.Config) (*services.Session, error) { 146 + if config.ATProtoDID == "" || config.ATProtoAccessJWT == "" || config.ATProtoRefreshJWT == "" { 147 + return nil, fmt.Errorf("incomplete session data in config") 148 + } 149 + 150 + var expiresAt time.Time 151 + if config.ATProtoExpiresAt != "" { 152 + parsed, err := time.Parse("2006-01-02T15:04:05Z07:00", config.ATProtoExpiresAt) 153 + if err != nil { 154 + expiresAt = time.Now().Add(-1 * time.Hour) 155 + } else { 156 + expiresAt = parsed 157 + } 158 + } else { 159 + expiresAt = time.Now().Add(-1 * time.Hour) 160 + } 161 + 162 + return &services.Session{ 163 + DID: config.ATProtoDID, 164 + Handle: config.ATProtoHandle, 165 + AccessJWT: config.ATProtoAccessJWT, 166 + RefreshJWT: config.ATProtoRefreshJWT, 167 + PDSURL: config.ATProtoPDSURL, 168 + ExpiresAt: expiresAt, 169 + Authenticated: true, 170 + }, nil 171 + }
+285
internal/handlers/publication_test.go
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "testing" 6 + "time" 7 + 8 + "github.com/stormlightlabs/noteleaf/internal/services" 9 + "github.com/stormlightlabs/noteleaf/internal/store" 10 + ) 11 + 12 + func TestPublicationHandler(t *testing.T) { 13 + t.Run("sessionFromConfig", func(t *testing.T) { 14 + t.Run("returns error when DID is missing", func(t *testing.T) { 15 + config := &store.Config{ 16 + ATProtoDID: "", 17 + ATProtoHandle: "test.bsky.social", 18 + ATProtoAccessJWT: "access_token", 19 + ATProtoRefreshJWT: "refresh_token", 20 + } 21 + 22 + _, err := sessionFromConfig(config) 23 + if err == nil { 24 + t.Error("Expected error when DID is missing") 25 + } 26 + }) 27 + 28 + t.Run("returns error when AccessJWT is missing", func(t *testing.T) { 29 + config := &store.Config{ 30 + ATProtoDID: "did:plc:test123", 31 + ATProtoHandle: "test.bsky.social", 32 + ATProtoAccessJWT: "", 33 + ATProtoRefreshJWT: "refresh_token", 34 + } 35 + 36 + _, err := sessionFromConfig(config) 37 + if err == nil { 38 + t.Error("Expected error when AccessJWT is missing") 39 + } 40 + }) 41 + 42 + t.Run("returns error when RefreshJWT is missing", func(t *testing.T) { 43 + config := &store.Config{ 44 + ATProtoDID: "did:plc:test123", 45 + ATProtoHandle: "test.bsky.social", 46 + ATProtoAccessJWT: "access_token", 47 + ATProtoRefreshJWT: "", 48 + } 49 + 50 + _, err := sessionFromConfig(config) 51 + if err == nil { 52 + t.Error("Expected error when RefreshJWT is missing") 53 + } 54 + }) 55 + 56 + t.Run("successfully creates session from complete config", func(t *testing.T) { 57 + expiresAt := time.Now().Add(2 * time.Hour) 58 + config := &store.Config{ 59 + ATProtoDID: "did:plc:test123", 60 + ATProtoHandle: "test.bsky.social", 61 + ATProtoAccessJWT: "access_token", 62 + ATProtoRefreshJWT: "refresh_token", 63 + ATProtoPDSURL: "https://bsky.social", 64 + ATProtoExpiresAt: expiresAt.Format("2006-01-02T15:04:05Z07:00"), 65 + } 66 + 67 + session, err := sessionFromConfig(config) 68 + if err != nil { 69 + t.Errorf("Expected no error, got %v", err) 70 + } 71 + 72 + if session.DID != config.ATProtoDID { 73 + t.Errorf("Expected DID '%s', got '%s'", config.ATProtoDID, session.DID) 74 + } 75 + if session.Handle != config.ATProtoHandle { 76 + t.Errorf("Expected Handle '%s', got '%s'", config.ATProtoHandle, session.Handle) 77 + } 78 + if session.AccessJWT != config.ATProtoAccessJWT { 79 + t.Errorf("Expected AccessJWT '%s', got '%s'", config.ATProtoAccessJWT, session.AccessJWT) 80 + } 81 + if session.RefreshJWT != config.ATProtoRefreshJWT { 82 + t.Errorf("Expected RefreshJWT '%s', got '%s'", config.ATProtoRefreshJWT, session.RefreshJWT) 83 + } 84 + if session.PDSURL != config.ATProtoPDSURL { 85 + t.Errorf("Expected PDSURL '%s', got '%s'", config.ATProtoPDSURL, session.PDSURL) 86 + } 87 + if !session.Authenticated { 88 + t.Error("Expected session to be authenticated") 89 + } 90 + }) 91 + 92 + t.Run("handles missing expiry time gracefully", func(t *testing.T) { 93 + config := &store.Config{ 94 + ATProtoDID: "did:plc:test123", 95 + ATProtoHandle: "test.bsky.social", 96 + ATProtoAccessJWT: "access_token", 97 + ATProtoRefreshJWT: "refresh_token", 98 + ATProtoExpiresAt: "", 99 + } 100 + 101 + session, err := sessionFromConfig(config) 102 + if err != nil { 103 + t.Errorf("Expected no error, got %v", err) 104 + } 105 + 106 + if session.ExpiresAt.After(time.Now()) { 107 + t.Error("Expected ExpiresAt to be in the past when not provided") 108 + } 109 + }) 110 + 111 + t.Run("handles invalid expiry time format gracefully", func(t *testing.T) { 112 + config := &store.Config{ 113 + ATProtoDID: "did:plc:test123", 114 + ATProtoHandle: "test.bsky.social", 115 + ATProtoAccessJWT: "access_token", 116 + ATProtoRefreshJWT: "refresh_token", 117 + ATProtoExpiresAt: "invalid-timestamp", 118 + } 119 + 120 + session, err := sessionFromConfig(config) 121 + if err != nil { 122 + t.Errorf("Expected no error, got %v", err) 123 + } 124 + 125 + if session.ExpiresAt.After(time.Now()) { 126 + t.Error("Expected ExpiresAt to be in the past when parse fails") 127 + } 128 + }) 129 + }) 130 + 131 + t.Run("Auth", func(t *testing.T) { 132 + t.Run("validates required parameters", func(t *testing.T) { 133 + _ = NewHandlerTestSuite(t) 134 + handler := CreateHandler(t, NewPublicationHandler) 135 + ctx := context.Background() 136 + 137 + err := handler.Auth(ctx, "", "password") 138 + if err == nil { 139 + t.Error("Expected error when handle is empty") 140 + } 141 + 142 + err = handler.Auth(ctx, "handle", "") 143 + if err == nil { 144 + t.Error("Expected error when password is empty") 145 + } 146 + }) 147 + 148 + }) 149 + 150 + t.Run("GetAuthStatus", func(t *testing.T) { 151 + t.Run("returns not authenticated when no session", func(t *testing.T) { 152 + _ = NewHandlerTestSuite(t) 153 + handler := CreateHandler(t, NewPublicationHandler) 154 + 155 + status := handler.GetAuthStatus() 156 + if status != "Not authenticated" { 157 + t.Errorf("Expected 'Not authenticated', got '%s'", status) 158 + } 159 + }) 160 + 161 + t.Run("returns authenticated status with session", func(t *testing.T) { 162 + _ = NewHandlerTestSuite(t) 163 + handler := CreateHandler(t, NewPublicationHandler) 164 + 165 + session := &services.Session{ 166 + DID: "did:plc:test123", 167 + Handle: "test.bsky.social", 168 + AccessJWT: "access_token", 169 + RefreshJWT: "refresh_token", 170 + PDSURL: "https://bsky.social", 171 + ExpiresAt: time.Now().Add(2 * time.Hour), 172 + Authenticated: true, 173 + } 174 + 175 + err := handler.atproto.RestoreSession(session) 176 + if err != nil { 177 + t.Fatalf("Failed to restore session: %v", err) 178 + } 179 + 180 + status := handler.GetAuthStatus() 181 + expectedStatus := "Authenticated as test.bsky.social" 182 + if status != expectedStatus { 183 + t.Errorf("Expected '%s', got '%s'", expectedStatus, status) 184 + } 185 + }) 186 + }) 187 + 188 + t.Run("NewPublicationHandler", func(t *testing.T) { 189 + t.Run("creates handler successfully", func(t *testing.T) { 190 + suite := NewHandlerTestSuite(t) 191 + defer suite.Cleanup() 192 + 193 + handler, err := NewPublicationHandler() 194 + if err != nil { 195 + t.Fatalf("Expected no error creating handler, got %v", err) 196 + } 197 + defer handler.Close() 198 + 199 + if handler.db == nil { 200 + t.Error("Expected database to be initialized") 201 + } 202 + if handler.config == nil { 203 + t.Error("Expected config to be initialized") 204 + } 205 + if handler.repos == nil { 206 + t.Error("Expected repos to be initialized") 207 + } 208 + if handler.atproto == nil { 209 + t.Error("Expected atproto service to be initialized") 210 + } 211 + }) 212 + 213 + t.Run("restores session from config when available", func(t *testing.T) { 214 + suite := NewHandlerTestSuite(t) 215 + defer suite.Cleanup() 216 + 217 + config, err := store.LoadConfig() 218 + if err != nil { 219 + t.Fatalf("Failed to load config: %v", err) 220 + } 221 + 222 + config.ATProtoDID = "did:plc:test123" 223 + config.ATProtoHandle = "test.bsky.social" 224 + config.ATProtoAccessJWT = "access_token" 225 + config.ATProtoRefreshJWT = "refresh_token" 226 + config.ATProtoPDSURL = "https://bsky.social" 227 + config.ATProtoExpiresAt = time.Now().Add(2 * time.Hour).Format("2006-01-02T15:04:05Z07:00") 228 + 229 + err = store.SaveConfig(config) 230 + if err != nil { 231 + t.Fatalf("Failed to save config: %v", err) 232 + } 233 + 234 + handler, err := NewPublicationHandler() 235 + if err != nil { 236 + t.Fatalf("Expected no error creating handler, got %v", err) 237 + } 238 + defer handler.Close() 239 + 240 + if !handler.atproto.IsAuthenticated() { 241 + t.Error("Expected handler to be authenticated after restoring from config") 242 + } 243 + 244 + session, err := handler.atproto.GetSession() 245 + if err != nil { 246 + t.Errorf("Expected to get session, got error: %v", err) 247 + } 248 + if session.DID != config.ATProtoDID { 249 + t.Errorf("Expected DID '%s', got '%s'", config.ATProtoDID, session.DID) 250 + } 251 + }) 252 + 253 + t.Run("handles empty config gracefully", func(t *testing.T) { 254 + suite := NewHandlerTestSuite(t) 255 + defer suite.Cleanup() 256 + 257 + handler, err := NewPublicationHandler() 258 + if err != nil { 259 + t.Fatalf("Expected no error creating handler, got %v", err) 260 + } 261 + defer handler.Close() 262 + 263 + if handler.atproto.IsAuthenticated() { 264 + t.Error("Expected handler to not be authenticated with empty config") 265 + } 266 + }) 267 + }) 268 + 269 + t.Run("Close", func(t *testing.T) { 270 + t.Run("cleans up resources properly", func(t *testing.T) { 271 + suite := NewHandlerTestSuite(t) 272 + defer suite.Cleanup() 273 + 274 + handler, err := NewPublicationHandler() 275 + if err != nil { 276 + t.Fatalf("Expected no error creating handler, got %v", err) 277 + } 278 + 279 + err = handler.Close() 280 + if err != nil { 281 + t.Errorf("Expected no error on close, got %v", err) 282 + } 283 + }) 284 + }) 285 + }
+92 -18
internal/services/atproto.go
··· 1 - // TODO: Implement authentication using indigo's xrpc client: 2 - // 1. Create session via com.atproto.server.createSession 3 - // 2. Store session tokens 4 - // 3. Handle token refresh 5 - // 6 - // TODO: Implement authentication 7 - // 1. Create XRPC client 8 - // 2. Call com.atproto.server.createSession 9 - // 3. Parse response and store session 10 - // 4. Resolve PDS URL from DID 11 - // 12 1 // TODO: Implement document fetching: 13 2 // 1. Call com.atproto.sync.getRepo to get repository CAR file 14 3 // 2. Parse CAR (Content Addressable aRchive) format ··· 36 25 "fmt" 37 26 "time" 38 27 28 + "github.com/bluesky-social/indigo/api/atproto" 29 + "github.com/bluesky-social/indigo/xrpc" 39 30 "github.com/stormlightlabs/noteleaf/internal/public" 40 31 ) 41 32 ··· 70 61 password string 71 62 session *Session 72 63 pdsURL string // Personal Data Server URL 73 - // TODO: wrap AT Protocol client from indigo package 74 - // client *xrpc.Client 64 + client *xrpc.Client 75 65 } 76 66 77 67 // NewATProtoService creates a new AT Protocol service 78 68 func NewATProtoService() *ATProtoService { 69 + pdsURL := "https://bsky.social" 79 70 return &ATProtoService{ 80 - pdsURL: "https://bsky.social", 71 + pdsURL: pdsURL, 72 + client: &xrpc.Client{ 73 + Host: pdsURL, 74 + }, 81 75 } 82 76 } 83 77 ··· 89 83 90 84 s.handle = handle 91 85 s.password = password 86 + 87 + input := &atproto.ServerCreateSession_Input{ 88 + Identifier: handle, 89 + Password: password, 90 + } 91 + 92 + output, err := atproto.ServerCreateSession(ctx, s.client, input) 93 + if err != nil { 94 + return fmt.Errorf("failed to create session: %w", err) 95 + } 96 + 97 + expiresAt := time.Now().Add(2 * time.Hour) 98 + 92 99 s.session = &Session{ 93 - Handle: handle, 94 - // TODO: Set to true once auth is implemented 95 - Authenticated: false, 100 + DID: output.Did, 101 + Handle: output.Handle, 102 + AccessJWT: output.AccessJwt, 103 + RefreshJWT: output.RefreshJwt, 96 104 PDSURL: s.pdsURL, 105 + ExpiresAt: expiresAt, 106 + Authenticated: true, 97 107 } 98 108 99 - return fmt.Errorf("TODO: implement com.atproto.server.createSession") 109 + s.client.Auth = &xrpc.AuthInfo{ 110 + AccessJwt: output.AccessJwt, 111 + RefreshJwt: output.RefreshJwt, 112 + Handle: output.Handle, 113 + Did: output.Did, 114 + } 115 + 116 + return nil 100 117 } 101 118 102 119 // GetSession returns the current session information ··· 110 127 // IsAuthenticated checks if the service has a valid session 111 128 func (s *ATProtoService) IsAuthenticated() bool { 112 129 return s.session != nil && s.session.Authenticated 130 + } 131 + 132 + // RestoreSession restores a previously authenticated session from stored credentials 133 + func (s *ATProtoService) RestoreSession(session *Session) error { 134 + if session == nil { 135 + return fmt.Errorf("session cannot be nil") 136 + } 137 + 138 + if session.DID == "" || session.AccessJWT == "" || session.RefreshJWT == "" { 139 + return fmt.Errorf("session missing required fields (DID, AccessJWT, RefreshJWT)") 140 + } 141 + 142 + s.session = session 143 + 144 + s.client.Auth = &xrpc.AuthInfo{ 145 + AccessJwt: session.AccessJWT, 146 + RefreshJwt: session.RefreshJWT, 147 + Handle: session.Handle, 148 + Did: session.DID, 149 + } 150 + 151 + if session.PDSURL != "" { 152 + s.pdsURL = session.PDSURL 153 + s.client.Host = session.PDSURL 154 + } 155 + 156 + return nil 157 + } 158 + 159 + // RefreshToken refreshes the access token using the refresh token 160 + func (s *ATProtoService) RefreshToken(ctx context.Context) error { 161 + if s.session == nil || s.session.RefreshJWT == "" { 162 + return fmt.Errorf("no session available to refresh") 163 + } 164 + 165 + s.client.Auth = &xrpc.AuthInfo{ 166 + AccessJwt: s.session.AccessJWT, 167 + RefreshJwt: s.session.RefreshJWT, 168 + Handle: s.session.Handle, 169 + Did: s.session.DID, 170 + } 171 + 172 + output, err := atproto.ServerRefreshSession(ctx, s.client) 173 + if err != nil { 174 + return fmt.Errorf("failed to refresh session: %w", err) 175 + } 176 + 177 + expiresAt := time.Now().Add(2 * time.Hour) 178 + s.session.AccessJWT = output.AccessJwt 179 + s.session.RefreshJWT = output.RefreshJwt 180 + s.session.ExpiresAt = expiresAt 181 + s.session.Authenticated = true 182 + 183 + s.client.Auth.AccessJwt = output.AccessJwt 184 + s.client.Auth.RefreshJwt = output.RefreshJwt 185 + 186 + return nil 113 187 } 114 188 115 189 // PullDocuments fetches all leaflet documents from the user's repository
+316
internal/services/atproto_test.go
··· 1 + package services 2 + 3 + import ( 4 + "context" 5 + "testing" 6 + "time" 7 + ) 8 + 9 + func TestATProtoService(t *testing.T) { 10 + t.Run("NewATProtoService", func(t *testing.T) { 11 + t.Run("creates service with default configuration", func(t *testing.T) { 12 + svc := NewATProtoService() 13 + 14 + if svc == nil { 15 + t.Fatal("Expected service to be created, got nil") 16 + } 17 + 18 + if svc.pdsURL != "https://bsky.social" { 19 + t.Errorf("Expected pdsURL to be 'https://bsky.social', got '%s'", svc.pdsURL) 20 + } 21 + 22 + if svc.client == nil { 23 + t.Fatal("Expected client to be initialized, got nil") 24 + } 25 + 26 + if svc.client.Host != "https://bsky.social" { 27 + t.Errorf("Expected client Host to be 'https://bsky.social', got '%s'", svc.client.Host) 28 + } 29 + }) 30 + }) 31 + 32 + t.Run("Authenticate", func(t *testing.T) { 33 + t.Run("validates required parameters", func(t *testing.T) { 34 + svc := NewATProtoService() 35 + ctx := context.Background() 36 + 37 + err := svc.Authenticate(ctx, "", "password") 38 + if err == nil { 39 + t.Error("Expected error for empty handle, got nil") 40 + } 41 + 42 + err = svc.Authenticate(ctx, "handle", "") 43 + if err == nil { 44 + t.Error("Expected error for empty password, got nil") 45 + } 46 + 47 + err = svc.Authenticate(ctx, "", "") 48 + if err == nil { 49 + t.Error("Expected error for empty handle and password, got nil") 50 + } 51 + }) 52 + }) 53 + 54 + t.Run("IsAuthenticated", func(t *testing.T) { 55 + t.Run("returns false when no session exists", func(t *testing.T) { 56 + svc := NewATProtoService() 57 + 58 + if svc.IsAuthenticated() { 59 + t.Error("Expected IsAuthenticated to return false for new service") 60 + } 61 + }) 62 + 63 + t.Run("returns false when session is not authenticated", func(t *testing.T) { 64 + svc := NewATProtoService() 65 + svc.session = &Session{ 66 + Handle: "test.bsky.social", 67 + Authenticated: false, 68 + } 69 + 70 + if svc.IsAuthenticated() { 71 + t.Error("Expected IsAuthenticated to return false for unauthenticated session") 72 + } 73 + }) 74 + 75 + t.Run("returns true when session is authenticated", func(t *testing.T) { 76 + svc := NewATProtoService() 77 + svc.session = &Session{ 78 + Handle: "test.bsky.social", 79 + Authenticated: true, 80 + } 81 + 82 + if !svc.IsAuthenticated() { 83 + t.Error("Expected IsAuthenticated to return true for authenticated session") 84 + } 85 + }) 86 + }) 87 + 88 + t.Run("GetSession", func(t *testing.T) { 89 + t.Run("returns error when not authenticated", func(t *testing.T) { 90 + svc := NewATProtoService() 91 + 92 + session, err := svc.GetSession() 93 + if err == nil { 94 + t.Error("Expected error when getting session without authentication") 95 + } 96 + if session != nil { 97 + t.Error("Expected nil session when not authenticated") 98 + } 99 + }) 100 + 101 + t.Run("returns session when authenticated", func(t *testing.T) { 102 + svc := NewATProtoService() 103 + expectedSession := &Session{ 104 + DID: "did:plc:test123", 105 + Handle: "test.bsky.social", 106 + AccessJWT: "access_token", 107 + RefreshJWT: "refresh_token", 108 + PDSURL: "https://bsky.social", 109 + ExpiresAt: time.Now().Add(2 * time.Hour), 110 + Authenticated: true, 111 + } 112 + svc.session = expectedSession 113 + 114 + session, err := svc.GetSession() 115 + if err != nil { 116 + t.Errorf("Expected no error, got %v", err) 117 + } 118 + if session == nil { 119 + t.Fatal("Expected session to be returned, got nil") 120 + } 121 + if session.DID != expectedSession.DID { 122 + t.Errorf("Expected DID '%s', got '%s'", expectedSession.DID, session.DID) 123 + } 124 + if session.Handle != expectedSession.Handle { 125 + t.Errorf("Expected Handle '%s', got '%s'", expectedSession.Handle, session.Handle) 126 + } 127 + }) 128 + }) 129 + 130 + t.Run("RefreshToken", func(t *testing.T) { 131 + t.Run("returns error when no session exists", func(t *testing.T) { 132 + svc := NewATProtoService() 133 + ctx := context.Background() 134 + 135 + err := svc.RefreshToken(ctx) 136 + if err == nil { 137 + t.Error("Expected error when refreshing without session") 138 + } 139 + }) 140 + 141 + t.Run("returns error when refresh token is empty", func(t *testing.T) { 142 + svc := NewATProtoService() 143 + ctx := context.Background() 144 + svc.session = &Session{ 145 + Handle: "test.bsky.social", 146 + RefreshJWT: "", 147 + } 148 + 149 + err := svc.RefreshToken(ctx) 150 + if err == nil { 151 + t.Error("Expected error when refreshing with empty refresh token") 152 + } 153 + }) 154 + }) 155 + 156 + t.Run("RestoreSession", func(t *testing.T) { 157 + t.Run("returns error when session is nil", func(t *testing.T) { 158 + svc := NewATProtoService() 159 + 160 + err := svc.RestoreSession(nil) 161 + if err == nil { 162 + t.Error("Expected error when restoring nil session") 163 + } 164 + }) 165 + 166 + t.Run("returns error when session missing DID", func(t *testing.T) { 167 + svc := NewATProtoService() 168 + session := &Session{ 169 + DID: "", 170 + Handle: "test.bsky.social", 171 + AccessJWT: "access_token", 172 + RefreshJWT: "refresh_token", 173 + } 174 + 175 + err := svc.RestoreSession(session) 176 + if err == nil { 177 + t.Error("Expected error when session missing DID") 178 + } 179 + }) 180 + 181 + t.Run("returns error when session missing AccessJWT", func(t *testing.T) { 182 + svc := NewATProtoService() 183 + session := &Session{ 184 + DID: "did:plc:test123", 185 + Handle: "test.bsky.social", 186 + AccessJWT: "", 187 + RefreshJWT: "refresh_token", 188 + } 189 + 190 + err := svc.RestoreSession(session) 191 + if err == nil { 192 + t.Error("Expected error when session missing AccessJWT") 193 + } 194 + }) 195 + 196 + t.Run("returns error when session missing RefreshJWT", func(t *testing.T) { 197 + svc := NewATProtoService() 198 + session := &Session{ 199 + DID: "did:plc:test123", 200 + Handle: "test.bsky.social", 201 + AccessJWT: "access_token", 202 + RefreshJWT: "", 203 + } 204 + 205 + err := svc.RestoreSession(session) 206 + if err == nil { 207 + t.Error("Expected error when session missing RefreshJWT") 208 + } 209 + }) 210 + 211 + t.Run("successfully restores valid session", func(t *testing.T) { 212 + svc := NewATProtoService() 213 + session := &Session{ 214 + DID: "did:plc:test123", 215 + Handle: "test.bsky.social", 216 + AccessJWT: "access_token", 217 + RefreshJWT: "refresh_token", 218 + PDSURL: "https://test.pds.example", 219 + ExpiresAt: time.Now().Add(2 * time.Hour), 220 + Authenticated: true, 221 + } 222 + 223 + err := svc.RestoreSession(session) 224 + if err != nil { 225 + t.Errorf("Expected no error, got %v", err) 226 + } 227 + 228 + if !svc.IsAuthenticated() { 229 + t.Error("Expected service to be authenticated after restore") 230 + } 231 + 232 + restoredSession, err := svc.GetSession() 233 + if err != nil { 234 + t.Errorf("Expected to get session, got error: %v", err) 235 + } 236 + if restoredSession.DID != session.DID { 237 + t.Errorf("Expected DID '%s', got '%s'", session.DID, restoredSession.DID) 238 + } 239 + if restoredSession.Handle != session.Handle { 240 + t.Errorf("Expected Handle '%s', got '%s'", session.Handle, restoredSession.Handle) 241 + } 242 + }) 243 + 244 + t.Run("updates client authentication", func(t *testing.T) { 245 + svc := NewATProtoService() 246 + session := &Session{ 247 + DID: "did:plc:test123", 248 + Handle: "test.bsky.social", 249 + AccessJWT: "access_token", 250 + RefreshJWT: "refresh_token", 251 + PDSURL: "https://test.pds.example", 252 + ExpiresAt: time.Now().Add(2 * time.Hour), 253 + Authenticated: true, 254 + } 255 + 256 + err := svc.RestoreSession(session) 257 + if err != nil { 258 + t.Errorf("Expected no error, got %v", err) 259 + } 260 + 261 + if svc.client.Auth == nil { 262 + t.Fatal("Expected client Auth to be set") 263 + } 264 + if svc.client.Auth.Did != session.DID { 265 + t.Errorf("Expected client DID '%s', got '%s'", session.DID, svc.client.Auth.Did) 266 + } 267 + if svc.client.Auth.AccessJwt != session.AccessJWT { 268 + t.Errorf("Expected client AccessJwt '%s', got '%s'", session.AccessJWT, svc.client.Auth.AccessJwt) 269 + } 270 + }) 271 + 272 + t.Run("updates PDS URL when provided", func(t *testing.T) { 273 + svc := NewATProtoService() 274 + customPDS := "https://custom.pds.example" 275 + session := &Session{ 276 + DID: "did:plc:test123", 277 + Handle: "test.bsky.social", 278 + AccessJWT: "access_token", 279 + RefreshJWT: "refresh_token", 280 + PDSURL: customPDS, 281 + ExpiresAt: time.Now().Add(2 * time.Hour), 282 + Authenticated: true, 283 + } 284 + 285 + err := svc.RestoreSession(session) 286 + if err != nil { 287 + t.Errorf("Expected no error, got %v", err) 288 + } 289 + 290 + if svc.pdsURL != customPDS { 291 + t.Errorf("Expected pdsURL '%s', got '%s'", customPDS, svc.pdsURL) 292 + } 293 + if svc.client.Host != customPDS { 294 + t.Errorf("Expected client Host '%s', got '%s'", customPDS, svc.client.Host) 295 + } 296 + }) 297 + }) 298 + 299 + t.Run("Close", func(t *testing.T) { 300 + t.Run("clears session", func(t *testing.T) { 301 + svc := NewATProtoService() 302 + svc.session = &Session{ 303 + Handle: "test.bsky.social", 304 + Authenticated: true, 305 + } 306 + 307 + err := svc.Close() 308 + if err != nil { 309 + t.Errorf("Expected no error on close, got %v", err) 310 + } 311 + if svc.session != nil { 312 + t.Error("Expected session to be nil after close") 313 + } 314 + }) 315 + }) 316 + }
+7
internal/store/config.go
··· 26 26 ExportFormat string `toml:"export_format"` 27 27 MovieAPIKey string `toml:"movie_api_key,omitempty"` 28 28 BookAPIKey string `toml:"book_api_key,omitempty"` 29 + 30 + ATProtoDID string `toml:"atproto_did,omitempty"` 31 + ATProtoHandle string `toml:"atproto_handle,omitempty"` 32 + ATProtoAccessJWT string `toml:"atproto_access_jwt,omitempty"` 33 + ATProtoRefreshJWT string `toml:"atproto_refresh_jwt,omitempty"` 34 + ATProtoPDSURL string `toml:"atproto_pds_url,omitempty"` 35 + ATProtoExpiresAt string `toml:"atproto_expires_at,omitempty"` // ISO8601 timestamp 29 36 } 30 37 31 38 // DefaultConfig returns a configuration with sensible defaults
+273
internal/ui/auth_form.go
··· 1 + package ui 2 + 3 + import ( 4 + "fmt" 5 + "io" 6 + "os" 7 + "strings" 8 + 9 + "github.com/charmbracelet/bubbles/key" 10 + "github.com/charmbracelet/bubbles/textinput" 11 + tea "github.com/charmbracelet/bubbletea" 12 + "github.com/charmbracelet/lipgloss" 13 + ) 14 + 15 + // AuthFormOptions configures the auth form display 16 + type AuthFormOptions struct { 17 + Output io.Writer 18 + Input io.Reader 19 + Width int 20 + Height int 21 + } 22 + 23 + // AuthFormResult holds the submitted credentials 24 + type AuthFormResult struct { 25 + Handle string 26 + Password string 27 + Canceled bool 28 + } 29 + 30 + // AuthForm provides an interactive form for AT Protocol authentication 31 + type AuthForm struct { 32 + initialHandle string 33 + opts AuthFormOptions 34 + } 35 + 36 + // NewAuthForm creates a new authentication form 37 + func NewAuthForm(initialHandle string, opts AuthFormOptions) *AuthForm { 38 + if opts.Output == nil { 39 + opts.Output = os.Stdout 40 + } 41 + if opts.Input == nil { 42 + opts.Input = os.Stdin 43 + } 44 + if opts.Width == 0 { 45 + opts.Width = 80 46 + } 47 + if opts.Height == 0 { 48 + opts.Height = 24 49 + } 50 + 51 + return &AuthForm{ 52 + initialHandle: initialHandle, 53 + opts: opts, 54 + } 55 + } 56 + 57 + type authFormKeyMap struct { 58 + Up key.Binding 59 + Down key.Binding 60 + Tab key.Binding 61 + ShiftTab key.Binding 62 + Enter key.Binding 63 + Submit key.Binding 64 + Cancel key.Binding 65 + } 66 + 67 + var authFormKeys = authFormKeyMap{ 68 + Up: key.NewBinding(key.WithKeys("up", "shift+tab"), key.WithHelp("โ†‘/shift+tab", "previous field")), 69 + Down: key.NewBinding(key.WithKeys("down", "tab"), key.WithHelp("โ†“/tab", "next field")), 70 + Tab: key.NewBinding(key.WithKeys("tab")), 71 + ShiftTab: key.NewBinding(key.WithKeys("shift+tab")), 72 + Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), 73 + Submit: key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "submit")), 74 + Cancel: key.NewBinding(key.WithKeys("esc", "ctrl+c"), key.WithHelp("esc/ctrl+c", "cancel")), 75 + } 76 + 77 + type authFormModel struct { 78 + handleInput textinput.Model 79 + passwordInput textinput.Model 80 + focusIndex int 81 + keys authFormKeyMap 82 + submitted bool 83 + canceled bool 84 + handleLocked bool 85 + } 86 + 87 + func (m authFormModel) Init() tea.Cmd { 88 + if m.handleLocked { 89 + return m.passwordInput.Focus() 90 + } 91 + return m.handleInput.Focus() 92 + } 93 + 94 + func (m authFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 95 + var cmds []tea.Cmd 96 + 97 + switch msg := msg.(type) { 98 + case tea.KeyMsg: 99 + switch { 100 + case key.Matches(msg, m.keys.Cancel): 101 + m.canceled = true 102 + return m, tea.Quit 103 + 104 + case key.Matches(msg, m.keys.Submit), key.Matches(msg, m.keys.Enter): 105 + if m.handleInput.Value() == "" { 106 + return m, nil 107 + } 108 + if m.passwordInput.Value() == "" { 109 + return m, nil 110 + } 111 + m.submitted = true 112 + return m, tea.Quit 113 + case key.Matches(msg, m.keys.Down), key.Matches(msg, m.keys.Tab): 114 + m.nextInput() 115 + cmds = append(cmds, m.updateFocus()) 116 + case key.Matches(msg, m.keys.Up), key.Matches(msg, m.keys.ShiftTab): 117 + m.prevInput() 118 + cmds = append(cmds, m.updateFocus()) 119 + } 120 + } 121 + 122 + cmd := m.updateInputs(msg) 123 + cmds = append(cmds, cmd) 124 + 125 + return m, tea.Batch(cmds...) 126 + } 127 + 128 + func (m *authFormModel) nextInput() { 129 + if m.handleLocked { 130 + m.focusIndex = 1 131 + } else { 132 + m.focusIndex = (m.focusIndex + 1) % 2 133 + } 134 + } 135 + 136 + func (m *authFormModel) prevInput() { 137 + if m.handleLocked { 138 + m.focusIndex = 1 139 + } else { 140 + m.focusIndex = (m.focusIndex - 1 + 2) % 2 141 + } 142 + } 143 + 144 + func (m *authFormModel) updateFocus() tea.Cmd { 145 + if m.focusIndex == 0 && !m.handleLocked { 146 + m.handleInput.Focus() 147 + m.passwordInput.Blur() 148 + return textinput.Blink 149 + } 150 + 151 + m.handleInput.Blur() 152 + m.passwordInput.Focus() 153 + return textinput.Blink 154 + } 155 + 156 + func (m *authFormModel) updateInputs(msg tea.Msg) tea.Cmd { 157 + var cmds []tea.Cmd 158 + var cmd tea.Cmd 159 + 160 + if !m.handleLocked { 161 + m.handleInput, cmd = m.handleInput.Update(msg) 162 + cmds = append(cmds, cmd) 163 + } 164 + 165 + m.passwordInput, cmd = m.passwordInput.Update(msg) 166 + cmds = append(cmds, cmd) 167 + 168 + return tea.Batch(cmds...) 169 + } 170 + 171 + func (m authFormModel) View() string { 172 + var b strings.Builder 173 + 174 + titleStyle := lipgloss.NewStyle(). 175 + Bold(true). 176 + Foreground(lipgloss.Color("6")). 177 + MarginBottom(1) 178 + 179 + labelStyle := lipgloss.NewStyle(). 180 + Foreground(lipgloss.Color("7")) 181 + 182 + helpStyle := lipgloss.NewStyle(). 183 + Foreground(lipgloss.Color("8")). 184 + MarginTop(1) 185 + 186 + errorStyle := lipgloss.NewStyle(). 187 + Foreground(lipgloss.Color("9")) 188 + 189 + b.WriteString(titleStyle.Render("AT Protocol Authentication")) 190 + b.WriteString("\n\n") 191 + 192 + b.WriteString(labelStyle.Render("BlueSky Handle:")) 193 + b.WriteString("\n") 194 + if m.handleLocked { 195 + lockedStyle := lipgloss.NewStyle(). 196 + Foreground(lipgloss.Color("8")) 197 + b.WriteString(lockedStyle.Render(m.handleInput.Value())) 198 + b.WriteString(lockedStyle.Render(" (locked)")) 199 + } else { 200 + b.WriteString(m.handleInput.View()) 201 + } 202 + b.WriteString("\n\n") 203 + 204 + b.WriteString(labelStyle.Render("App Password:")) 205 + b.WriteString("\n") 206 + b.WriteString(m.passwordInput.View()) 207 + b.WriteString("\n\n") 208 + 209 + if m.handleInput.Value() == "" { 210 + b.WriteString(errorStyle.Render("Handle is required")) 211 + b.WriteString("\n") 212 + } 213 + if m.passwordInput.Value() == "" { 214 + b.WriteString(errorStyle.Render("Password is required")) 215 + b.WriteString("\n") 216 + } 217 + 218 + b.WriteString("\n") 219 + helpText := "tab/shift+tab: navigate โ€ข enter/ctrl+s: submit โ€ข esc/ctrl+c: cancel" 220 + b.WriteString(helpStyle.Render(helpText)) 221 + 222 + return b.String() 223 + } 224 + 225 + // Run displays the auth form and returns the entered credentials 226 + func (af *AuthForm) Run() (*AuthFormResult, error) { 227 + handleInput := textinput.New() 228 + handleInput.Placeholder = "username.bsky.social" 229 + handleInput.Width = 40 230 + handleInput.CharLimit = 253 231 + 232 + passwordInput := textinput.New() 233 + passwordInput.Placeholder = "App password" 234 + passwordInput.Width = 40 235 + passwordInput.EchoMode = textinput.EchoPassword 236 + passwordInput.EchoCharacter = 'โ€ข' 237 + 238 + handleLocked := false 239 + focusIndex := 0 240 + 241 + if af.initialHandle != "" { 242 + handleInput.SetValue(af.initialHandle) 243 + handleLocked = true 244 + focusIndex = 1 245 + } 246 + 247 + model := authFormModel{ 248 + handleInput: handleInput, 249 + passwordInput: passwordInput, 250 + focusIndex: focusIndex, 251 + keys: authFormKeys, 252 + handleLocked: handleLocked, 253 + } 254 + 255 + program := tea.NewProgram( 256 + model, 257 + tea.WithInput(af.opts.Input), 258 + tea.WithOutput(af.opts.Output), 259 + ) 260 + 261 + finalModel, err := program.Run() 262 + if err != nil { 263 + return nil, fmt.Errorf("failed to run auth form: %w", err) 264 + } 265 + 266 + result := finalModel.(authFormModel) 267 + 268 + return &AuthFormResult{ 269 + Handle: result.handleInput.Value(), 270 + Password: result.passwordInput.Value(), 271 + Canceled: result.canceled, 272 + }, nil 273 + }
+419
internal/ui/auth_form_test.go
··· 1 + package ui 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + "time" 7 + 8 + "github.com/charmbracelet/bubbles/textinput" 9 + tea "github.com/charmbracelet/bubbletea" 10 + ) 11 + 12 + func createTestAuthFormModel(handle string) authFormModel { 13 + handleInput := textinput.New() 14 + handleInput.Placeholder = "username.bsky.social" 15 + handleInput.Width = 40 16 + 17 + passwordInput := textinput.New() 18 + passwordInput.Placeholder = "App password" 19 + passwordInput.Width = 40 20 + passwordInput.EchoMode = textinput.EchoPassword 21 + passwordInput.EchoCharacter = 'โ€ข' 22 + 23 + handleLocked := false 24 + focusIndex := 0 25 + 26 + if handle != "" { 27 + handleInput.SetValue(handle) 28 + handleLocked = true 29 + focusIndex = 1 30 + } 31 + 32 + return authFormModel{ 33 + handleInput: handleInput, 34 + passwordInput: passwordInput, 35 + focusIndex: focusIndex, 36 + keys: authFormKeys, 37 + handleLocked: handleLocked, 38 + } 39 + } 40 + 41 + func TestAuthFormModel(t *testing.T) { 42 + t.Run("Init", func(t *testing.T) { 43 + t.Run("focuses handle input when no initial handle", func(t *testing.T) { 44 + model := createTestAuthFormModel("") 45 + 46 + cmd := model.Init() 47 + if cmd == nil { 48 + t.Error("Expected Init to return a focus command") 49 + } 50 + }) 51 + 52 + t.Run("focuses password input when handle is locked", func(t *testing.T) { 53 + model := createTestAuthFormModel("test.bsky.social") 54 + 55 + cmd := model.Init() 56 + if cmd == nil { 57 + t.Error("Expected Init to return a focus command") 58 + } 59 + 60 + if !model.handleLocked { 61 + t.Error("Expected handleLocked to be true") 62 + } 63 + if model.focusIndex != 1 { 64 + t.Errorf("Expected focusIndex to be 1, got %d", model.focusIndex) 65 + } 66 + }) 67 + }) 68 + 69 + t.Run("Navigation", func(t *testing.T) { 70 + t.Run("tab moves to next field", func(t *testing.T) { 71 + model := createTestAuthFormModel("") 72 + if cmd := model.Init(); cmd != nil { 73 + model.handleInput.Focus() 74 + } 75 + 76 + suite := NewTUITestSuite(t, model) 77 + suite.Start() 78 + 79 + if err := suite.SendKey(tea.KeyTab); err != nil { 80 + t.Fatalf("Failed to send tab key: %v", err) 81 + } 82 + 83 + if err := suite.WaitFor(func(m tea.Model) bool { 84 + if authModel, ok := m.(authFormModel); ok { 85 + return authModel.focusIndex == 1 86 + } 87 + return false 88 + }, 1*time.Second); err != nil { 89 + t.Errorf("Expected focusIndex to change to 1: %v", err) 90 + } 91 + }) 92 + 93 + t.Run("shift+tab moves to previous field", func(t *testing.T) { 94 + model := createTestAuthFormModel("") 95 + if cmd := model.Init(); cmd != nil { 96 + model.handleInput.Focus() 97 + } 98 + model.focusIndex = 1 99 + 100 + suite := NewTUITestSuite(t, model) 101 + suite.Start() 102 + 103 + if err := suite.SendKey(tea.KeyShiftTab); err != nil { 104 + t.Fatalf("Failed to send shift+tab key: %v", err) 105 + } 106 + 107 + if err := suite.WaitFor(func(m tea.Model) bool { 108 + if authModel, ok := m.(authFormModel); ok { 109 + return authModel.focusIndex == 0 110 + } 111 + return false 112 + }, 1*time.Second); err != nil { 113 + t.Errorf("Expected focusIndex to change to 0: %v", err) 114 + } 115 + }) 116 + 117 + t.Run("locked handle prevents navigation to handle field", func(t *testing.T) { 118 + model := createTestAuthFormModel("test.bsky.social") 119 + 120 + suite := NewTUITestSuite(t, model) 121 + suite.Start() 122 + 123 + if err := suite.SendKey(tea.KeyShiftTab); err != nil { 124 + t.Fatalf("Failed to send shift+tab key: %v", err) 125 + } 126 + 127 + if err := suite.WaitFor(func(m tea.Model) bool { 128 + if authModel, ok := m.(authFormModel); ok { 129 + return authModel.focusIndex == 1 130 + } 131 + return false 132 + }, 500*time.Millisecond); err != nil { 133 + t.Errorf("Expected focusIndex to stay at 1 when handle is locked: %v", err) 134 + } 135 + }) 136 + }) 137 + 138 + t.Run("Submission", func(t *testing.T) { 139 + t.Run("enter submits when both fields are filled", func(t *testing.T) { 140 + model := createTestAuthFormModel("") 141 + model.handleInput.SetValue("test.bsky.social") 142 + model.passwordInput.SetValue("test-password") 143 + 144 + suite := NewTUITestSuite(t, model) 145 + suite.Start() 146 + 147 + if err := suite.SendKey(tea.KeyEnter); err != nil { 148 + t.Fatalf("Failed to send enter key: %v", err) 149 + } 150 + 151 + if err := suite.WaitFor(func(m tea.Model) bool { 152 + if authModel, ok := m.(authFormModel); ok { 153 + return authModel.submitted 154 + } 155 + return false 156 + }, 1*time.Second); err != nil { 157 + t.Errorf("Expected model to be submitted: %v", err) 158 + } 159 + }) 160 + 161 + t.Run("enter does not submit when handle is empty", func(t *testing.T) { 162 + model := createTestAuthFormModel("") 163 + model.passwordInput.SetValue("test-password") 164 + 165 + suite := NewTUITestSuite(t, model) 166 + suite.Start() 167 + 168 + if err := suite.SendKey(tea.KeyEnter); err != nil { 169 + t.Fatalf("Failed to send enter key: %v", err) 170 + } 171 + 172 + time.Sleep(100 * time.Millisecond) 173 + 174 + currentModel := suite.GetCurrentModel() 175 + if authModel, ok := currentModel.(authFormModel); ok { 176 + if authModel.submitted { 177 + t.Error("Expected model to not be submitted when handle is empty") 178 + } 179 + } 180 + }) 181 + 182 + t.Run("enter does not submit when password is empty", func(t *testing.T) { 183 + model := createTestAuthFormModel("") 184 + model.handleInput.SetValue("test.bsky.social") 185 + 186 + suite := NewTUITestSuite(t, model) 187 + suite.Start() 188 + 189 + if err := suite.SendKey(tea.KeyEnter); err != nil { 190 + t.Fatalf("Failed to send enter key: %v", err) 191 + } 192 + 193 + time.Sleep(100 * time.Millisecond) 194 + 195 + currentModel := suite.GetCurrentModel() 196 + if authModel, ok := currentModel.(authFormModel); ok { 197 + if authModel.submitted { 198 + t.Error("Expected model to not be submitted when password is empty") 199 + } 200 + } 201 + }) 202 + 203 + t.Run("ctrl+s submits when both fields are filled", func(t *testing.T) { 204 + model := createTestAuthFormModel("") 205 + model.handleInput.SetValue("test.bsky.social") 206 + model.passwordInput.SetValue("test-password") 207 + 208 + suite := NewTUITestSuite(t, model) 209 + suite.Start() 210 + 211 + if err := suite.SendKeyString("ctrl+s"); err != nil { 212 + t.Fatalf("Failed to send ctrl+s: %v", err) 213 + } 214 + 215 + if err := suite.WaitFor(func(m tea.Model) bool { 216 + if authModel, ok := m.(authFormModel); ok { 217 + return authModel.submitted 218 + } 219 + return false 220 + }, 1*time.Second); err != nil { 221 + t.Errorf("Expected model to be submitted: %v", err) 222 + } 223 + }) 224 + }) 225 + 226 + t.Run("Cancellation", func(t *testing.T) { 227 + t.Run("esc cancels the form", func(t *testing.T) { 228 + model := createTestAuthFormModel("") 229 + 230 + suite := NewTUITestSuite(t, model) 231 + suite.Start() 232 + 233 + if err := suite.SendKey(tea.KeyEsc); err != nil { 234 + t.Fatalf("Failed to send esc key: %v", err) 235 + } 236 + 237 + if err := suite.WaitFor(func(m tea.Model) bool { 238 + if authModel, ok := m.(authFormModel); ok { 239 + return authModel.canceled 240 + } 241 + return false 242 + }, 1*time.Second); err != nil { 243 + t.Errorf("Expected model to be canceled: %v", err) 244 + } 245 + }) 246 + 247 + t.Run("ctrl+c cancels the form", func(t *testing.T) { 248 + model := createTestAuthFormModel("") 249 + 250 + suite := NewTUITestSuite(t, model) 251 + suite.Start() 252 + 253 + if err := suite.SendKeyString("ctrl+c"); err != nil { 254 + t.Fatalf("Failed to send ctrl+c: %v", err) 255 + } 256 + 257 + if err := suite.WaitFor(func(m tea.Model) bool { 258 + if authModel, ok := m.(authFormModel); ok { 259 + return authModel.canceled 260 + } 261 + return false 262 + }, 1*time.Second); err != nil { 263 + t.Errorf("Expected model to be canceled: %v", err) 264 + } 265 + }) 266 + }) 267 + 268 + t.Run("View", func(t *testing.T) { 269 + t.Run("displays handle and password fields", func(t *testing.T) { 270 + model := createTestAuthFormModel("") 271 + 272 + view := model.View() 273 + 274 + if !strings.Contains(view, "AT Protocol Authentication") { 275 + t.Error("Expected view to contain title") 276 + } 277 + if !strings.Contains(view, "BlueSky Handle:") { 278 + t.Error("Expected view to contain handle label") 279 + } 280 + if !strings.Contains(view, "App Password:") { 281 + t.Error("Expected view to contain password label") 282 + } 283 + }) 284 + 285 + t.Run("displays locked status for handle", func(t *testing.T) { 286 + model := createTestAuthFormModel("test.bsky.social") 287 + 288 + view := model.View() 289 + 290 + if !strings.Contains(view, "test.bsky.social") { 291 + t.Error("Expected view to contain handle value") 292 + } 293 + if !strings.Contains(view, "(locked)") { 294 + t.Error("Expected view to indicate handle is locked") 295 + } 296 + }) 297 + 298 + t.Run("displays validation messages when fields are empty", func(t *testing.T) { 299 + model := createTestAuthFormModel("") 300 + 301 + view := model.View() 302 + 303 + if !strings.Contains(view, "Handle is required") { 304 + t.Error("Expected view to show handle validation message") 305 + } 306 + if !strings.Contains(view, "Password is required") { 307 + t.Error("Expected view to show password validation message") 308 + } 309 + }) 310 + 311 + t.Run("displays help text", func(t *testing.T) { 312 + model := createTestAuthFormModel("") 313 + 314 + view := model.View() 315 + 316 + if !strings.Contains(view, "tab/shift+tab: navigate") { 317 + t.Error("Expected view to contain navigation help") 318 + } 319 + if !strings.Contains(view, "enter/ctrl+s: submit") { 320 + t.Error("Expected view to contain submit help") 321 + } 322 + if !strings.Contains(view, "esc/ctrl+c: cancel") { 323 + t.Error("Expected view to contain cancel help") 324 + } 325 + }) 326 + }) 327 + 328 + t.Run("Input handling", func(t *testing.T) { 329 + t.Run("accepts text input in handle field", func(t *testing.T) { 330 + model := createTestAuthFormModel("") 331 + if cmd := model.Init(); cmd != nil { 332 + model.handleInput.Focus() 333 + } 334 + 335 + suite := NewTUITestSuite(t, model) 336 + suite.Start() 337 + 338 + if err := suite.SendKeyString("t"); err != nil { 339 + t.Fatalf("Failed to send 't' key: %v", err) 340 + } 341 + 342 + if err := suite.WaitFor(func(m tea.Model) bool { 343 + if authModel, ok := m.(authFormModel); ok { 344 + return authModel.handleInput.Value() == "t" 345 + } 346 + return false 347 + }, 1*time.Second); err != nil { 348 + t.Errorf("Expected handle input to contain 't': %v", err) 349 + } 350 + }) 351 + 352 + t.Run("does not accept text input in locked handle field", func(t *testing.T) { 353 + model := createTestAuthFormModel("test.bsky.social") 354 + originalValue := model.handleInput.Value() 355 + 356 + suite := NewTUITestSuite(t, model) 357 + suite.Start() 358 + 359 + if err := suite.SendKeyString("x"); err != nil { 360 + t.Fatalf("Failed to send 'x' key: %v", err) 361 + } 362 + 363 + time.Sleep(100 * time.Millisecond) 364 + 365 + currentModel := suite.GetCurrentModel() 366 + if authModel, ok := currentModel.(authFormModel); ok { 367 + if authModel.handleInput.Value() != originalValue { 368 + t.Errorf("Expected handle input to remain unchanged, got '%s'", authModel.handleInput.Value()) 369 + } 370 + } 371 + }) 372 + }) 373 + } 374 + 375 + func TestNewAuthForm(t *testing.T) { 376 + t.Run("creates form with default options", func(t *testing.T) { 377 + form := NewAuthForm("", AuthFormOptions{}) 378 + 379 + if form == nil { 380 + t.Fatal("Expected form to be created") 381 + } 382 + if form.opts.Width != 80 { 383 + t.Errorf("Expected default width 80, got %d", form.opts.Width) 384 + } 385 + if form.opts.Height != 24 { 386 + t.Errorf("Expected default height 24, got %d", form.opts.Height) 387 + } 388 + if form.opts.Output == nil { 389 + t.Error("Expected output to be set to default") 390 + } 391 + if form.opts.Input == nil { 392 + t.Error("Expected input to be set to default") 393 + } 394 + }) 395 + 396 + t.Run("creates form with initial handle", func(t *testing.T) { 397 + handle := "test.bsky.social" 398 + form := NewAuthForm(handle, AuthFormOptions{}) 399 + 400 + if form.initialHandle != handle { 401 + t.Errorf("Expected initialHandle '%s', got '%s'", handle, form.initialHandle) 402 + } 403 + }) 404 + 405 + t.Run("creates form with custom options", func(t *testing.T) { 406 + opts := AuthFormOptions{ 407 + Width: 100, 408 + Height: 30, 409 + } 410 + form := NewAuthForm("", opts) 411 + 412 + if form.opts.Width != 100 { 413 + t.Errorf("Expected width 100, got %d", form.opts.Width) 414 + } 415 + if form.opts.Height != 30 { 416 + t.Errorf("Expected height 30, got %d", form.opts.Height) 417 + } 418 + }) 419 + }