feat(client/v2)!: dynamic prompt (backport #22775) (#22827)

Co-authored-by: Julián Toledano <JulianToledano@users.noreply.github.com>
Co-authored-by: Julien Robert <julien@rbrt.fr>
This commit is contained in:
mergify[bot] 2024-12-11 12:30:41 +01:00 committed by GitHub
parent 721e83882c
commit d7e7af42e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 778 additions and 415 deletions

View File

@ -57,6 +57,10 @@ Every module contains its own CHANGELOG.md. Please refer to the module you are i
### API Breaking Changes
* (client) [#22775](https://github.com/cosmos/cosmos-sdk/pull/22775) Removed client prompt validations.
### Deprecated
## [v0.52.0](https://github.com/cosmos/cosmos-sdk/releases/tag/v0.52.0) - 2024-XX-XX
Every module contains its own CHANGELOG.md. Please refer to the module you are interested in.

View File

@ -1,39 +0,0 @@
package client_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/cosmos/cosmos-sdk/client"
)
func TestValidatePromptNotEmpty(t *testing.T) {
require := require.New(t)
require.NoError(client.ValidatePromptNotEmpty("foo"))
require.ErrorContains(client.ValidatePromptNotEmpty(""), "input cannot be empty")
}
func TestValidatePromptURL(t *testing.T) {
require := require.New(t)
require.NoError(client.ValidatePromptURL("https://example.com"))
require.ErrorContains(client.ValidatePromptURL("foo"), "invalid URL")
}
func TestValidatePromptAddress(t *testing.T) {
require := require.New(t)
require.NoError(client.ValidatePromptAddress("cosmos1huydeevpz37sd9snkgul6070mstupukw00xkw9"))
require.NoError(client.ValidatePromptAddress("cosmosvaloper1sjllsnramtg3ewxqwwrwjxfgc4n4ef9u2lcnj0"))
require.NoError(client.ValidatePromptAddress("cosmosvalcons1ntk8eualewuprz0gamh8hnvcem2nrcdsgz563h"))
require.ErrorContains(client.ValidatePromptAddress("foo"), "invalid address")
}
func TestValidatePromptCoins(t *testing.T) {
require := require.New(t)
require.NoError(client.ValidatePromptCoins("100stake"))
require.ErrorContains(client.ValidatePromptCoins("foo"), "invalid coins")
}

View File

@ -45,6 +45,7 @@ Ref: https://keepachangelog.com/en/1.0.0/
* [#20623](https://github.com/cosmos/cosmos-sdk/pull/20623) Introduce client/v2 tx factory.
* [#20623](https://github.com/cosmos/cosmos-sdk/pull/20623) Extend client/v2 keyring interface with `KeyType` and `KeyInfo`.
* [#22282](https://github.com/cosmos/cosmos-sdk/pull/22282) Added custom broadcast logic.
* [#22775](https://github.com/cosmos/cosmos-sdk/pull/22775) Added interactive autocli prompt functionality, including message field prompting, validation helpers, and default value support.
### Improvements

View File

@ -0,0 +1,259 @@
package prompt
import (
"fmt"
"io"
"strconv"
"strings"
"github.com/manifoldco/promptui"
"google.golang.org/protobuf/reflect/protoreflect"
"cosmossdk.io/client/v2/autocli/flag"
addresscodec "cosmossdk.io/core/address"
)
// PromptMessage prompts the user for values to populate a protobuf message interactively.
// It returns the populated message and any error encountered during prompting.
func PromptMessage(
addressCodec, validatorAddressCodec, consensusAddressCodec addresscodec.Codec,
promptPrefix string, msg protoreflect.Message,
) (protoreflect.Message, error) {
return promptMessage(addressCodec, validatorAddressCodec, consensusAddressCodec, promptPrefix, nil, msg)
}
// promptMessage prompts the user for values to populate a protobuf message interactively.
// stdIn is provided to make the function easier to unit test by allowing injection of predefined inputs.
func promptMessage(
addressCodec, validatorAddressCodec, consensusAddressCodec addresscodec.Codec,
promptPrefix string, stdIn io.ReadCloser, msg protoreflect.Message,
) (protoreflect.Message, error) {
fields := msg.Descriptor().Fields()
for i := 0; i < fields.Len(); i++ {
field := fields.Get(i)
fieldName := string(field.Name())
promptUi := promptui.Prompt{
Validate: ValidatePromptNotEmpty,
Stdin: stdIn,
}
// If this signer field has already a valid default value set,
// use that value as the default prompt value. This is useful for
// commands that have an authority such as gov.
if strings.EqualFold(fieldName, flag.GetSignerFieldName(msg.Descriptor())) {
if defaultValue := msg.Get(field); defaultValue.IsValid() {
promptUi.Default = defaultValue.String()
}
}
// validate address fields
scalarField, ok := flag.GetScalarType(field)
if ok {
switch scalarField {
case flag.AddressStringScalarType:
promptUi.Validate = ValidateAddress(addressCodec)
case flag.ValidatorAddressStringScalarType:
promptUi.Validate = ValidateAddress(validatorAddressCodec)
case flag.ConsensusAddressStringScalarType:
promptUi.Validate = ValidateAddress(consensusAddressCodec)
default:
// prompt.Validate = ValidatePromptNotEmpty (we possibly don't want to force all fields to be non-empty)
promptUi.Validate = nil
}
}
// handle nested message fields recursively
if field.Kind() == protoreflect.MessageKind {
err := promptInnerMessageKind(field, addressCodec, validatorAddressCodec, consensusAddressCodec, promptPrefix, stdIn, msg)
if err != nil {
return nil, err
}
continue
}
// handle repeated fields by prompting for a comma-separated list of values
if field.IsList() {
list, err := promptList(field, msg, promptUi, promptPrefix)
if err != nil {
return nil, err
}
msg.Set(field, protoreflect.ValueOfList(list))
continue
}
promptUi.Label = fmt.Sprintf("Enter %s %s", promptPrefix, fieldName)
result, err := promptUi.Run()
if err != nil {
return msg, fmt.Errorf("failed to prompt for %s: %w", fieldName, err)
}
v, err := valueOf(field, result)
if err != nil {
return msg, err
}
msg.Set(field, v)
}
return msg, nil
}
// valueOf converts a string input value to a protoreflect.Value based on the field's type.
// It handles string, numeric, bool, bytes and enum field types.
// Returns the converted value and any error that occurred during conversion.
func valueOf(field protoreflect.FieldDescriptor, result string) (protoreflect.Value, error) {
switch field.Kind() {
case protoreflect.StringKind:
return protoreflect.ValueOfString(result), nil
case protoreflect.Uint32Kind, protoreflect.Fixed32Kind, protoreflect.Uint64Kind, protoreflect.Fixed64Kind:
resultUint, err := strconv.ParseUint(result, 10, 0)
if err != nil {
return protoreflect.Value{}, fmt.Errorf("invalid value for int: %w", err)
}
return protoreflect.ValueOfUint64(resultUint), nil
case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind, protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind:
resultInt, err := strconv.ParseInt(result, 10, 0)
if err != nil {
return protoreflect.Value{}, fmt.Errorf("invalid value for int: %w", err)
}
// If a value was successfully parsed the ranges of:
// [minInt, maxInt]
// are within the ranges of:
// [minInt64, maxInt64]
// of which on 64-bit machines, which are most common,
// int==int64
return protoreflect.ValueOfInt64(resultInt), nil
case protoreflect.BoolKind:
resultBool, err := strconv.ParseBool(result)
if err != nil {
return protoreflect.Value{}, fmt.Errorf("invalid value for bool: %w", err)
}
return protoreflect.ValueOfBool(resultBool), nil
case protoreflect.BytesKind:
resultBytes := []byte(result)
return protoreflect.ValueOfBytes(resultBytes), nil
case protoreflect.EnumKind:
enumValue := field.Enum().Values().ByName(protoreflect.Name(result))
if enumValue == nil {
return protoreflect.Value{}, fmt.Errorf("invalid enum value %q", result)
}
return protoreflect.ValueOfEnum(enumValue.Number()), nil
default:
// TODO: add more kinds
// skip any other types
return protoreflect.Value{}, nil
}
}
// promptList prompts the user for a comma-separated list of values for a repeated field.
// The user will be prompted to enter values separated by commas which will be parsed
// according to the field's type using valueOf.
func promptList(field protoreflect.FieldDescriptor, msg protoreflect.Message, promptUi promptui.Prompt, promptPrefix string) (protoreflect.List, error) {
promptUi.Label = fmt.Sprintf("Enter %s %s list (separate values with ',')", promptPrefix, string(field.Name()))
result, err := promptUi.Run()
if err != nil {
return nil, fmt.Errorf("failed to prompt for %s: %w", string(field.Name()), err)
}
list := msg.Mutable(field).List()
for _, item := range strings.Split(result, ",") {
v, err := valueOf(field, item)
if err != nil {
return nil, err
}
list.Append(v)
}
return list, nil
}
// promptInnerMessageKind handles prompting for fields that are of message kind.
// It handles both single messages and repeated message fields by delegating to
// promptInnerMessage and promptMessageList respectively.
func promptInnerMessageKind(
f protoreflect.FieldDescriptor, addressCodec addresscodec.Codec,
validatorAddressCodec, consensusAddressCodec addresscodec.Codec,
promptPrefix string, stdIn io.ReadCloser, msg protoreflect.Message,
) error {
if f.IsList() {
return promptMessageList(f, addressCodec, validatorAddressCodec, consensusAddressCodec, promptPrefix, stdIn, msg)
}
return promptInnerMessage(f, addressCodec, validatorAddressCodec, consensusAddressCodec, promptPrefix, stdIn, msg)
}
// promptInnerMessage prompts for a single nested message field. It creates a new message instance,
// recursively prompts for its fields, and sets the populated message on the parent message.
func promptInnerMessage(
f protoreflect.FieldDescriptor, addressCodec addresscodec.Codec,
validatorAddressCodec, consensusAddressCodec addresscodec.Codec,
promptPrefix string, stdIn io.ReadCloser, msg protoreflect.Message,
) error {
fieldName := promptPrefix + "." + string(f.Name())
nestedMsg := msg.Get(f).Message()
nestedMsg = nestedMsg.New()
// Recursively prompt for nested message fields
updatedMsg, err := promptMessage(
addressCodec,
validatorAddressCodec,
consensusAddressCodec,
fieldName,
stdIn,
nestedMsg,
)
if err != nil {
return fmt.Errorf("failed to prompt for nested message %s: %w", fieldName, err)
}
msg.Set(f, protoreflect.ValueOfMessage(updatedMsg))
return nil
}
// promptMessageList prompts for a repeated message field by repeatedly creating new message instances,
// prompting for their fields, and appending them to the list until the user chooses to stop.
func promptMessageList(
f protoreflect.FieldDescriptor, addressCodec addresscodec.Codec,
validatorAddressCodec, consensusAddressCodec addresscodec.Codec,
promptPrefix string, stdIn io.ReadCloser, msg protoreflect.Message,
) error {
list := msg.Mutable(f).List()
for {
fieldName := promptPrefix + "." + string(f.Name())
// Create and populate a new message for the list
nestedMsg := list.NewElement().Message()
updatedMsg, err := promptMessage(
addressCodec,
validatorAddressCodec,
consensusAddressCodec,
fieldName,
stdIn,
nestedMsg,
)
if err != nil {
return fmt.Errorf("failed to prompt for list item in %s: %w", fieldName, err)
}
list.Append(protoreflect.ValueOfMessage(updatedMsg))
// Prompt whether to continue
// TODO: may be better yes/no rather than interactive?
continuePrompt := promptui.Select{
Label: "Add another item?",
Items: []string{"No", "Yes"},
Stdin: stdIn,
}
_, result, err := continuePrompt.Run()
if err != nil {
return fmt.Errorf("failed to prompt for continuation: %w", err)
}
if result == "No" {
break
}
}
return nil
}

View File

@ -0,0 +1,59 @@
package prompt
import (
"io"
"strings"
"testing"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/reflect/protoreflect"
"cosmossdk.io/client/v2/internal/testpb"
address2 "github.com/cosmos/cosmos-sdk/codec/address"
)
func getReader(inputs []string) io.ReadCloser {
// https://github.com/manifoldco/promptui/issues/63#issuecomment-621118463
var paddedInputs []string
for _, input := range inputs {
padding := strings.Repeat("a", 4096-1-len(input)%4096)
paddedInputs = append(paddedInputs, input+"\n"+padding)
}
return io.NopCloser(strings.NewReader(strings.Join(paddedInputs, "")))
}
func TestPromptMessage(t *testing.T) {
tests := []struct {
name string
msg protoreflect.Message
inputs []string
}{
{
name: "testPb",
inputs: []string{
"1", "2", "string", "bytes", "10101010", "0", "234234", "3", "4", "5", "true", "ENUM_ONE",
"bar", "6", "10000", "stake", "cosmos10d07y265gmmuvt4z0w9aw880jnsr700j6zn9kn",
"bytes", "6", "7", "false", "false", "true,false,true", "1,2,3", "hello,hola,ciao", "ENUM_ONE,ENUM_TWO",
"10239", "0", "No", "bar", "343", "No", "134", "positional2", "23455", "stake", "No", "deprecate",
"shorthand", "false", "cosmosvaloper1tnh2q55v8wyygtt9srz5safamzdengsn9dsd7z",
},
msg: (&testpb.MsgRequest{}).ProtoReflect(),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// https://github.com/manifoldco/promptui/issues/63#issuecomment-621118463
var paddedInputs []string
for _, input := range tt.inputs {
padding := strings.Repeat("a", 4096-1-len(input)%4096)
paddedInputs = append(paddedInputs, input+"\n"+padding)
}
reader := io.NopCloser(strings.NewReader(strings.Join(paddedInputs, "")))
got, err := promptMessage(address2.NewBech32Codec("cosmos"), address2.NewBech32Codec("cosmosvaloper"), address2.NewBech32Codec("cosmosvalcons"), "prefix", reader, tt.msg)
require.NoError(t, err)
require.NotNil(t, got)
})
}
}

View File

@ -0,0 +1,134 @@
package prompt
import (
"fmt"
"io"
"reflect"
"strconv"
"strings"
"github.com/manifoldco/promptui"
)
// PromptStruct prompts for values of a struct's fields interactively.
// It returns the populated struct and any error encountered.
func PromptStruct[T any](promptPrefix string, data T) (T, error) {
return promptStruct(promptPrefix, data, nil)
}
// promptStruct prompts for values of a struct's fields interactively.
//
// For each field in the struct:
// - Pointer fields are initialized if nil and handled recursively if they contain structs
// - Struct fields are handled recursively
// - String and int slices are supported
// - String and int fields are prompted for and populated
// - Only String and int pointers are supported
// - Other types are skipped
func promptStruct[T any](promptPrefix string, data T, stdIn io.ReadCloser) (T, error) {
v := reflect.ValueOf(&data).Elem()
if v.Kind() == reflect.Interface {
v = reflect.ValueOf(data)
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
}
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
fieldName := strings.ToLower(v.Type().Field(i).Name)
// Handle pointer types
if field.Kind() == reflect.Ptr {
if field.IsNil() {
field.Set(reflect.New(field.Type().Elem()))
}
if field.Elem().Kind() == reflect.Struct {
result, err := promptStruct(promptPrefix+"."+fieldName, field.Interface(), stdIn)
if err != nil {
return data, err
}
field.Set(reflect.ValueOf(result))
continue
}
}
switch field.Kind() {
case reflect.Struct:
// For struct fields, create a new pointer to handle them
structPtr := reflect.New(field.Type()).Interface()
reflect.ValueOf(structPtr).Elem().Set(field)
result, err := promptStruct(promptPrefix+"."+fieldName, structPtr, stdIn)
if err != nil {
return data, err
}
// Get the actual struct value from the result
resultValue := reflect.ValueOf(result)
if resultValue.Kind() == reflect.Ptr {
resultValue = resultValue.Elem()
}
field.Set(resultValue)
continue
case reflect.Slice:
if v.Field(i).Type().Elem().Kind() != reflect.String && v.Field(i).Type().Elem().Kind() != reflect.Int {
continue
}
}
// create prompts
prompt := promptui.Prompt{
Label: fmt.Sprintf("Enter %s %s", promptPrefix, strings.Title(fieldName)), // nolint:staticcheck // strings.Title has a better API
Validate: ValidatePromptNotEmpty,
Stdin: stdIn,
}
result, err := prompt.Run()
if err != nil {
return data, fmt.Errorf("failed to prompt for %s: %w", fieldName, err)
}
switch field.Kind() {
case reflect.String:
v.Field(i).SetString(result)
case reflect.Int:
resultInt, err := strconv.ParseInt(result, 10, 0)
if err != nil {
return data, fmt.Errorf("invalid value for int: %w", err)
}
v.Field(i).SetInt(resultInt)
case reflect.Slice:
switch v.Field(i).Type().Elem().Kind() {
case reflect.String:
v.Field(i).Set(reflect.ValueOf([]string{result}))
case reflect.Int:
resultInt, err := strconv.ParseInt(result, 10, 0)
if err != nil {
return data, fmt.Errorf("invalid value for int: %w", err)
}
v.Field(i).Set(reflect.ValueOf([]int{int(resultInt)}))
}
case reflect.Ptr:
// Handle pointer fields by creating a new value and setting it
ptrValue := reflect.New(field.Type().Elem())
if ptrValue.Elem().Kind() == reflect.String {
ptrValue.Elem().SetString(result)
v.Field(i).Set(ptrValue)
} else if ptrValue.Elem().Kind() == reflect.Int {
resultInt, err := strconv.ParseInt(result, 10, 0)
if err != nil {
return data, fmt.Errorf("invalid value for int: %w", err)
}
ptrValue.Elem().SetInt(resultInt)
v.Field(i).Set(ptrValue)
}
default:
// skip any other types
continue
}
}
return data, nil
}

View File

@ -0,0 +1,46 @@
package prompt
import (
"testing"
"github.com/stretchr/testify/require"
)
type innerStruct struct {
A string
B int
}
type testStruct struct {
A string
B int
C *innerStruct
D innerStruct
E *string
F []string
}
func TestPromptStruct(t *testing.T) {
type testCase[T any] struct {
name string
data T
inputs []string
}
tests := []testCase[testStruct]{
{
name: "test struct",
data: testStruct{},
inputs: []string{
"a", "1", "b", "2", "c", "3", "pointerStr", "list",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
inputs := getReader(tt.inputs)
got, err := promptStruct("testStruct", tt.data, inputs)
require.NoError(t, err)
require.NotNil(t, got)
})
}
}

View File

@ -0,0 +1,70 @@
package prompt
import (
"fmt"
"github.com/manifoldco/promptui"
"google.golang.org/protobuf/reflect/protoreflect"
)
// Select prompts the user to select an option from a list of choices.
// It takes a label string to display above the selection prompt and a slice of string options to choose from.
func Select(label string, options []string) (string, error) {
selectUi := promptui.Select{
Label: label,
Items: options,
}
_, selectedProposalType, err := selectUi.Run()
if err != nil {
return "", fmt.Errorf("failed to prompt proposal types: %w", err)
}
return selectedProposalType, nil
}
// PromptString prompts the user for a string input with the given label.
// It validates the input using the provided validate function.
func PromptString(label string, validate func(string) error) (string, error) {
promptUi := promptui.Prompt{
Label: label,
Validate: validate,
}
return promptUi.Run()
}
// SetDefaults sets default values on a protobuf message based on a map of field names to values.
// It iterates through the message fields and sets values from the defaults map if the field name
// and type match.
func SetDefaults(msg protoreflect.Message, defaults map[string]interface{}) {
fields := msg.Descriptor().Fields()
for i := 0; i < fields.Len(); i++ {
field := fields.Get(i)
fieldName := string(field.Name())
if v, ok := defaults[fieldName]; ok {
// Get the field's kind
fieldKind := field.Kind()
switch v.(type) {
case string:
if fieldKind == protoreflect.StringKind {
msg.Set(field, protoreflect.ValueOf(v))
}
case int64:
if fieldKind == protoreflect.Int64Kind {
msg.Set(field, protoreflect.ValueOf(v))
}
case int32:
if fieldKind == protoreflect.Int32Kind {
msg.Set(field, protoreflect.ValueOf(v))
}
case bool:
if fieldKind == protoreflect.BoolKind {
msg.Set(field, protoreflect.ValueOf(v))
}
}
}
}
}

View File

@ -1,4 +1,4 @@
package client
package prompt
import (
"errors"
@ -6,7 +6,7 @@ import (
"net/url"
"unicode"
sdk "github.com/cosmos/cosmos-sdk/types"
"cosmossdk.io/core/address"
)
// ValidatePromptNotEmpty validates that the input is not empty.
@ -18,6 +18,18 @@ func ValidatePromptNotEmpty(input string) error {
return nil
}
// ValidateAddress returns a validation function that checks if a string is a valid address
// for the given address codec.
func ValidateAddress(ac address.Codec) func(string) error {
return func(i string) error {
if _, err := ac.StringToBytes(i); err != nil {
return fmt.Errorf("invalid address")
}
return nil
}
}
// ValidatePromptURL validates that the input is a valid URL.
func ValidatePromptURL(input string) error {
_, err := url.ParseRequestURI(input)
@ -28,35 +40,6 @@ func ValidatePromptURL(input string) error {
return nil
}
// ValidatePromptAddress validates that the input is a valid Bech32 address.
func ValidatePromptAddress(input string) error { // TODO(@julienrbrt) remove and add prompts in AutoCLI
_, err := sdk.AccAddressFromBech32(input)
if err == nil {
return nil
}
_, err = sdk.ValAddressFromBech32(input)
if err == nil {
return nil
}
_, err = sdk.ConsAddressFromBech32(input)
if err == nil {
return nil
}
return fmt.Errorf("invalid address: %w", err)
}
// ValidatePromptCoins validates that the input contains valid sdk.Coins
func ValidatePromptCoins(input string) error {
if _, err := sdk.ParseCoinsNormalized(input); err != nil {
return fmt.Errorf("invalid coins: %w", err)
}
return nil
}
// CamelCaseToString converts a camel case string to a string with spaces.
func CamelCaseToString(str string) string {
w := []rune(str)

View File

@ -0,0 +1,55 @@
package prompt
import (
"testing"
"github.com/stretchr/testify/require"
"cosmossdk.io/core/address"
address2 "github.com/cosmos/cosmos-sdk/codec/address"
)
func TestValidatePromptNotEmpty(t *testing.T) {
require := require.New(t)
require.NoError(ValidatePromptNotEmpty("foo"))
require.ErrorContains(ValidatePromptNotEmpty(""), "input cannot be empty")
}
func TestValidateAddress(t *testing.T) {
tests := []struct {
name string
ac address.Codec
addr string
}{
{
name: "address",
ac: address2.NewBech32Codec("cosmos"),
addr: "cosmos129lxcu2n3hx54fdxlwsahqkjr3sp32cxm00zlm",
},
{
name: "validator address",
ac: address2.NewBech32Codec("cosmosvaloper"),
addr: "cosmosvaloper1tnh2q55v8wyygtt9srz5safamzdengsn9dsd7z",
},
{
name: "consensus address",
ac: address2.NewBech32Codec("cosmosvalcons"),
addr: "cosmosvalcons136uu5rj23kdr3jjcmjt7aw5qpugjjat2klgrus",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateAddress(tt.ac)(tt.addr)
require.NoError(t, err)
})
}
}
func TestValidatePromptURL(t *testing.T) {
require := require.New(t)
require.NoError(ValidatePromptURL("https://example.com"))
require.ErrorContains(ValidatePromptURL("foo"), "invalid URL")
}

View File

@ -119,7 +119,7 @@ require (
github.com/lib/pq v1.10.9 // indirect
github.com/linxGnu/grocksdb v1.9.3 // indirect
github.com/magiconair/properties v1.8.9 // indirect
github.com/manifoldco/promptui v0.9.0 // indirect
github.com/manifoldco/promptui v0.9.0
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minio/highwayhash v1.0.3 // indirect

View File

@ -1,37 +0,0 @@
package prompt
import (
"errors"
"fmt"
"net/url"
sdk "github.com/cosmos/cosmos-sdk/types"
)
// ValidatePromptNotEmpty validates that the input is not empty.
func ValidatePromptNotEmpty(input string) error {
if input == "" {
return errors.New("input cannot be empty")
}
return nil
}
// ValidatePromptURL validates that the input is a valid URL.
func ValidatePromptURL(input string) error {
_, err := url.ParseRequestURI(input)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
return nil
}
// ValidatePromptCoins validates that the input contains valid sdk.Coins
func ValidatePromptCoins(input string) error {
if _, err := sdk.ParseCoinsNormalized(input); err != nil {
return fmt.Errorf("invalid coins: %w", err)
}
return nil
}

View File

@ -1,30 +0,0 @@
package prompt_test
import (
"testing"
"github.com/stretchr/testify/require"
"cosmossdk.io/client/v2/internal/prompt"
)
func TestValidatePromptNotEmpty(t *testing.T) {
require := require.New(t)
require.NoError(prompt.ValidatePromptNotEmpty("foo"))
require.ErrorContains(prompt.ValidatePromptNotEmpty(""), "input cannot be empty")
}
func TestValidatePromptURL(t *testing.T) {
require := require.New(t)
require.NoError(prompt.ValidatePromptURL("https://example.com"))
require.ErrorContains(prompt.ValidatePromptURL("foo"), "invalid URL")
}
func TestValidatePromptCoins(t *testing.T) {
require := require.New(t)
require.NoError(prompt.ValidatePromptCoins("100stake"))
require.ErrorContains(prompt.ValidatePromptCoins("foo"), "invalid coins")
}

View File

@ -4,7 +4,7 @@ go 1.23.3
require (
cosmossdk.io/api v0.8.0 // main
cosmossdk.io/client/v2 v2.0.0-20230630094428-02b760776860
cosmossdk.io/client/v2 v2.0.0-20241211112513-a4c34c41b4c7
cosmossdk.io/collections v0.4.1-0.20241209183624-332d0b106d1b // main
cosmossdk.io/core v1.0.0-alpha.6 // main
cosmossdk.io/core/testing v0.0.0-20241108153815-606544c7be7e // main

View File

@ -4,7 +4,7 @@ go 1.23.3
require (
cosmossdk.io/api v0.8.0 // main
cosmossdk.io/client/v2 v2.0.0-00010101000000-000000000000
cosmossdk.io/client/v2 v2.0.0-20241211112513-a4c34c41b4c7
cosmossdk.io/core v1.0.0 // main
cosmossdk.io/core/testing v0.0.0 // indirect; main
cosmossdk.io/depinject v1.1.0

View File

@ -69,7 +69,7 @@ require (
cloud.google.com/go/compute/metadata v0.5.0 // indirect
cloud.google.com/go/iam v1.1.8 // indirect
cloud.google.com/go/storage v1.42.0 // indirect
cosmossdk.io/client/v2 v2.0.0-20230630094428-02b760776860 // indirect
cosmossdk.io/client/v2 v2.0.0-20241211112513-a4c34c41b4c7 // indirect
cosmossdk.io/errors v1.0.1 // indirect
cosmossdk.io/errors/v2 v2.0.0-20240731132947-df72853b3ca5 // indirect
cosmossdk.io/indexer/postgres v0.0.0-20241128094659-bd76b47e1d8b // indirect

View File

@ -12,7 +12,7 @@ require (
cosmossdk.io/math v1.4.0
cosmossdk.io/store v1.1.1-0.20240909133312-50288938d1b6
cosmossdk.io/x/bank v0.0.0-20240226161501-23359a0b6d91
cosmossdk.io/x/gov v0.0.0-20230925135524-a1bc045b3190
cosmossdk.io/x/gov v0.0.0-20231113122742-912390d5fc4a
github.com/cometbft/cometbft v1.0.0-rc2.0.20241127125717-4ce33b646ac9 // indirect
github.com/cosmos/cosmos-proto v1.0.0-beta.5
github.com/cosmos/cosmos-sdk v0.52.0
@ -29,6 +29,7 @@ require (
)
require (
cosmossdk.io/client/v2 v2.0.0-20241211112513-a4c34c41b4c7 // indirect
github.com/bytedance/sonic v1.12.4 // indirect
github.com/bytedance/sonic/loader v0.2.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect

View File

@ -6,6 +6,8 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cosmossdk.io/api v0.7.3-0.20240924065902-eb7653cfecdf h1:CttA/mEIxGm4E7vwrjUpju7/Iespns08d9bOza70cIc=
cosmossdk.io/api v0.7.3-0.20240924065902-eb7653cfecdf/go.mod h1:YMfx2ATpgITsoydD3hIBa8IkDHtyXp/14rmG0d3sEew=
cosmossdk.io/client/v2 v2.0.0-20241211112513-a4c34c41b4c7 h1:sV7U1DpnWPAz9Z2Nz8019DIIw1Z+BjekEY1lLzrtL/w=
cosmossdk.io/client/v2 v2.0.0-20241211112513-a4c34c41b4c7/go.mod h1:8PzpjDx0Wfe5T+r1HkAzaRNQCk/tQzG3ChK8YIq5ObA=
cosmossdk.io/collections v0.4.1-0.20241209183624-332d0b106d1b h1:smupoVhpdK+5pztIylyIGkCc+0QaAaGLEvnM7Wnrq18=
cosmossdk.io/collections v0.4.1-0.20241209183624-332d0b106d1b/go.mod h1:uf12i1yKvzEIHt2ok7poNqFDQTb71O00RQLitSynmrg=
cosmossdk.io/core v1.0.0-alpha.6 h1:5ukC4JcQKmemLQXcAgu/QoOvJI50hpBkIIg4ZT2EN8E=

View File

@ -61,6 +61,7 @@ Ref: https://keepachangelog.com/en/1.0.0/
### Client Breaking Changes
* [#19101](https://github.com/cosmos/cosmos-sdk/pull/19101) Querying specific params types was deprecated in gov/v1 and has been removed. gov/v1beta1 rest unchanged.
* [#22775](https://github.com/cosmos/cosmos-sdk/pull/22775) Refactored interactive proposal prompts to use `client/v2/autocli/prompt` package.
### API Breaking Changes

View File

@ -4,14 +4,15 @@ import (
"encoding/json"
"fmt"
"os"
"reflect" // #nosec
"sort"
"strconv"
"strings"
"github.com/manifoldco/promptui"
gogoproto "github.com/cosmos/gogoproto/proto"
"github.com/spf13/cobra"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoregistry"
"cosmossdk.io/client/v2/autocli/prompt"
"cosmossdk.io/core/address"
"cosmossdk.io/x/gov/types"
@ -19,7 +20,7 @@ import (
"github.com/cosmos/cosmos-sdk/client/flags"
"github.com/cosmos/cosmos-sdk/codec"
sdk "github.com/cosmos/cosmos-sdk/types"
authtypes "github.com/cosmos/cosmos-sdk/x/auth/types"
sdkaddress "github.com/cosmos/cosmos-sdk/types/address"
)
const (
@ -60,102 +61,6 @@ var suggestedProposalTypes = []proposalType{
},
}
// Prompt prompts the user for all values of the given type.
// data is the struct to be filled
// namePrefix is the name to be displayed as "Enter <namePrefix> <field>"
// TODO: when bringing this in autocli, use proto message instead
// this will simplify the get address logic
func Prompt[T any](data T, namePrefix string, addressCodec address.Codec) (T, error) {
v := reflect.ValueOf(&data).Elem()
if v.Kind() == reflect.Interface {
v = reflect.ValueOf(data)
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
}
for i := 0; i < v.NumField(); i++ {
// if the field is a struct skip or not slice of string or int then skip
switch v.Field(i).Kind() {
case reflect.Struct:
// TODO(@julienrbrt) in the future we can add a recursive call to Prompt
continue
case reflect.Slice:
if v.Field(i).Type().Elem().Kind() != reflect.String && v.Field(i).Type().Elem().Kind() != reflect.Int {
continue
}
}
// create prompts
prompt := promptui.Prompt{
Label: fmt.Sprintf("Enter %s %s", namePrefix, strings.ToLower(client.CamelCaseToString(v.Type().Field(i).Name))),
Validate: client.ValidatePromptNotEmpty,
}
fieldName := strings.ToLower(v.Type().Field(i).Name)
if strings.EqualFold(fieldName, "authority") {
// pre-fill with gov address
defaultAddr, err := addressCodec.BytesToString(authtypes.NewModuleAddress(types.ModuleName))
if err != nil {
return data, err
}
prompt.Default = defaultAddr
prompt.Validate = client.ValidatePromptAddress
}
// TODO(@julienrbrt) use scalar annotation instead of dumb string name matching
if strings.Contains(fieldName, "addr") ||
strings.Contains(fieldName, "sender") ||
strings.Contains(fieldName, "voter") ||
strings.Contains(fieldName, "depositor") ||
strings.Contains(fieldName, "granter") ||
strings.Contains(fieldName, "grantee") ||
strings.Contains(fieldName, "recipient") {
prompt.Validate = client.ValidatePromptAddress
}
result, err := prompt.Run()
if err != nil {
return data, fmt.Errorf("failed to prompt for %s: %w", fieldName, err)
}
switch v.Field(i).Kind() {
case reflect.String:
v.Field(i).SetString(result)
case reflect.Int:
resultInt, err := strconv.ParseInt(result, 10, 0)
if err != nil {
return data, fmt.Errorf("invalid value for int: %w", err)
}
// If a value was successfully parsed the ranges of:
// [minInt, maxInt]
// are within the ranges of:
// [minInt64, maxInt64]
// of which on 64-bit machines, which are most common,
// int==int64
v.Field(i).SetInt(resultInt)
case reflect.Slice:
switch v.Field(i).Type().Elem().Kind() {
case reflect.String:
v.Field(i).Set(reflect.ValueOf([]string{result}))
case reflect.Int:
resultInt, err := strconv.ParseInt(result, 10, 0)
if err != nil {
return data, fmt.Errorf("invalid value for int: %w", err)
}
v.Field(i).Set(reflect.ValueOf([]int{int(resultInt)}))
}
default:
// skip any other types
continue
}
}
return data, nil
}
type proposalType struct {
Name string
MsgType string
@ -163,8 +68,8 @@ type proposalType struct {
}
// Prompt the proposal type values and return the proposal and its metadata
func (p *proposalType) Prompt(cdc codec.Codec, skipMetadata bool, addressCodec address.Codec) (*proposal, types.ProposalMetadata, error) {
metadata, err := PromptMetadata(skipMetadata, addressCodec)
func (p *proposalType) Prompt(cdc codec.Codec, skipMetadata bool, addressCodec, validatorAddressCodec, consensusAddressCodec address.Codec) (*proposal, types.ProposalMetadata, error) {
metadata, err := PromptMetadata(skipMetadata)
if err != nil {
return nil, metadata, fmt.Errorf("failed to set proposal metadata: %w", err)
}
@ -176,11 +81,7 @@ func (p *proposalType) Prompt(cdc codec.Codec, skipMetadata bool, addressCodec a
}
// set deposit
depositPrompt := promptui.Prompt{
Label: "Enter proposal deposit",
Validate: client.ValidatePromptCoins,
}
proposal.Deposit, err = depositPrompt.Run()
proposal.Deposit, err = prompt.PromptString("Enter proposal deposit", ValidatePromptCoins)
if err != nil {
return nil, metadata, fmt.Errorf("failed to set proposal deposit: %w", err)
}
@ -190,12 +91,35 @@ func (p *proposalType) Prompt(cdc codec.Codec, skipMetadata bool, addressCodec a
}
// set messages field
result, err := Prompt(p.Msg, "msg", addressCodec)
msg, err := protoregistry.GlobalTypes.FindMessageByURL(p.MsgType)
if err != nil {
return nil, metadata, fmt.Errorf("failed to find proposal msg: %w", err)
}
newMsg := msg.New()
govAddr := sdkaddress.Module(types.ModuleName)
govAddrStr, err := addressCodec.BytesToString(govAddr)
if err != nil {
return nil, metadata, fmt.Errorf("failed to convert gov address to string: %w", err)
}
prompt.SetDefaults(newMsg, map[string]interface{}{"authority": govAddrStr})
result, err := prompt.PromptMessage(addressCodec, validatorAddressCodec, consensusAddressCodec, "msg", newMsg)
if err != nil {
return nil, metadata, fmt.Errorf("failed to set proposal message: %w", err)
}
message, err := cdc.MarshalInterfaceJSON(result)
// message must be converted to gogoproto so @type is not lost
resultBytes, err := proto.Marshal(result.Interface())
if err != nil {
return nil, metadata, fmt.Errorf("failed to marshal proposal message: %w", err)
}
err = gogoproto.Unmarshal(resultBytes, p.Msg)
if err != nil {
return nil, metadata, fmt.Errorf("failed to unmarshal proposal message: %w", err)
}
message, err := cdc.MarshalInterfaceJSON(p.Msg)
if err != nil {
return nil, metadata, fmt.Errorf("failed to marshal proposal message: %w", err)
}
@ -214,33 +138,22 @@ func getProposalSuggestions() []string {
}
// PromptMetadata prompts for proposal metadata or only title and summary if skip is true
func PromptMetadata(skip bool, addressCodec address.Codec) (types.ProposalMetadata, error) {
func PromptMetadata(skip bool) (types.ProposalMetadata, error) {
if !skip {
metadata, err := Prompt(types.ProposalMetadata{}, "proposal", addressCodec)
metadata, err := prompt.PromptStruct("proposal", types.ProposalMetadata{})
if err != nil {
return metadata, fmt.Errorf("failed to set proposal metadata: %w", err)
return types.ProposalMetadata{}, err
}
return metadata, nil
}
// prompt for title and summary
titlePrompt := promptui.Prompt{
Label: "Enter proposal title",
Validate: client.ValidatePromptNotEmpty,
}
title, err := titlePrompt.Run()
title, err := prompt.PromptString("Enter proposal title", ValidatePromptNotEmpty)
if err != nil {
return types.ProposalMetadata{}, fmt.Errorf("failed to set proposal title: %w", err)
}
summaryPrompt := promptui.Prompt{
Label: "Enter proposal summary",
Validate: client.ValidatePromptNotEmpty,
}
summary, err := summaryPrompt.Run()
summary, err := prompt.PromptString("Enter proposal summary", ValidatePromptNotEmpty)
if err != nil {
return types.ProposalMetadata{}, fmt.Errorf("failed to set proposal summary: %w", err)
}
@ -262,17 +175,10 @@ func NewCmdDraftProposal() *cobra.Command {
return err
}
// prompt proposal type
proposalTypesPrompt := promptui.Select{
Label: "Select proposal type",
Items: getProposalSuggestions(),
}
_, selectedProposalType, err := proposalTypesPrompt.Run()
selectedProposalType, err := prompt.Select("Select proposal type", getProposalSuggestions())
if err != nil {
return fmt.Errorf("failed to prompt proposal types: %w", err)
}
var proposal proposalType
for _, p := range suggestedProposalTypes {
if strings.EqualFold(p.Name, selectedProposalType) {
@ -283,17 +189,10 @@ func NewCmdDraftProposal() *cobra.Command {
// create any proposal type
if proposal.Name == proposalOther {
// prompt proposal type
msgPrompt := promptui.Select{
Label: "Select proposal message type:",
Items: func() []string {
msgs := clientCtx.InterfaceRegistry.ListImplementations(sdk.MsgInterfaceProtoName)
sort.Strings(msgs)
return msgs
}(),
}
msgs := clientCtx.InterfaceRegistry.ListImplementations(sdk.MsgInterfaceProtoName)
sort.Strings(msgs)
_, result, err := msgPrompt.Run()
result, err := prompt.Select("Select proposal message type:", msgs)
if err != nil {
return fmt.Errorf("failed to prompt proposal types: %w", err)
}
@ -311,7 +210,7 @@ func NewCmdDraftProposal() *cobra.Command {
skipMetadataPrompt, _ := cmd.Flags().GetBool(flagSkipMetadata)
result, metadata, err := proposal.Prompt(clientCtx.Codec, skipMetadataPrompt, clientCtx.AddressCodec)
result, metadata, err := proposal.Prompt(clientCtx.Codec, skipMetadataPrompt, clientCtx.AddressCodec, clientCtx.ValidatorAddressCodec, clientCtx.ConsensusAddressCodec)
if err != nil {
return err
}

View File

@ -1,90 +0,0 @@
//go:build !race
// +build !race
// Disabled -race because the package github.com/manifoldco/promptui@v0.9.0
// has a data race and this code exposes it, but fixing it would require
// holding up the associated change to this.
package cli_test
import (
"fmt"
"math"
"os"
"testing"
"github.com/chzyer/readline"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"cosmossdk.io/x/gov/client/cli"
codectestutil "github.com/cosmos/cosmos-sdk/codec/testutil"
)
type st struct {
I int
}
// Tests that we successfully report overflows in parsing ints
// See https://github.com/cosmos/cosmos-sdk/issues/13346
func TestPromptIntegerOverflow(t *testing.T) {
// Intentionally sending values out of the range of int.
intOverflowers := []string{
"-9223372036854775809",
"9223372036854775808",
"9923372036854775809",
"-9923372036854775809",
"18446744073709551616",
"-18446744073709551616",
}
for _, intOverflower := range intOverflowers {
overflowStr := intOverflower
t.Run(overflowStr, func(t *testing.T) {
origStdin := readline.Stdin
defer func() {
readline.Stdin = origStdin
}()
fin, fw := readline.NewFillableStdin(os.Stdin)
readline.Stdin = fin
_, err := fw.Write([]byte(overflowStr + "\n"))
assert.NoError(t, err)
v, err := cli.Prompt(st{}, "", codectestutil.CodecOptions{}.GetAddressCodec())
assert.Equal(t, st{}, v, "expected a value of zero")
require.NotNil(t, err, "expected a report of an overflow")
require.Contains(t, err.Error(), "range")
})
}
}
func TestPromptParseInteger(t *testing.T) {
// Intentionally sending a value out of the range of
values := []struct {
in string
want int
}{
{fmt.Sprintf("%d", math.MinInt), math.MinInt},
{"19991", 19991},
{"991000000199", 991000000199},
}
for _, tc := range values {
t.Run(tc.in, func(t *testing.T) {
origStdin := readline.Stdin
defer func() {
readline.Stdin = origStdin
}()
fin, fw := readline.NewFillableStdin(os.Stdin)
readline.Stdin = fin
_, err := fw.Write([]byte(tc.in + "\n"))
assert.NoError(t, err)
v, err := cli.Prompt(st{}, "", codectestutil.CodecOptions{}.GetAddressCodec())
assert.Nil(t, err, "expected a nil error")
assert.Equal(t, tc.want, v.I, "expected %d = %d", tc.want, v.I)
})
}
}

View File

@ -209,3 +209,21 @@ func ReadGovPropFlags(clientCtx client.Context, flagSet *pflag.FlagSet) (*govv1.
return ReadGovPropCmdFlags(addr, flagSet)
}
// ValidatePromptCoins validates that the input contains valid sdk.Coins
func ValidatePromptCoins(input string) error {
if _, err := sdk.ParseCoinsNormalized(input); err != nil {
return fmt.Errorf("invalid coins: %w", err)
}
return nil
}
// ValidatePromptNotEmpty validates that the input is not empty.
func ValidatePromptNotEmpty(input string) error {
if input == "" {
return errors.New("input cannot be empty")
}
return nil
}

View File

@ -714,3 +714,17 @@ func TestReadGovPropFlags(t *testing.T) {
})
}
}
func TestValidatePromptNotEmpty(t *testing.T) {
require := require.New(t)
require.NoError(ValidatePromptNotEmpty("foo"))
require.ErrorContains(ValidatePromptNotEmpty(""), "input cannot be empty")
}
func TestValidatePromptCoins(t *testing.T) {
require := require.New(t)
require.NoError(ValidatePromptCoins("100stake"))
require.ErrorContains(ValidatePromptCoins("foo"), "invalid coins")
}

View File

@ -4,6 +4,7 @@ go 1.23.3
require (
cosmossdk.io/api v0.8.0 // main
cosmossdk.io/client/v2 v2.0.0-20241211112513-a4c34c41b4c7
cosmossdk.io/collections v0.4.1-0.20241209183624-332d0b106d1b // main
cosmossdk.io/core v1.0.0-alpha.6 // main
cosmossdk.io/core/testing v0.0.0-20241108153815-606544c7be7e // main
@ -15,7 +16,7 @@ require (
cosmossdk.io/x/bank v0.0.0-20240226161501-23359a0b6d91
cosmossdk.io/x/protocolpool v0.0.0-20230925135524-a1bc045b3190
cosmossdk.io/x/staking v0.0.0-00010101000000-000000000000
github.com/chzyer/readline v1.5.1
github.com/chzyer/readline v1.5.1 // indirect
github.com/cometbft/cometbft v1.0.0-rc2.0.20241127125717-4ce33b646ac9 // indirect
github.com/cosmos/cosmos-proto v1.0.0-beta.5
github.com/cosmos/cosmos-sdk v0.52.0
@ -23,7 +24,7 @@ require (
github.com/golang/mock v1.6.0
github.com/golang/protobuf v1.5.4
github.com/grpc-ecosystem/grpc-gateway v1.16.0
github.com/manifoldco/promptui v0.9.0
github.com/manifoldco/promptui v0.9.0 // indirect
github.com/spf13/cobra v1.8.1
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.10.0

View File

@ -6,6 +6,8 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cosmossdk.io/api v0.7.3-0.20240924065902-eb7653cfecdf h1:CttA/mEIxGm4E7vwrjUpju7/Iespns08d9bOza70cIc=
cosmossdk.io/api v0.7.3-0.20240924065902-eb7653cfecdf/go.mod h1:YMfx2ATpgITsoydD3hIBa8IkDHtyXp/14rmG0d3sEew=
cosmossdk.io/client/v2 v2.0.0-20241211112513-a4c34c41b4c7 h1:sV7U1DpnWPAz9Z2Nz8019DIIw1Z+BjekEY1lLzrtL/w=
cosmossdk.io/client/v2 v2.0.0-20241211112513-a4c34c41b4c7/go.mod h1:8PzpjDx0Wfe5T+r1HkAzaRNQCk/tQzG3ChK8YIq5ObA=
cosmossdk.io/collections v0.4.1-0.20241209183624-332d0b106d1b h1:smupoVhpdK+5pztIylyIGkCc+0QaAaGLEvnM7Wnrq18=
cosmossdk.io/collections v0.4.1-0.20241209183624-332d0b106d1b/go.mod h1:uf12i1yKvzEIHt2ok7poNqFDQTb71O00RQLitSynmrg=
cosmossdk.io/core v1.0.0-alpha.6 h1:5ukC4JcQKmemLQXcAgu/QoOvJI50hpBkIIg4ZT2EN8E=

View File

@ -6,9 +6,12 @@ import (
"os"
"sort"
"github.com/manifoldco/promptui"
gogoproto "github.com/cosmos/gogoproto/proto"
"github.com/spf13/cobra"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoregistry"
"cosmossdk.io/client/v2/autocli/prompt"
"cosmossdk.io/core/address"
govcli "cosmossdk.io/x/gov/client/cli"
govtypes "cosmossdk.io/x/gov/types"
@ -27,14 +30,15 @@ const (
)
type proposalType struct {
Name string
Msg sdk.Msg
Name string
MsgType string
Msg sdk.Msg
}
// Prompt the proposal type values and return the proposal and its metadata.
func (p *proposalType) Prompt(cdc codec.Codec, skipMetadata bool, addressCodec address.Codec) (*Proposal, govtypes.ProposalMetadata, error) {
func (p *proposalType) Prompt(cdc codec.Codec, skipMetadata bool, addressCodec, validatorAddressCodec, consensusAddressCodec address.Codec) (*Proposal, govtypes.ProposalMetadata, error) {
// set metadata
metadata, err := govcli.PromptMetadata(skipMetadata, addressCodec)
metadata, err := govcli.PromptMetadata(skipMetadata)
if err != nil {
return nil, metadata, fmt.Errorf("failed to set proposal metadata: %w", err)
}
@ -46,22 +50,14 @@ func (p *proposalType) Prompt(cdc codec.Codec, skipMetadata bool, addressCodec a
}
// set group policy address
policyAddressPrompt := promptui.Prompt{
Label: "Enter group policy address",
Validate: client.ValidatePromptAddress,
}
groupPolicyAddress, err := policyAddressPrompt.Run()
groupPolicyAddress, err := prompt.PromptString("Enter group policy address", prompt.ValidateAddress(addressCodec))
if err != nil {
return nil, metadata, fmt.Errorf("failed to set group policy address: %w", err)
}
proposal.GroupPolicyAddress = groupPolicyAddress
// set proposer address
proposerPrompt := promptui.Prompt{
Label: "Enter proposer address",
Validate: client.ValidatePromptAddress,
}
proposerAddress, err := proposerPrompt.Run()
proposerAddress, err := prompt.PromptString("Enter proposer address", prompt.ValidateAddress(addressCodec))
if err != nil {
return nil, metadata, fmt.Errorf("failed to set proposer address: %w", err)
}
@ -72,12 +68,29 @@ func (p *proposalType) Prompt(cdc codec.Codec, skipMetadata bool, addressCodec a
}
// set messages field
result, err := govcli.Prompt(p.Msg, "msg", addressCodec)
msg, err := protoregistry.GlobalTypes.FindMessageByURL(p.MsgType)
if err != nil {
return nil, metadata, fmt.Errorf("failed to find proposal msg: %w", err)
}
newMsg := msg.New()
result, err := prompt.PromptMessage(addressCodec, validatorAddressCodec, consensusAddressCodec, "msg", newMsg)
if err != nil {
return nil, metadata, fmt.Errorf("failed to set proposal message: %w", err)
}
message, err := cdc.MarshalInterfaceJSON(result)
// message must be converted to gogoproto so @type is not lost
resultBytes, err := proto.Marshal(result.Interface())
if err != nil {
return nil, metadata, fmt.Errorf("failed to marshal proposal message: %w", err)
}
err = gogoproto.Unmarshal(resultBytes, p.Msg)
if err != nil {
return nil, metadata, fmt.Errorf("failed to unmarshal proposal message: %w", err)
}
message, err := cdc.MarshalInterfaceJSON(p.Msg)
if err != nil {
return nil, metadata, fmt.Errorf("failed to marshal proposal message: %w", err)
}
@ -101,12 +114,7 @@ func NewCmdDraftProposal() *cobra.Command {
}
// prompt proposal type
proposalTypesPrompt := promptui.Select{
Label: "Select proposal type",
Items: []string{proposalText, proposalOther},
}
_, selectedProposalType, err := proposalTypesPrompt.Run()
selectedProposalType, err := prompt.Select("Select proposal type", []string{proposalText, proposalOther})
if err != nil {
return fmt.Errorf("failed to prompt proposal types: %w", err)
}
@ -118,20 +126,15 @@ func NewCmdDraftProposal() *cobra.Command {
case proposalOther:
// prompt proposal type
proposal = &proposalType{Name: proposalOther}
msgPrompt := promptui.Select{
Label: "Select proposal message type:",
Items: func() []string {
msgs := clientCtx.InterfaceRegistry.ListImplementations(sdk.MsgInterfaceProtoName)
sort.Strings(msgs)
return msgs
}(),
}
_, result, err := msgPrompt.Run()
msgs := clientCtx.InterfaceRegistry.ListImplementations(sdk.MsgInterfaceProtoName)
sort.Strings(msgs)
result, err := prompt.Select("Select proposal message type:", msgs)
if err != nil {
return fmt.Errorf("failed to prompt proposal types: %w", err)
}
proposal.MsgType = result
proposal.Msg, err = sdk.GetMsgFromTypeURL(clientCtx.Codec, result)
if err != nil {
// should never happen
@ -143,7 +146,7 @@ func NewCmdDraftProposal() *cobra.Command {
skipMetadataPrompt, _ := cmd.Flags().GetBool(flagSkipMetadata)
result, metadata, err := proposal.Prompt(clientCtx.Codec, skipMetadataPrompt, clientCtx.AddressCodec)
result, metadata, err := proposal.Prompt(clientCtx.Codec, skipMetadataPrompt, clientCtx.AddressCodec, clientCtx.ValidatorAddressCodec, clientCtx.ConsensusAddressCodec)
if err != nil {
return err
}

View File

@ -14,7 +14,7 @@ require (
cosmossdk.io/x/authz v0.0.0-00010101000000-000000000000
cosmossdk.io/x/bank v0.0.0-20240226161501-23359a0b6d91
cosmossdk.io/x/consensus v0.0.0-00010101000000-000000000000
cosmossdk.io/x/gov v0.0.0-20230925135524-a1bc045b3190
cosmossdk.io/x/gov v0.0.0-20231113122742-912390d5fc4a
cosmossdk.io/x/mint v0.0.0-00010101000000-000000000000
cosmossdk.io/x/staking v0.0.0-00010101000000-000000000000
github.com/cockroachdb/apd/v2 v2.0.2
@ -24,7 +24,7 @@ require (
github.com/golang/mock v1.6.0
github.com/golang/protobuf v1.5.4
github.com/grpc-ecosystem/grpc-gateway v1.16.0
github.com/manifoldco/promptui v0.9.0
github.com/manifoldco/promptui v0.9.0 // indirect
github.com/spf13/cobra v1.8.1
github.com/stretchr/testify v1.10.0
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1
@ -175,6 +175,8 @@ require (
sigs.k8s.io/yaml v1.4.0 // indirect
)
require cosmossdk.io/client/v2 v2.0.0-20241211112513-a4c34c41b4c7
require (
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect

View File

@ -6,6 +6,8 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cosmossdk.io/api v0.7.3-0.20240924065902-eb7653cfecdf h1:CttA/mEIxGm4E7vwrjUpju7/Iespns08d9bOza70cIc=
cosmossdk.io/api v0.7.3-0.20240924065902-eb7653cfecdf/go.mod h1:YMfx2ATpgITsoydD3hIBa8IkDHtyXp/14rmG0d3sEew=
cosmossdk.io/client/v2 v2.0.0-20241211112513-a4c34c41b4c7 h1:sV7U1DpnWPAz9Z2Nz8019DIIw1Z+BjekEY1lLzrtL/w=
cosmossdk.io/client/v2 v2.0.0-20241211112513-a4c34c41b4c7/go.mod h1:8PzpjDx0Wfe5T+r1HkAzaRNQCk/tQzG3ChK8YIq5ObA=
cosmossdk.io/collections v0.4.1-0.20241209183624-332d0b106d1b h1:smupoVhpdK+5pztIylyIGkCc+0QaAaGLEvnM7Wnrq18=
cosmossdk.io/collections v0.4.1-0.20241209183624-332d0b106d1b/go.mod h1:uf12i1yKvzEIHt2ok7poNqFDQTb71O00RQLitSynmrg=
cosmossdk.io/core v1.0.0-alpha.6 h1:5ukC4JcQKmemLQXcAgu/QoOvJI50hpBkIIg4ZT2EN8E=

View File

@ -10,7 +10,7 @@ require (
cosmossdk.io/errors v1.0.1
cosmossdk.io/log v1.5.0
cosmossdk.io/store v1.1.1-0.20240909133312-50288938d1b6
cosmossdk.io/x/gov v0.0.0-20230925135524-a1bc045b3190
cosmossdk.io/x/gov v0.0.0-20231113122742-912390d5fc4a
github.com/cometbft/cometbft v1.0.0-rc2.0.20241127125717-4ce33b646ac9
github.com/cometbft/cometbft/api v1.0.0-rc2
github.com/cosmos/cosmos-proto v1.0.0-beta.5
@ -199,6 +199,7 @@ require (
)
require (
cosmossdk.io/client/v2 v2.0.0-20241211112513-a4c34c41b4c7 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect

View File

@ -194,6 +194,8 @@ cloud.google.com/go/workflows v1.6.0/go.mod h1:6t9F5h/unJz41YqfBmqSASJSXccBLtD1V
cloud.google.com/go/workflows v1.7.0/go.mod h1:JhSrZuVZWuiDfKEFxU0/F1PQjmpnpcoISEXH2bcHC3M=
cosmossdk.io/api v0.7.3-0.20240924065902-eb7653cfecdf h1:CttA/mEIxGm4E7vwrjUpju7/Iespns08d9bOza70cIc=
cosmossdk.io/api v0.7.3-0.20240924065902-eb7653cfecdf/go.mod h1:YMfx2ATpgITsoydD3hIBa8IkDHtyXp/14rmG0d3sEew=
cosmossdk.io/client/v2 v2.0.0-20241211112513-a4c34c41b4c7 h1:sV7U1DpnWPAz9Z2Nz8019DIIw1Z+BjekEY1lLzrtL/w=
cosmossdk.io/client/v2 v2.0.0-20241211112513-a4c34c41b4c7/go.mod h1:8PzpjDx0Wfe5T+r1HkAzaRNQCk/tQzG3ChK8YIq5ObA=
cosmossdk.io/collections v0.4.1-0.20241209183624-332d0b106d1b h1:smupoVhpdK+5pztIylyIGkCc+0QaAaGLEvnM7Wnrq18=
cosmossdk.io/collections v0.4.1-0.20241209183624-332d0b106d1b/go.mod h1:uf12i1yKvzEIHt2ok7poNqFDQTb71O00RQLitSynmrg=
cosmossdk.io/core v1.0.0-alpha.6 h1:5ukC4JcQKmemLQXcAgu/QoOvJI50hpBkIIg4ZT2EN8E=