A CLI for tangled.sh

initial commit

+18
LICENSE
···
··· 1 + Copyright 2025 Tim Culverhouse 2 + 3 + Permission is hereby granted, free of charge, to any person obtaining a copy of 4 + this software and associated documentation files (the “Software”), to deal in 5 + the Software without restriction, including without limitation the rights to 6 + use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 + the Software, and to permit persons to whom the Software is furnished to do so, 8 + subject to the following conditions: 9 + 10 + The above copyright notice and this permission notice shall be included in all 11 + copies or substantial portions of the Software. 12 + 13 + THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 + FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 + COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 + IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 + CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+37
README.md
···
··· 1 + # knit 2 + 3 + A CLI for tangled.sh 4 + 5 + ## Installation 6 + 7 + `go install tangled.sh/rockorager.dev/knit` 8 + 9 + ## Usage 10 + 11 + `knit` has similar usage patterns as the Github CLI (`gh`). Prior to making any 12 + authenticated calls, you must authenticate with tangled.sh 13 + 14 + ``` 15 + knit auth login 16 + ``` 17 + 18 + This will require your ATProto handle and app password (the app password can be 19 + specific to knit). Knit will *not* store the password. Instead, knit 20 + authenticates with tangled.sh and stores the session cookie into your system 21 + keyring. Note to linux users: Your keyring *must be setup* for this to work 22 + properly 23 + 24 + Authenticating will also create a default configuration file which maps a list 25 + of handles to a host. The first handle in the list is the default handle. 26 + 27 + ### Creating repos 28 + 29 + To create a repo, run: 30 + 31 + ``` 32 + knit repo create 33 + ``` 34 + 35 + This will guide you through an interactive prompt to set up the repository. 36 + Currently, we can't request the configured knot servers for a given user - the 37 + only option is the default tangled.sh server
+21
auth/auth.go
···
··· 1 + package auth 2 + 3 + import "github.com/spf13/cobra" 4 + 5 + func Command() *cobra.Command { 6 + auth := &cobra.Command{ 7 + Use: "auth", 8 + Short: "Authenticate knit with tangled.sh", 9 + Run: func(cmd *cobra.Command, args []string) { 10 + cmd.Usage() 11 + }, 12 + } 13 + 14 + auth.AddCommand(&cobra.Command{ 15 + Use: "login", 16 + Short: "Authenticate with tangled.sh", 17 + RunE: Login, 18 + }) 19 + 20 + return auth 21 + }
+54
auth/client.go
···
··· 1 + package auth 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/http/cookiejar" 7 + "net/url" 8 + 9 + "github.com/zalando/go-keyring" 10 + ) 11 + 12 + const service = "knit://" 13 + 14 + // NewClient creates a new http.Client with the appropriate authentication set 15 + func NewClient(host string, handle string) (*http.Client, error) { 16 + cookies, err := getCookies(host, handle) 17 + if err != nil { 18 + return nil, err 19 + } 20 + 21 + // cookiejar.New can't fail 22 + jar, _ := cookiejar.New(nil) 23 + 24 + u := &url.URL{ 25 + Scheme: "https", 26 + Host: host, 27 + } 28 + jar.SetCookies(u, cookies) 29 + 30 + return &http.Client{Jar: jar}, nil 31 + } 32 + 33 + // saveCookies serializes the cookies and saves them to the system keyring 34 + func saveCookies(cookies []*http.Cookie, host string, handle string) error { 35 + data, err := json.Marshal(cookies) 36 + if err != nil { 37 + return err 38 + } 39 + return keyring.Set(service+host, handle, string(data)) 40 + } 41 + 42 + func getCookies(host string, handle string) ([]*http.Cookie, error) { 43 + s, err := keyring.Get(service+host, handle) 44 + if err != nil { 45 + return nil, err 46 + } 47 + 48 + var cookies []*http.Cookie 49 + if err := json.Unmarshal([]byte(s), &cookies); err != nil { 50 + return nil, err 51 + } 52 + 53 + return cookies, nil 54 + }
+107
auth/login.go
···
··· 1 + package auth 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + "net/url" 8 + "strings" 9 + "sync" 10 + 11 + "github.com/charmbracelet/huh" 12 + "github.com/charmbracelet/huh/spinner" 13 + "github.com/spf13/cobra" 14 + "tangled.sh/rockorager.dev/knit" 15 + "tangled.sh/rockorager.dev/knit/config" 16 + ) 17 + 18 + func Login(cmd *cobra.Command, args []string) error { 19 + var ( 20 + handle string 21 + appPassword string 22 + ) 23 + 24 + host := knit.DefaultHost 25 + 26 + handleInput := huh.NewInput(). 27 + Title("Handle"). 28 + Value(&handle) 29 + 30 + if err := handleInput.Run(); err != nil { 31 + return fmt.Errorf("getting handle: %w", err) 32 + } 33 + 34 + appPasswordInput := huh.NewInput(). 35 + Title("App Password"). 36 + EchoMode(huh.EchoModePassword). 37 + Value(&appPassword) 38 + 39 + if err := appPasswordInput.Run(); err != nil { 40 + return fmt.Errorf("getting app-password: %w", err) 41 + } 42 + 43 + ctx, stop := context.WithCancel(cmd.Context()) 44 + defer stop() 45 + 46 + wg := &sync.WaitGroup{} 47 + wg.Add(1) 48 + 49 + go func(wg *sync.WaitGroup) { 50 + spinner.New(). 51 + Context(ctx). 52 + Title("Authenticating..."). 53 + Run() 54 + wg.Done() 55 + }(wg) 56 + 57 + cookies, err := login(host, handle, appPassword) 58 + if err != nil { 59 + return fmt.Errorf("authenticating: %w", err) 60 + } 61 + 62 + if err := saveCookies(cookies, "tangled.sh", handle); err != nil { 63 + return err 64 + } 65 + 66 + if err := config.AddHandleToHost(host, handle); err != nil { 67 + return err 68 + } 69 + 70 + stop() 71 + wg.Wait() 72 + fmt.Printf("\x1b[32m✔\x1b[m %s logged in to %s\r\n", handle, host) 73 + return nil 74 + } 75 + 76 + func login(host string, handle string, appPassword string) ([]*http.Cookie, error) { 77 + form := url.Values{} 78 + form.Set("handle", handle) 79 + form.Set("app_password", appPassword) 80 + 81 + u := url.URL{ 82 + Scheme: "https", 83 + Host: host, 84 + Path: "/login", 85 + } 86 + 87 + resp, err := http.Post( 88 + u.String(), 89 + "application/x-www-form-urlencoded", 90 + strings.NewReader(form.Encode()), 91 + ) 92 + if err != nil { 93 + return nil, err 94 + } 95 + defer resp.Body.Close() 96 + 97 + if resp.StatusCode != http.StatusOK { 98 + return nil, fmt.Errorf("unexpected status code: %d", 99 + resp.StatusCode) 100 + } 101 + 102 + if len(resp.Cookies()) == 0 { 103 + return nil, fmt.Errorf("session cookie not found") 104 + } 105 + 106 + return resp.Cookies(), nil 107 + }
+32
cmd/knit/main.go
···
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "os" 7 + 8 + "github.com/spf13/cobra" 9 + "tangled.sh/rockorager.dev/knit/auth" 10 + "tangled.sh/rockorager.dev/knit/config" 11 + "tangled.sh/rockorager.dev/knit/repo" 12 + ) 13 + 14 + func main() { 15 + root := &cobra.Command{ 16 + Use: "knit", 17 + Short: "knit is a CLI for tangled.sh", 18 + } 19 + 20 + root.AddCommand(auth.Command()) 21 + root.AddCommand(repo.Command()) 22 + 23 + if err := config.LoadOrCreate(); err != nil { 24 + fmt.Fprintf(os.Stderr, "%s", err) 25 + os.Exit(1) 26 + } 27 + 28 + if err := root.ExecuteContext(context.Background()); err != nil { 29 + fmt.Fprintf(os.Stderr, "%s", err) 30 + os.Exit(1) 31 + } 32 + }
+105
config/config.go
···
··· 1 + package config 2 + 3 + import ( 4 + "errors" 5 + "io" 6 + "os" 7 + "path" 8 + "slices" 9 + 10 + "github.com/pelletier/go-toml/v2" 11 + ) 12 + 13 + var globalConfig = make(map[string]any) 14 + 15 + func LoadOrCreate() error { 16 + cfgDir, err := os.UserConfigDir() 17 + if err != nil { 18 + return err 19 + } 20 + cfgPath := path.Join(cfgDir, "knit", "config.toml") 21 + 22 + f, err := os.Open(cfgPath) 23 + if errors.Is(err, os.ErrNotExist) { 24 + return createConfig() 25 + } 26 + defer f.Close() 27 + 28 + data, err := io.ReadAll(f) 29 + if err != nil { 30 + return err 31 + } 32 + 33 + if err := toml.Unmarshal(data, &globalConfig); err != nil { 34 + return err 35 + } 36 + 37 + return nil 38 + } 39 + 40 + func createConfig() error { 41 + cfgDir, err := os.UserConfigDir() 42 + if err != nil { 43 + return err 44 + } 45 + cfgPath := path.Join(cfgDir, "knit", "config.toml") 46 + 47 + return os.WriteFile(cfgPath, []byte{}, 0o600) 48 + } 49 + 50 + func HandlesForHost(host string) []string { 51 + hostCfg, ok := globalConfig[host].(map[string]any) 52 + if !ok { 53 + return []string{} 54 + } 55 + 56 + handles, ok := hostCfg["handles"].([]interface{}) 57 + if !ok { 58 + return []string{} 59 + } 60 + asStrings := make([]string, 0, len(handles)) 61 + for _, handle := range handles { 62 + asStrings = append(asStrings, handle.(string)) 63 + } 64 + return asStrings 65 + } 66 + 67 + func DefaultHandleForHost(host string) string { 68 + handles := HandlesForHost(host) 69 + if len(handles) == 0 { 70 + return "" 71 + } 72 + return handles[0] 73 + } 74 + 75 + func AddHandleToHost(host string, handle string) error { 76 + handles := HandlesForHost(host) 77 + if slices.Contains(handles, handle) { 78 + return nil 79 + } 80 + 81 + handles = append(handles, handle) 82 + 83 + hostCfg, ok := globalConfig[host].(map[string]any) 84 + if !ok { 85 + hostCfg = make(map[string]any) 86 + } 87 + 88 + hostCfg["handles"] = handles 89 + globalConfig[host] = hostCfg 90 + return WriteConfig() 91 + } 92 + 93 + func WriteConfig() error { 94 + cfgDir, err := os.UserConfigDir() 95 + if err != nil { 96 + return err 97 + } 98 + cfgPath := path.Join(cfgDir, "knit", "config.toml") 99 + data, err := toml.Marshal(globalConfig) 100 + if err != nil { 101 + return err 102 + } 103 + 104 + return os.WriteFile(cfgPath, data, 0o600) 105 + }
+46
go.mod
···
··· 1 + module tangled.sh/rockorager.dev/knit 2 + 3 + go 1.24.3 4 + 5 + require ( 6 + github.com/charmbracelet/huh v0.7.0 7 + github.com/charmbracelet/huh/spinner v0.0.0-20250505102704-9e8811d319bf 8 + github.com/pelletier/go-toml/v2 v2.2.3 9 + github.com/spf13/cobra v1.9.1 10 + github.com/zalando/go-keyring v0.2.6 11 + ) 12 + 13 + require ( 14 + al.essio.dev/pkg/shellescape v1.5.1 // indirect 15 + github.com/atotto/clipboard v0.1.4 // indirect 16 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 17 + github.com/catppuccin/go v0.3.0 // indirect 18 + github.com/charmbracelet/bubbles v0.21.0 // indirect 19 + github.com/charmbracelet/bubbletea v1.3.4 // indirect 20 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 21 + github.com/charmbracelet/lipgloss v1.1.0 // indirect 22 + github.com/charmbracelet/x/ansi v0.8.0 // indirect 23 + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 24 + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect 25 + github.com/charmbracelet/x/term v0.2.1 // indirect 26 + github.com/danieljoos/wincred v1.2.2 // indirect 27 + github.com/dustin/go-humanize v1.0.1 // indirect 28 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 29 + github.com/godbus/dbus/v5 v5.1.0 // indirect 30 + github.com/inconshreveable/mousetrap v1.1.0 // indirect 31 + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 32 + github.com/mattn/go-isatty v0.0.20 // indirect 33 + github.com/mattn/go-localereader v0.0.1 // indirect 34 + github.com/mattn/go-runewidth v0.0.16 // indirect 35 + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect 36 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 37 + github.com/muesli/cancelreader v0.2.2 // indirect 38 + github.com/muesli/termenv v0.16.0 // indirect 39 + github.com/rivo/uniseg v0.4.7 // indirect 40 + github.com/spf13/pflag v1.0.6 // indirect 41 + github.com/stretchr/testify v1.10.0 // indirect 42 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 43 + golang.org/x/sync v0.12.0 // indirect 44 + golang.org/x/sys v0.31.0 // indirect 45 + golang.org/x/text v0.23.0 // indirect 46 + )
+108
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/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= 4 + github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= 5 + github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 6 + github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 7 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 8 + github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 9 + github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 10 + github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 11 + github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= 12 + github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= 13 + github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 14 + github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 15 + github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= 16 + github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= 17 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 18 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 19 + github.com/charmbracelet/huh v0.7.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc= 20 + github.com/charmbracelet/huh v0.7.0/go.mod h1:UGC3DZHlgOKHvHC07a5vHag41zzhpPFj34U92sOmyuk= 21 + github.com/charmbracelet/huh/spinner v0.0.0-20250505102704-9e8811d319bf h1:Ca8zT3usuy5NcGmwPZLZidr4iLE2FgYRfoZ4OfftYyU= 22 + github.com/charmbracelet/huh/spinner v0.0.0-20250505102704-9e8811d319bf/go.mod h1:D/ml7UtSMq/cwoJiHJ78KFzGrx4m01ALekBSHImKiu4= 23 + github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 24 + github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 25 + github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 26 + github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 27 + github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 28 + github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 29 + github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= 30 + github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= 31 + github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= 32 + github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= 33 + github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 34 + github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 35 + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= 36 + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= 37 + github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 38 + github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 39 + github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= 40 + github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= 41 + github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= 42 + github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= 43 + github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 44 + github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= 45 + github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 46 + github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= 47 + github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= 48 + github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 49 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 50 + github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 51 + github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 52 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 53 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 54 + github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= 55 + github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 56 + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= 57 + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= 58 + github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 59 + github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 60 + github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 61 + github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 62 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 63 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 64 + github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 65 + github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 66 + github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 67 + github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 68 + github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= 69 + github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= 70 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 71 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 72 + github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 73 + github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 74 + github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 75 + github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 76 + github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 77 + github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 78 + github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 79 + github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 80 + github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 81 + github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 82 + github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 83 + github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 84 + github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 85 + github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 86 + github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 87 + github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 88 + github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 89 + github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 90 + github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 91 + github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 92 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 93 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 94 + github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= 95 + github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= 96 + golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= 97 + golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 98 + golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= 99 + golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 100 + golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 101 + golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 102 + golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 103 + golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 104 + golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 105 + golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 106 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 107 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 108 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+13
knit.go
···
··· 1 + package knit 2 + 3 + import "errors" 4 + 5 + // DefaultHost is the default host for tangled.sh services 6 + const DefaultHost = "tangled.sh" 7 + 8 + // DefaultKnot is the default knot host 9 + const DefaultKnot = "knot1.tangled.sh" 10 + 11 + // ErrRequiresAuth is returned when a command requires authentication but the 12 + // user has not authenticated with the host 13 + var ErrRequiresAuth = errors.New("authentication required")
+138
repo/create.go
···
··· 1 + package repo 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "net/http" 8 + "net/url" 9 + "strings" 10 + "sync" 11 + 12 + "github.com/charmbracelet/huh" 13 + "github.com/charmbracelet/huh/spinner" 14 + "github.com/spf13/cobra" 15 + "github.com/zalando/go-keyring" 16 + 17 + "tangled.sh/rockorager.dev/knit" 18 + "tangled.sh/rockorager.dev/knit/auth" 19 + "tangled.sh/rockorager.dev/knit/config" 20 + ) 21 + 22 + func Create(cmd *cobra.Command, args []string) error { 23 + handle := config.DefaultHandleForHost(knit.DefaultHost) 24 + if handle == "" { 25 + return knit.ErrRequiresAuth 26 + } 27 + 28 + client, err := auth.NewClient(knit.DefaultHost, handle) 29 + if errors.Is(err, keyring.ErrNotFound) { 30 + return knit.ErrRequiresAuth 31 + } 32 + 33 + var ( 34 + knot string 35 + repo string 36 + description string 37 + branch string 38 + ) 39 + 40 + repoInput := huh.NewInput(). 41 + Title("Repository Name"). 42 + Value(&repo) 43 + 44 + if err := repoInput.Run(); err != nil { 45 + return fmt.Errorf("getting repo name: %w", err) 46 + } 47 + 48 + descriptionInput := huh.NewInput(). 49 + Title("Description"). 50 + Value(&description) 51 + 52 + if err := descriptionInput.Run(); err != nil { 53 + return fmt.Errorf("getting description: %w", err) 54 + } 55 + 56 + branchInput := huh.NewInput(). 57 + Title("Default Branch"). 58 + Placeholder("main"). 59 + Value(&branch) 60 + 61 + if err := branchInput.Run(); err != nil { 62 + return fmt.Errorf("getting branch: %w", err) 63 + } 64 + 65 + if branch == "" { 66 + branch = "main" 67 + } 68 + 69 + knotSelect := huh.NewSelect[string](). 70 + Title("Select a knot"). 71 + Options( 72 + huh.NewOption(knit.DefaultKnot, knit.DefaultKnot), 73 + ). 74 + Value(&knot) 75 + if err := knotSelect.Run(); err != nil { 76 + return fmt.Errorf("getting knot: %w", err) 77 + } 78 + 79 + ctx, stop := context.WithCancel(cmd.Context()) 80 + defer stop() 81 + 82 + wg := &sync.WaitGroup{} 83 + wg.Add(1) 84 + 85 + go func(wg *sync.WaitGroup) { 86 + spinner.New(). 87 + Context(ctx). 88 + Title("Creating repo..."). 89 + Run() 90 + wg.Done() 91 + }(wg) 92 + 93 + err = create(client, knot, repo, branch, description) 94 + if err != nil { 95 + return err 96 + } 97 + 98 + stop() 99 + wg.Wait() 100 + fmt.Printf("\x1b[32m✔\x1b[m %s created!\r\n", repo) 101 + return nil 102 + } 103 + 104 + func create( 105 + client *http.Client, 106 + domain string, 107 + name string, 108 + branch string, 109 + description string, 110 + ) error { 111 + form := url.Values{} 112 + form.Set("domain", domain) 113 + form.Set("name", name) 114 + form.Set("branch", branch) 115 + form.Set("description", description) 116 + 117 + u := url.URL{ 118 + Scheme: "https", 119 + Host: knit.DefaultHost, 120 + Path: "/repo/new", 121 + } 122 + 123 + resp, err := client.Post( 124 + u.String(), 125 + "application/x-www-form-urlencoded", 126 + strings.NewReader(form.Encode()), 127 + ) 128 + if err != nil { 129 + return err 130 + } 131 + defer resp.Body.Close() 132 + 133 + if resp.StatusCode != http.StatusOK { 134 + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) 135 + } 136 + 137 + return nil 138 + }
+21
repo/repo.go
···
··· 1 + package repo 2 + 3 + import "github.com/spf13/cobra" 4 + 5 + func Command() *cobra.Command { 6 + repo := &cobra.Command{ 7 + Use: "repo", 8 + Short: "Perform actions on a repository", 9 + Run: func(cmd *cobra.Command, args []string) { 10 + cmd.Usage() 11 + }, 12 + } 13 + 14 + repo.AddCommand(&cobra.Command{ 15 + Use: "create", 16 + Short: "Create a repository", 17 + RunE: Create, 18 + }) 19 + 20 + return repo 21 + }