+18
LICENSE
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+
}