feat(cli): add declarative typed setup engine
This commit is contained in:
parent
11fc89dc71
commit
34abd29bac
3 changed files with 847 additions and 0 deletions
52
README.md
52
README.md
|
|
@ -275,6 +275,58 @@ if err != nil {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Pour décrire un setup complet sans réécrire la boucle interactive :
|
||||||
|
|
||||||
|
```go
|
||||||
|
result, err := cli.RunSetup(cli.SetupOptions{
|
||||||
|
Stdin: os.Stdin,
|
||||||
|
Stdout: os.Stdout,
|
||||||
|
Fields: []cli.SetupField{
|
||||||
|
{
|
||||||
|
Name: "base_url",
|
||||||
|
Label: "Base URL",
|
||||||
|
Type: cli.SetupFieldURL,
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "api_token",
|
||||||
|
Label: "API token",
|
||||||
|
Type: cli.SetupFieldSecret,
|
||||||
|
Required: true,
|
||||||
|
ExistingSecret: storedToken, // conserve la valeur existante si l'utilisateur laisse vide
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "enabled",
|
||||||
|
Label: "Enable integration",
|
||||||
|
Type: cli.SetupFieldBool,
|
||||||
|
Default: "true",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "scopes",
|
||||||
|
Label: "Scopes",
|
||||||
|
Type: cli.SetupFieldList,
|
||||||
|
Default: "read,write",
|
||||||
|
Normalize: func(value string) string { return strings.TrimSpace(strings.ToLower(value)) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL, _ := result.Get("base_url")
|
||||||
|
apiToken, _ := result.Get("api_token")
|
||||||
|
enabled, _ := result.Get("enabled")
|
||||||
|
scopes, _ := result.Get("scopes")
|
||||||
|
|
||||||
|
if apiToken.KeptStoredSecret {
|
||||||
|
fmt.Println("Stored token kept.")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Chaque champ peut déclarer ses propres hooks de validation (`Validate`, `ValidateBool`, `ValidateList`).
|
||||||
|
Les validations sont appliquées de manière cohérente en TTY et en stdin non interactif.
|
||||||
|
|
||||||
Pour standardiser la résolution `flag > env > config > secret` avec provenance :
|
Pour standardiser la résolution `flag > env > config > secret` avec provenance :
|
||||||
|
|
||||||
```go
|
```go
|
||||||
|
|
|
||||||
530
cli/setup.go
Normal file
530
cli/setup.go
Normal file
|
|
@ -0,0 +1,530 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SetupFieldType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SetupFieldString SetupFieldType = "string"
|
||||||
|
SetupFieldURL SetupFieldType = "url"
|
||||||
|
SetupFieldSecret SetupFieldType = "secret"
|
||||||
|
SetupFieldBool SetupFieldType = "bool"
|
||||||
|
SetupFieldList SetupFieldType = "list"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidSetupDefinition = errors.New("invalid setup definition")
|
||||||
|
|
||||||
|
type SetupField struct {
|
||||||
|
Name string
|
||||||
|
Label string
|
||||||
|
Type SetupFieldType
|
||||||
|
Required bool
|
||||||
|
Default string
|
||||||
|
ExistingSecret string
|
||||||
|
ListSeparator string
|
||||||
|
Normalize func(string) string
|
||||||
|
Validate func(string) error
|
||||||
|
ValidateBool func(bool) error
|
||||||
|
ValidateList func([]string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetupOptions struct {
|
||||||
|
Fields []SetupField
|
||||||
|
Stdin *os.File
|
||||||
|
Stdout io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetupValue struct {
|
||||||
|
Type SetupFieldType
|
||||||
|
String string
|
||||||
|
Bool bool
|
||||||
|
List []string
|
||||||
|
Set bool
|
||||||
|
KeptStoredSecret bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetupResultField struct {
|
||||||
|
Name string
|
||||||
|
Value SetupValue
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetupResult struct {
|
||||||
|
Fields []SetupResultField
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r SetupResult) Get(name string) (SetupValue, bool) {
|
||||||
|
needle := strings.TrimSpace(name)
|
||||||
|
for _, field := range r.Fields {
|
||||||
|
if field.Name == needle {
|
||||||
|
return field.Value, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SetupValue{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetupValidationError struct {
|
||||||
|
Field string
|
||||||
|
Label string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *SetupValidationError) Error() string {
|
||||||
|
label := strings.TrimSpace(e.Label)
|
||||||
|
if label == "" {
|
||||||
|
label = strings.TrimSpace(e.Field)
|
||||||
|
}
|
||||||
|
|
||||||
|
if label == "" {
|
||||||
|
return strings.TrimSpace(e.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s: %s", label, strings.TrimSpace(e.Message))
|
||||||
|
}
|
||||||
|
|
||||||
|
type normalizedSetupField struct {
|
||||||
|
Name string
|
||||||
|
Label string
|
||||||
|
Type SetupFieldType
|
||||||
|
Required bool
|
||||||
|
DefaultString string
|
||||||
|
DefaultBool *bool
|
||||||
|
DefaultList []string
|
||||||
|
ExistingSecret string
|
||||||
|
ListSeparator string
|
||||||
|
Normalize func(string) string
|
||||||
|
Validate func(string) error
|
||||||
|
ValidateBool func(bool) error
|
||||||
|
ValidateList func([]string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunSetup(options SetupOptions) (SetupResult, error) {
|
||||||
|
stdin, stdout := normalizeSetupIO(options)
|
||||||
|
|
||||||
|
fields, err := normalizeSetupFields(options.Fields)
|
||||||
|
if err != nil {
|
||||||
|
return SetupResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := bufio.NewReader(stdin)
|
||||||
|
fd := int(stdin.Fd())
|
||||||
|
isTTY := term.IsTerminal(fd)
|
||||||
|
|
||||||
|
result := SetupResult{
|
||||||
|
Fields: make([]SetupResultField, 0, len(fields)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, field := range fields {
|
||||||
|
value, err := promptSetupField(reader, stdin, stdout, fd, isTTY, field)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Fields = append(result.Fields, SetupResultField{
|
||||||
|
Name: field.Name,
|
||||||
|
Value: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSetupIO(options SetupOptions) (*os.File, io.Writer) {
|
||||||
|
stdin := options.Stdin
|
||||||
|
if stdin == nil {
|
||||||
|
stdin = os.Stdin
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout := options.Stdout
|
||||||
|
if stdout == nil {
|
||||||
|
stdout = os.Stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
return stdin, stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSetupFields(fields []SetupField) ([]normalizedSetupField, error) {
|
||||||
|
normalized := make([]normalizedSetupField, 0, len(fields))
|
||||||
|
seenNames := make(map[string]struct{}, len(fields))
|
||||||
|
|
||||||
|
for i, field := range fields {
|
||||||
|
name := strings.TrimSpace(field.Name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("%w: field at index %d has empty name", ErrInvalidSetupDefinition, i)
|
||||||
|
}
|
||||||
|
if _, exists := seenNames[name]; exists {
|
||||||
|
return nil, fmt.Errorf("%w: duplicate field name %q", ErrInvalidSetupDefinition, name)
|
||||||
|
}
|
||||||
|
seenNames[name] = struct{}{}
|
||||||
|
|
||||||
|
if !isKnownSetupFieldType(field.Type) {
|
||||||
|
return nil, fmt.Errorf("%w: field %q uses unknown type %q", ErrInvalidSetupDefinition, name, field.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
label := strings.TrimSpace(field.Label)
|
||||||
|
if label == "" {
|
||||||
|
label = name
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizer := field.Normalize
|
||||||
|
if normalizer == nil {
|
||||||
|
normalizer = strings.TrimSpace
|
||||||
|
}
|
||||||
|
|
||||||
|
listSeparator := field.ListSeparator
|
||||||
|
if listSeparator == "" {
|
||||||
|
listSeparator = ","
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := normalizedSetupField{
|
||||||
|
Name: name,
|
||||||
|
Label: label,
|
||||||
|
Type: field.Type,
|
||||||
|
Required: field.Required,
|
||||||
|
ExistingSecret: strings.TrimSpace(field.ExistingSecret),
|
||||||
|
ListSeparator: listSeparator,
|
||||||
|
Normalize: normalizer,
|
||||||
|
Validate: field.Validate,
|
||||||
|
ValidateBool: field.ValidateBool,
|
||||||
|
ValidateList: field.ValidateList,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch field.Type {
|
||||||
|
case SetupFieldString, SetupFieldURL, SetupFieldSecret:
|
||||||
|
entry.DefaultString = normalizer(field.Default)
|
||||||
|
if field.Type == SetupFieldURL && entry.DefaultString != "" {
|
||||||
|
if err := ValidateBaseURL(entry.DefaultString); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: field %q default URL is invalid: %v", ErrInvalidSetupDefinition, name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case SetupFieldBool:
|
||||||
|
defaultRaw := strings.TrimSpace(field.Default)
|
||||||
|
if defaultRaw != "" {
|
||||||
|
defaultValue, err := parseBoolValue(defaultRaw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: field %q default bool is invalid: %v", ErrInvalidSetupDefinition, name, err)
|
||||||
|
}
|
||||||
|
entry.DefaultBool = &defaultValue
|
||||||
|
}
|
||||||
|
case SetupFieldList:
|
||||||
|
defaultRaw := strings.TrimSpace(field.Default)
|
||||||
|
if defaultRaw != "" {
|
||||||
|
entry.DefaultList = splitSetupList(defaultRaw, listSeparator, normalizer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized = append(normalized, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptSetupField(
|
||||||
|
reader *bufio.Reader,
|
||||||
|
stdin *os.File,
|
||||||
|
stdout io.Writer,
|
||||||
|
fd int,
|
||||||
|
isTTY bool,
|
||||||
|
field normalizedSetupField,
|
||||||
|
) (SetupValue, error) {
|
||||||
|
for {
|
||||||
|
if err := renderSetupPrompt(stdout, field); err != nil {
|
||||||
|
return SetupValue{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := readSetupInput(reader, stdout, fd, isTTY, field.Type)
|
||||||
|
if err != nil {
|
||||||
|
return SetupValue{}, fmt.Errorf("read %q: %w", field.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
value, validationErr := parseSetupValue(field, raw)
|
||||||
|
if validationErr == nil {
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
setupErr := &SetupValidationError{
|
||||||
|
Field: field.Name,
|
||||||
|
Label: field.Label,
|
||||||
|
Message: validationErr.Error(),
|
||||||
|
}
|
||||||
|
if !isTTY {
|
||||||
|
return SetupValue{}, setupErr
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := fmt.Fprintf(stdout, "Invalid value for %s: %s\n", field.Label, validationErr.Error()); err != nil {
|
||||||
|
return SetupValue{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSetupInput(
|
||||||
|
reader *bufio.Reader,
|
||||||
|
stdout io.Writer,
|
||||||
|
fd int,
|
||||||
|
isTTY bool,
|
||||||
|
fieldType SetupFieldType,
|
||||||
|
) (string, error) {
|
||||||
|
if fieldType == SetupFieldSecret && isTTY {
|
||||||
|
secret, err := term.ReadPassword(fd)
|
||||||
|
fmt.Fprintln(stdout)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(secret), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return line, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSetupValue(field normalizedSetupField, raw string) (SetupValue, error) {
|
||||||
|
switch field.Type {
|
||||||
|
case SetupFieldString:
|
||||||
|
return parseSetupStringValue(field, raw)
|
||||||
|
case SetupFieldURL:
|
||||||
|
value, err := parseSetupStringValue(field, raw)
|
||||||
|
if err != nil {
|
||||||
|
return SetupValue{}, err
|
||||||
|
}
|
||||||
|
if value.Set {
|
||||||
|
if err := ValidateBaseURL(value.String); err != nil {
|
||||||
|
return SetupValue{}, fmt.Errorf("must be a valid URL with scheme and host")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
case SetupFieldSecret:
|
||||||
|
return parseSetupSecretValue(field, raw)
|
||||||
|
case SetupFieldBool:
|
||||||
|
return parseSetupBoolValue(field, raw)
|
||||||
|
case SetupFieldList:
|
||||||
|
return parseSetupListValue(field, raw)
|
||||||
|
default:
|
||||||
|
return SetupValue{}, fmt.Errorf("unsupported field type %q", field.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSetupStringValue(field normalizedSetupField, raw string) (SetupValue, error) {
|
||||||
|
value := field.Normalize(raw)
|
||||||
|
set := value != ""
|
||||||
|
if !set && field.DefaultString != "" {
|
||||||
|
value = field.DefaultString
|
||||||
|
set = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if field.Required && !set {
|
||||||
|
return SetupValue{}, fmt.Errorf("value is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if set && field.Validate != nil {
|
||||||
|
if err := field.Validate(value); err != nil {
|
||||||
|
return SetupValue{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SetupValue{
|
||||||
|
Type: field.Type,
|
||||||
|
String: value,
|
||||||
|
Set: set,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSetupSecretValue(field normalizedSetupField, raw string) (SetupValue, error) {
|
||||||
|
value := field.Normalize(raw)
|
||||||
|
set := value != ""
|
||||||
|
keptStored := false
|
||||||
|
|
||||||
|
if !set && field.ExistingSecret != "" {
|
||||||
|
value = field.ExistingSecret
|
||||||
|
set = true
|
||||||
|
keptStored = true
|
||||||
|
} else if !set && field.DefaultString != "" {
|
||||||
|
value = field.DefaultString
|
||||||
|
set = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if field.Required && !set {
|
||||||
|
return SetupValue{}, fmt.Errorf("value is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if set && field.Validate != nil {
|
||||||
|
if err := field.Validate(value); err != nil {
|
||||||
|
return SetupValue{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SetupValue{
|
||||||
|
Type: field.Type,
|
||||||
|
String: value,
|
||||||
|
Set: set,
|
||||||
|
KeptStoredSecret: keptStored,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSetupBoolValue(field normalizedSetupField, raw string) (SetupValue, error) {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
if field.DefaultBool != nil {
|
||||||
|
value := *field.DefaultBool
|
||||||
|
if field.ValidateBool != nil {
|
||||||
|
if err := field.ValidateBool(value); err != nil {
|
||||||
|
return SetupValue{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SetupValue{
|
||||||
|
Type: SetupFieldBool,
|
||||||
|
Bool: value,
|
||||||
|
Set: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if field.Required {
|
||||||
|
return SetupValue{}, fmt.Errorf("value is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return SetupValue{
|
||||||
|
Type: SetupFieldBool,
|
||||||
|
Set: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := parseBoolValue(trimmed)
|
||||||
|
if err != nil {
|
||||||
|
return SetupValue{}, err
|
||||||
|
}
|
||||||
|
if field.ValidateBool != nil {
|
||||||
|
if err := field.ValidateBool(value); err != nil {
|
||||||
|
return SetupValue{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SetupValue{
|
||||||
|
Type: SetupFieldBool,
|
||||||
|
Bool: value,
|
||||||
|
Set: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSetupListValue(field normalizedSetupField, raw string) (SetupValue, error) {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
|
||||||
|
var list []string
|
||||||
|
set := false
|
||||||
|
if trimmed != "" {
|
||||||
|
list = splitSetupList(trimmed, field.ListSeparator, field.Normalize)
|
||||||
|
set = true
|
||||||
|
} else if len(field.DefaultList) > 0 {
|
||||||
|
list = slices.Clone(field.DefaultList)
|
||||||
|
set = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if field.Required && len(list) == 0 {
|
||||||
|
return SetupValue{}, fmt.Errorf("value is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if field.ValidateList != nil {
|
||||||
|
if err := field.ValidateList(list); err != nil {
|
||||||
|
return SetupValue{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SetupValue{
|
||||||
|
Type: SetupFieldList,
|
||||||
|
List: list,
|
||||||
|
Set: set,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderSetupPrompt(w io.Writer, field normalizedSetupField) error {
|
||||||
|
switch field.Type {
|
||||||
|
case SetupFieldSecret:
|
||||||
|
if field.ExistingSecret != "" {
|
||||||
|
_, err := fmt.Fprintf(w, "%s [stored, leave blank to keep]: ", field.Label)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if field.DefaultString != "" {
|
||||||
|
_, err := fmt.Fprintf(w, "%s [%s]: ", field.Label, field.DefaultString)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := fmt.Fprintf(w, "%s: ", field.Label)
|
||||||
|
return err
|
||||||
|
case SetupFieldBool:
|
||||||
|
defaultLabel := "y/n"
|
||||||
|
if field.DefaultBool != nil {
|
||||||
|
if *field.DefaultBool {
|
||||||
|
defaultLabel = "Y/n"
|
||||||
|
} else {
|
||||||
|
defaultLabel = "y/N"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := fmt.Fprintf(w, "%s [%s]: ", field.Label, defaultLabel)
|
||||||
|
return err
|
||||||
|
case SetupFieldList:
|
||||||
|
if len(field.DefaultList) > 0 {
|
||||||
|
_, err := fmt.Fprintf(
|
||||||
|
w,
|
||||||
|
"%s [%s]: ",
|
||||||
|
field.Label,
|
||||||
|
strings.Join(field.DefaultList, field.ListSeparator),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := fmt.Fprintf(w, "%s: ", field.Label)
|
||||||
|
return err
|
||||||
|
case SetupFieldString, SetupFieldURL:
|
||||||
|
if field.DefaultString != "" {
|
||||||
|
_, err := fmt.Fprintf(w, "%s [%s]: ", field.Label, field.DefaultString)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := fmt.Fprintf(w, "%s: ", field.Label)
|
||||||
|
return err
|
||||||
|
default:
|
||||||
|
_, err := fmt.Fprintf(w, "%s: ", field.Label)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitSetupList(raw, separator string, normalize func(string) string) []string {
|
||||||
|
parts := strings.Split(raw, separator)
|
||||||
|
list := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
normalized := normalize(part)
|
||||||
|
if normalized == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
list = append(list, normalized)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBoolValue(raw string) (bool, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||||
|
case "1", "t", "true", "y", "yes", "on":
|
||||||
|
return true, nil
|
||||||
|
case "0", "f", "false", "n", "no", "off":
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("must be one of: yes/no, y/n, true/false, 1/0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isKnownSetupFieldType(fieldType SetupFieldType) bool {
|
||||||
|
switch fieldType {
|
||||||
|
case SetupFieldString, SetupFieldURL, SetupFieldSecret, SetupFieldBool, SetupFieldList:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
265
cli/setup_test.go
Normal file
265
cli/setup_test.go
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunSetupParsesTypedFieldsWithDefaultsAndNormalization(t *testing.T) {
|
||||||
|
stdin := setupTestInputFile(t, strings.Join([]string{
|
||||||
|
" https://api.example.com ",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"Read, WRITE, admin",
|
||||||
|
" eu-west ",
|
||||||
|
}, "\n")+"\n")
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
result, err := RunSetup(SetupOptions{
|
||||||
|
Stdin: stdin,
|
||||||
|
Stdout: &stdout,
|
||||||
|
Fields: []SetupField{
|
||||||
|
{
|
||||||
|
Name: "base_url",
|
||||||
|
Label: "Base URL",
|
||||||
|
Type: SetupFieldURL,
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "api_token",
|
||||||
|
Label: "API token",
|
||||||
|
Type: SetupFieldSecret,
|
||||||
|
Required: true,
|
||||||
|
ExistingSecret: "stored-token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "enabled",
|
||||||
|
Label: "Enabled",
|
||||||
|
Type: SetupFieldBool,
|
||||||
|
Default: "true",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "scopes",
|
||||||
|
Label: "Scopes",
|
||||||
|
Type: SetupFieldList,
|
||||||
|
Default: "read,write",
|
||||||
|
Normalize: func(raw string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(raw))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "region",
|
||||||
|
Label: "Region",
|
||||||
|
Type: SetupFieldString,
|
||||||
|
Default: "eu-central",
|
||||||
|
Normalize: strings.TrimSpace,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RunSetup returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL, ok := result.Get("base_url")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("missing base_url field")
|
||||||
|
}
|
||||||
|
if !baseURL.Set || baseURL.String != "https://api.example.com" {
|
||||||
|
t.Fatalf("base_url = %#v, want normalized URL", baseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, ok := result.Get("api_token")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("missing api_token field")
|
||||||
|
}
|
||||||
|
if token.String != "stored-token" {
|
||||||
|
t.Fatalf("token.String = %q, want stored-token", token.String)
|
||||||
|
}
|
||||||
|
if !token.KeptStoredSecret {
|
||||||
|
t.Fatalf("token should keep stored secret when blank")
|
||||||
|
}
|
||||||
|
|
||||||
|
enabled, ok := result.Get("enabled")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("missing enabled field")
|
||||||
|
}
|
||||||
|
if !enabled.Bool || !enabled.Set {
|
||||||
|
t.Fatalf("enabled = %#v, want true from default", enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
scopes, ok := result.Get("scopes")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("missing scopes field")
|
||||||
|
}
|
||||||
|
wantScopes := []string{"read", "write", "admin"}
|
||||||
|
if !slices.Equal(scopes.List, wantScopes) {
|
||||||
|
t.Fatalf("scopes.List = %v, want %v", scopes.List, wantScopes)
|
||||||
|
}
|
||||||
|
|
||||||
|
region, ok := result.Get("region")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("missing region field")
|
||||||
|
}
|
||||||
|
if region.String != "eu-west" {
|
||||||
|
t.Fatalf("region.String = %q, want eu-west", region.String)
|
||||||
|
}
|
||||||
|
|
||||||
|
promptOutput := stdout.String()
|
||||||
|
for _, needle := range []string{
|
||||||
|
"Base URL:",
|
||||||
|
"API token [stored, leave blank to keep]:",
|
||||||
|
"Enabled [Y/n]:",
|
||||||
|
"Scopes [read,write]:",
|
||||||
|
"Region [eu-central]:",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(promptOutput, needle) {
|
||||||
|
t.Fatalf("prompt output = %q, want substring %q", promptOutput, needle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunSetupReturnsReadableValidationErrorInNonInteractiveMode(t *testing.T) {
|
||||||
|
stdin := setupTestInputFile(t, "maybe\n")
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
|
||||||
|
_, err := RunSetup(SetupOptions{
|
||||||
|
Stdin: stdin,
|
||||||
|
Stdout: &stdout,
|
||||||
|
Fields: []SetupField{
|
||||||
|
{
|
||||||
|
Name: "enabled",
|
||||||
|
Label: "Enabled",
|
||||||
|
Type: SetupFieldBool,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
var validationErr *SetupValidationError
|
||||||
|
if !errors.As(err, &validationErr) {
|
||||||
|
t.Fatalf("RunSetup error = %v, want SetupValidationError", err)
|
||||||
|
}
|
||||||
|
if validationErr.Field != "enabled" {
|
||||||
|
t.Fatalf("validationErr.Field = %q, want enabled", validationErr.Field)
|
||||||
|
}
|
||||||
|
if !strings.Contains(validationErr.Error(), "must be one of") {
|
||||||
|
t.Fatalf("validationErr = %v, want readable bool error", validationErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunSetupSupportsValidationHooks(t *testing.T) {
|
||||||
|
stdin := setupTestInputFile(t, "https://example.com\nalpha,beta\n")
|
||||||
|
|
||||||
|
_, err := RunSetup(SetupOptions{
|
||||||
|
Stdin: stdin,
|
||||||
|
Fields: []SetupField{
|
||||||
|
{
|
||||||
|
Name: "base_url",
|
||||||
|
Label: "Base URL",
|
||||||
|
Type: SetupFieldURL,
|
||||||
|
Validate: func(value string) error {
|
||||||
|
if !strings.HasSuffix(value, "/v1") {
|
||||||
|
return errors.New("must end with /v1")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "scopes",
|
||||||
|
Type: SetupFieldList,
|
||||||
|
ValidateList: func(values []string) error {
|
||||||
|
if len(values) < 3 {
|
||||||
|
return errors.New("at least 3 scopes are required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
var validationErr *SetupValidationError
|
||||||
|
if !errors.As(err, &validationErr) {
|
||||||
|
t.Fatalf("RunSetup error = %v, want SetupValidationError", err)
|
||||||
|
}
|
||||||
|
if validationErr.Field != "base_url" {
|
||||||
|
t.Fatalf("validationErr.Field = %q, want base_url", validationErr.Field)
|
||||||
|
}
|
||||||
|
if !strings.Contains(validationErr.Error(), "must end with /v1") {
|
||||||
|
t.Fatalf("validationErr = %v", validationErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunSetupRejectsInvalidDefinitions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fields []SetupField
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty field name",
|
||||||
|
fields: []SetupField{
|
||||||
|
{Name: " ", Type: SetupFieldString},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate field names",
|
||||||
|
fields: []SetupField{
|
||||||
|
{Name: "base_url", Type: SetupFieldString},
|
||||||
|
{Name: "base_url", Type: SetupFieldURL},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown type",
|
||||||
|
fields: []SetupField{
|
||||||
|
{Name: "base_url", Type: SetupFieldType("json")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid bool default",
|
||||||
|
fields: []SetupField{
|
||||||
|
{Name: "enabled", Type: SetupFieldBool, Default: "sometimes"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid url default",
|
||||||
|
fields: []SetupField{
|
||||||
|
{Name: "base_url", Type: SetupFieldURL, Default: "localhost"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, err := RunSetup(SetupOptions{
|
||||||
|
Stdin: setupTestInputFile(t, ""),
|
||||||
|
Fields: tc.fields,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrInvalidSetupDefinition) {
|
||||||
|
t.Fatalf("RunSetup error = %v, want ErrInvalidSetupDefinition", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupTestInputFile(t *testing.T, content string) *os.File {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
file, err := os.CreateTemp(t.TempDir(), "setup-input-*.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTemp returned error: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = file.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := file.WriteString(content); err != nil {
|
||||||
|
t.Fatalf("WriteString returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := file.Seek(0, 0); err != nil {
|
||||||
|
t.Fatalf("Seek returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return file
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue