Cli setup command (#3384)

Co-authored-by: Robert Kaussow <xoxys@rknet.org>
This commit is contained in:
Anbraten
2024-03-13 11:08:22 +01:00
committed by GitHub
parent 1026f95f7e
commit 03c891eb93
17 changed files with 665 additions and 43 deletions

88
cli/setup/setup.go Normal file
View File

@@ -0,0 +1,88 @@
package setup
import (
"errors"
"strings"
"github.com/rs/zerolog/log"
"github.com/urfave/cli/v2"
"go.woodpecker-ci.org/woodpecker/v2/cli/internal/config"
"go.woodpecker-ci.org/woodpecker/v2/cli/setup/ui"
)
// Command exports the setup command.
var Command = &cli.Command{
Name: "setup",
Usage: "setup the woodpecker-cli for the first time",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "server-url",
Usage: "The URL of the woodpecker server",
},
&cli.StringFlag{
Name: "token",
Usage: "The token to authenticate with the woodpecker server",
},
},
Action: setup,
}
func setup(c *cli.Context) error {
_config, err := config.Get(c, c.String("config"))
if err != nil {
return err
} else if _config != nil {
setupAgain, err := ui.Confirm("The woodpecker-cli was already configured. Do you want to configure it again?")
if err != nil {
return err
}
if !setupAgain {
log.Info().Msg("Configuration skipped")
return nil
}
}
serverURL := c.String("server-url")
if serverURL == "" {
serverURL, err = ui.Ask("Enter the URL of the woodpecker server", "https://ci.woodpecker-ci.org", true)
if err != nil {
return err
}
if serverURL == "" {
return errors.New("server URL cannot be empty")
}
}
if !strings.Contains(serverURL, "://") {
serverURL = "https://" + serverURL
}
token := c.String("token")
if token == "" {
token, err = receiveTokenFromUI(c.Context, serverURL)
if err != nil {
return err
}
if token == "" {
return errors.New("no token received from the UI")
}
}
err = config.Save(c, c.String("config"), &config.Config{
ServerURL: serverURL,
Token: token,
LogLevel: "info",
})
if err != nil {
return err
}
log.Info().Msg("The woodpecker-cli has been successfully setup")
return nil
}

117
cli/setup/token_fetcher.go Normal file
View File

@@ -0,0 +1,117 @@
package setup
import (
"context"
"errors"
"fmt"
"math/rand"
"net/http"
"os/exec"
"runtime"
"time"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
)
func receiveTokenFromUI(c context.Context, serverURL string) (string, error) {
port := randomPort()
tokenReceived := make(chan string)
srv := &http.Server{Addr: fmt.Sprintf("127.0.0.1:%d", port)}
srv.Handler = setupRouter(tokenReceived)
go func() {
log.Debug().Msgf("Listening for token response on :%d", port)
_ = srv.ListenAndServe()
}()
defer func() {
log.Debug().Msg("Shutting down server")
_ = srv.Shutdown(c)
}()
err := openBrowser(fmt.Sprintf("%s/cli/auth?port=%d", serverURL, port))
if err != nil {
return "", err
}
// wait for token to be received or timeout
select {
case token := <-tokenReceived:
return token, nil
case <-c.Done():
return "", c.Err()
case <-time.After(5 * time.Minute):
return "", errors.New("timed out waiting for token")
}
}
func setupRouter(tokenReceived chan string) *gin.Engine {
gin.SetMode(gin.ReleaseMode)
e := gin.New()
e.UseRawPath = true
e.Use(gin.Recovery())
e.Use(func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
e.POST("/token", func(c *gin.Context) {
data := struct {
Token string `json:"token"`
}{}
err := c.BindJSON(&data)
if err != nil {
log.Debug().Err(err).Msg("Failed to bind JSON")
c.JSON(400, gin.H{
"error": "invalid request",
})
return
}
tokenReceived <- data.Token
c.JSON(200, gin.H{
"ok": "true",
})
})
return e
}
func openBrowser(url string) error {
var err error
log.Debug().Msgf("Opening browser with URL: %s", url)
switch runtime.GOOS {
case "linux":
err = exec.Command("xdg-open", url).Start()
case "windows":
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
case "darwin":
err = exec.Command("open", url).Start()
default:
err = fmt.Errorf("unsupported platform")
}
return err
}
func randomPort() int {
s1 := rand.NewSource(time.Now().UnixNano())
r1 := rand.New(s1)
return r1.Intn(10000) + 20000
}

79
cli/setup/ui/ask.go Normal file
View File

@@ -0,0 +1,79 @@
package ui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
type askModel struct {
prompt string
required bool
textInput textinput.Model
err error
}
func (m askModel) Init() tea.Cmd {
return textinput.Blink
}
func (m askModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEnter:
if !m.required || (m.required && strings.TrimSpace(m.textInput.Value()) != "") {
return m, tea.Quit
}
case tea.KeyCtrlC, tea.KeyEsc:
return m, tea.Quit
}
default:
return m, cmd
}
m.textInput, cmd = m.textInput.Update(msg)
return m, cmd
}
func (m askModel) View() string {
return fmt.Sprintf(
"%s\n\n%s\n\n%s",
m.prompt,
m.textInput.View(),
"(esc to quit)",
) + "\n"
}
func Ask(prompt, placeholder string, required bool) (string, error) {
ti := textinput.New()
ti.Placeholder = placeholder
ti.Focus()
ti.CharLimit = 156
ti.Width = 40
p := tea.NewProgram(askModel{
prompt: prompt,
textInput: ti,
required: required,
err: nil,
})
_m, err := p.Run()
if err != nil {
return "", err
}
m, ok := _m.(askModel)
if !ok {
return "", fmt.Errorf("unexpected model: %T", _m)
}
text := strings.TrimSpace(m.textInput.Value())
return text, nil
}

71
cli/setup/ui/confirm.go Normal file
View File

@@ -0,0 +1,71 @@
package ui
import (
"fmt"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
type confirmModel struct {
confirmed bool
prompt string
err error
}
func (m confirmModel) Init() tea.Cmd {
return textinput.Blink
}
func (m confirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.Runes != nil {
switch msg.Runes[0] {
case 'y':
m.confirmed = true
return m, tea.Quit
case 'n':
m.confirmed = false
return m, tea.Quit
}
}
switch msg.Type {
case tea.KeyCtrlC, tea.KeyEsc:
return m, tea.Quit
}
default:
return m, nil
}
return m, cmd
}
func (m confirmModel) View() string {
return fmt.Sprintf(
"%s y / n (esc to quit)",
m.prompt,
) + "\n"
}
func Confirm(prompt string) (bool, error) {
p := tea.NewProgram(confirmModel{
prompt: prompt,
err: nil,
})
_m, err := p.Run()
if err != nil {
return false, err
}
m, ok := _m.(confirmModel)
if !ok {
return false, fmt.Errorf("unexpected model: %T", _m)
}
return m.confirmed, nil
}