From 34abd29bac838a10f3f29cf27ea7de4d41250678 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 14 Apr 2026 09:17:45 +0200 Subject: [PATCH] feat(cli): add declarative typed setup engine --- README.md | 52 +++++ cli/setup.go | 530 ++++++++++++++++++++++++++++++++++++++++++++++ cli/setup_test.go | 265 +++++++++++++++++++++++ 3 files changed, 847 insertions(+) create mode 100644 cli/setup.go create mode 100644 cli/setup_test.go diff --git a/README.md b/README.md index 51c79cd..a935a50 100644 --- a/README.md +++ b/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 : ```go diff --git a/cli/setup.go b/cli/setup.go new file mode 100644 index 0000000..77eb8bb --- /dev/null +++ b/cli/setup.go @@ -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 + } +} diff --git a/cli/setup_test.go b/cli/setup_test.go new file mode 100644 index 0000000..922079c --- /dev/null +++ b/cli/setup_test.go @@ -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 +}