diff --git a/examples/cmd-proxy/assets/summon.config.yaml b/examples/cmd-proxy/assets/summon.config.yaml index d1cd1ae..9c7f441 100644 --- a/examples/cmd-proxy/assets/summon.config.yaml +++ b/examples/cmd-proxy/assets/summon.config.yaml @@ -104,3 +104,11 @@ exec: cmd: [docker, run, -ti, -v, '{{ env "PWD"}}:/workdir', -w, /workdir, alpine] args: [echo] help: use alpine to echo a string + + prompt: + prompts: '{{ prompt "slotA" "What is your name" "David" }}' + cmd: [bash, -c] + args: + - | + echo hello {{ promptValue "slotA" }}! \ + How should I continue {{ prompt "continue" "Select One" (list "A" "B" "C") }} diff --git a/go.mod b/go.mod index 6fc054f..4a0c23b 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,17 @@ module github.com/davidovich/summon -go 1.21 +go 1.23 require ( github.com/DiSiqueira/GoTree v1.0.1-0.20190529205929-3e23dcd4532b github.com/Masterminds/sprig/v3 v3.2.3 + github.com/cqroot/prompt v0.9.4 github.com/google/go-cmp v0.6.0 github.com/lithammer/dedent v1.1.0 github.com/spf13/afero v1.11.0 github.com/spf13/cobra v1.8.0 github.com/spf13/pflag v1.0.5 - github.com/stretchr/testify v1.8.4 + github.com/stretchr/testify v1.9.0 golang.org/x/exp v0.0.0-20231226003508-02704c960a9b gopkg.in/yaml.v3 v3.0.1 ) @@ -19,16 +20,36 @@ require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.2.1 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbles v0.16.1 // indirect + github.com/charmbracelet/bubbletea v0.24.2 // indirect + github.com/charmbracelet/lipgloss v0.11.0 // indirect + github.com/charmbracelet/x/ansi v0.1.1 // indirect + github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect + github.com/cqroot/multichoose v0.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/google/uuid v1.5.0 // indirect github.com/huandu/xstrings v1.4.0 // indirect github.com/imdario/mergo v0.3.16 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/shopspring/decimal v1.3.1 // indirect github.com/spf13/cast v1.6.0 // indirect golang.org/x/crypto v0.17.0 // indirect + golang.org/x/sync v0.5.0 // indirect + golang.org/x/sys v0.19.0 // indirect + golang.org/x/term v0.15.0 // indirect golang.org/x/text v0.14.0 // indirect ) diff --git a/go.sum b/go.sum index 681d985..a1333e2 100644 --- a/go.sum +++ b/go.sum @@ -9,7 +9,25 @@ github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY= +github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= +github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY= +github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg= +github.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g= +github.com/charmbracelet/lipgloss v0.11.0/go.mod h1:1UdRTH9gYgpcdNN5oBtjbu/IzNKtzVtb7sqN1t9LNn8= +github.com/charmbracelet/x/ansi v0.1.1 h1:CGAduulr6egay/YVbGc8Hsu8deMg1xZ/bkaXTPi1JDk= +github.com/charmbracelet/x/ansi v0.1.1/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cqroot/multichoose v0.1.1 h1:diGuKYKea9ePOTwUyUDor9zKRqKFWXGkYGqUa9+firU= +github.com/cqroot/multichoose v0.1.1/go.mod h1:BJzIGqbQZNADPDuA3IzhmTMpRc2F3fZKysMRYP+Ydw8= +github.com/cqroot/prompt v0.9.4 h1:uFRlhXuOP3CSD+Pii0Z8VJhgXpavSloFf7/KAERwjz8= +github.com/cqroot/prompt v0.9.4/go.mod h1:6BVZiEv7XkW1K64y1k2wdzToDwspL3n/RkUIyPjQ808= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -34,14 +52,35 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY= github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -60,8 +99,8 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= @@ -77,15 +116,23 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= +golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= diff --git a/main.go b/main.go index 8010140..d67030e 100755 --- a/main.go +++ b/main.go @@ -9,13 +9,13 @@ code snippets distributed in many repos (like general makefile recipes), leverag management. It also allows configuring a standard set of tools that a dev team can readily invoke by name. -Basics +# Basics This library needs a command entrypoint in a data repository. See https://github.com/davidovich/summon-example-assets. It can be bootstrapped in an empty directory by using: - cd [empty data repo dir] - go run github.com/davidovich/summon/scaffold@latest init [module name] + cd [empty data repo dir] + go run github.com/davidovich/summon/scaffold@latest init [module name] */ package summon @@ -49,7 +49,7 @@ func Main(args []string, fs embed.FS, opts ...option) int { return 1 } - c := make(chan os.Signal) + c := make(chan os.Signal, 1) signal.Notify(c, os.Interrupt, syscall.SIGTERM) go func() { for range c { diff --git a/pkg/config/config.go b/pkg/config/config.go index 6f28273..b8ea4b8 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -52,6 +52,8 @@ type ArgSliceSpec []interface{} // Its Flags can be a one line string flag or a FlagSpec type CmdDesc struct { Cmd ArgSliceSpec `yaml:"cmd"` + // Prompts are used to prompt the user with values. They can be templated. + Prompts string `yaml:"prompts"` // Args contain the args that get appended to the ExecEnvironment Args ArgSliceSpec `yaml:"args"` // SubCmd describes a sub-command of current command @@ -98,11 +100,21 @@ func (e *FlagDesc) UnmarshalYAML(value *yaml.Node) error { switch value.Kind { case yaml.ScalarNode: var args string - value.Decode(&args) + err := value.Decode(&args) + if err != nil { + return &yaml.TypeError{ + Errors: []string{fmt.Sprintf("could not decode flag: %s", err)}, + } + } e.Value = args case yaml.MappingNode: flag := FlagSpec{} - value.Decode(&flag) + err := value.Decode(&flag) + if err != nil { + return &yaml.TypeError{ + Errors: []string{fmt.Sprintf("could not decode flag as mapping: %s", err)}, + } + } e.Value = flag default: return &yaml.TypeError{ diff --git a/pkg/summon/driver.go b/pkg/summon/driver.go index ef88dbf..4d85d02 100644 --- a/pkg/summon/driver.go +++ b/pkg/summon/driver.go @@ -4,7 +4,6 @@ import ( "fmt" "io" "io/fs" - "io/ioutil" "os" "path" "text/template" @@ -35,6 +34,8 @@ type Driver struct { configRead bool flagsToRender []*flagValue cmdToSpec map[*cobra.Command]*commandSpec + prompts map[string]string + prompter Prompter } // New creates the Driver. @@ -43,6 +44,8 @@ func New(filesystem fs.FS, opts ...Option) (*Driver, error) { fs: filesystem, execCommand: command.New, cmdToSpec: map[*cobra.Command]*commandSpec{}, + prompts: map[string]string{}, + prompter: &Prompt{}, } err := fs.WalkDir(d.fs, ".", func(path string, de fs.DirEntry, err error) error { @@ -131,6 +134,11 @@ func (d *Driver) Configure(opts ...Option) error { d.opts.data = map[string]interface{}{} } + // override prompter + if d.opts.prompter != nil { + d.prompter = d.opts.prompter + } + d.opts.data["osArgs"] = os.Args return nil @@ -156,9 +164,9 @@ func (jv *jsonValue) Set(s string) error { var j []byte var err error if s == "-" { - j, err = ioutil.ReadAll(jv.cmd.InOrStdin()) + j, err = io.ReadAll(jv.cmd.InOrStdin()) } else { - j, err = ioutil.ReadFile(s) + j, err = os.ReadFile(s) } if err != nil { return err diff --git a/pkg/summon/interface.go b/pkg/summon/interface.go index 6b1467d..7575123 100644 --- a/pkg/summon/interface.go +++ b/pkg/summon/interface.go @@ -36,3 +36,10 @@ type ConfigurableLister interface { type Lister interface { List(opts ...Option) ([]string, error) } + +// Prompter allows prompting the user. +type Prompter interface { + NewPrompt(userPrompt string) + Choose(choices []string) (string, error) + Input(defaultVal string) (string, error) +} diff --git a/pkg/summon/options.go b/pkg/summon/options.go index 03b8b1c..c59c2b7 100644 --- a/pkg/summon/options.go +++ b/pkg/summon/options.go @@ -22,7 +22,7 @@ type options struct { all bool // where the summoned file will land or stdout if "-" destination string - // single file to instanciate + // single file to instantiate filename string // show tree of files tree bool @@ -50,6 +50,8 @@ type options struct { dryrun bool // execCommand overrides the command used to run external processes execCommand command.ExecCommandFn + //prompter + prompter Prompter } type helpInfo struct { @@ -188,3 +190,11 @@ func (o *options) DefaultsFrom(conf config.Config) { o.destination = conf.OutputDir } } + +// WithPrompter configures the prompter. This is mostly used in testing. +func WithPrompter(p Prompter) Option { + return func(opts *options) error { + opts.prompter = p + return nil + } +} diff --git a/pkg/summon/run.go b/pkg/summon/run.go index fcad5d4..a3f6bdd 100644 --- a/pkg/summon/run.go +++ b/pkg/summon/run.go @@ -20,6 +20,8 @@ import ( type commandSpec struct { // command is the caller environment (docker, bash, python) command config.ArgSliceSpec + // Inputs are used to prompt the user with values. They can be templated. + prompts string // args is the command and args that get appended to the ExecEnvironment args config.ArgSliceSpec // subCmd sub-command of current command @@ -80,6 +82,11 @@ func (d *Driver) buildCmdArgs() ([]string, error) { return nil, fmt.Errorf("could not find exec handle reference '%s' in config %s", ref, config.ConfigFileName) } + _, err := d.renderTemplate(cmdSpec.prompts) + if err != nil { + return nil, fmt.Errorf("could not get all prompts for exec handle '%s' in config %s, error: %s", ref, config.ConfigFileName, err) + } + execEnv, err := d.RenderArgs(FlattenStrings(cmdSpec.command)...) if err != nil { return nil, err @@ -144,11 +151,7 @@ func (d *Driver) buildCmdArgs() ([]string, error) { func (d *Driver) getCmdSpec() (*commandSpec, string) { var cmdSpec *commandSpec var ref string - if d.opts.cobraCmd != nil { - cmdSpec = d.cmdToSpec[d.opts.cobraCmd] - ref = d.opts.cobraCmd.Name() - } else { - + if d.opts.cobraCmd == nil { for cmd, spec := range d.cmdToSpec { if cmd.Name() == d.opts.ref { cmdSpec = spec @@ -156,6 +159,9 @@ func (d *Driver) getCmdSpec() (*commandSpec, string) { break } } + } else { + cmdSpec = d.cmdToSpec[d.opts.cobraCmd] + ref = d.opts.cobraCmd.Name() } return cmdSpec, ref } @@ -216,6 +222,7 @@ func normalizeExecDesc(argsDesc interface{}) (*commandSpec, error) { case config.CmdDesc: c.command = descType.Cmd c.args = descType.Args + c.prompts = descType.Prompts c.help = descType.Help c.completion = descType.Completion c.hidden = descType.Hidden @@ -229,7 +236,7 @@ func normalizeExecDesc(argsDesc interface{}) (*commandSpec, error) { if err != nil { return nil, err } - // inherit command if not set explicitely + // inherit command if not set explicitly if subCmd.command == nil { subCmd.command = c.command } diff --git a/pkg/summon/summon.go b/pkg/summon/summon.go index c1711f7..2f005a2 100644 --- a/pkg/summon/summon.go +++ b/pkg/summon/summon.go @@ -13,11 +13,7 @@ import ( "os" "path" "path/filepath" - "reflect" - "strings" - "text/template" - "github.com/Masterminds/sprig/v3" "github.com/spf13/afero" "github.com/davidovich/summon/internal/testutil" @@ -101,50 +97,6 @@ func makeCopyFileFun(startdir string, d *Driver) func(path string, de fs.DirEntr } } -func (d *Driver) prepareTemplate() (*template.Template, error) { - t := d.templateCtx - var err error - if t != nil { - t, err = t.Clone() - if err != nil { - return nil, err - } - } else { - t = template.New(Name) - } - - t.Option("missingkey=zero"). - Funcs(sprig.TxtFuncMap()). - Funcs(summonFuncMap(d)) - - return t, nil -} - -func executeTemplate(t *template.Template, data interface{}) (string, error) { - buf := &bytes.Buffer{} - err := t.Execute(buf, data) - - // The zero value for an interface is a nil interface{} which - // has a string representation of . Strip this out. - // https://github.com/golang/go/issues/24963 - return strings.ReplaceAll(buf.String(), "", ""), err -} - -func (d *Driver) renderTemplate(tmpl string) (string, error) { - t, err := d.prepareTemplate() - if err != nil { - return tmpl, err - } - - t, err = t.Parse(tmpl) - if err != nil { - return tmpl, err - } - - data := d.opts.data - return executeTemplate(t, data) -} - func (d *Driver) resolveAlias(alias string) string { if resolved, ok := d.config.Aliases[alias]; ok { return resolved @@ -153,93 +105,6 @@ func (d *Driver) resolveAlias(alias string) string { return alias } -func summonFuncMap(d *Driver) template.FuncMap { - initConsumed := func() { - if d.opts.argsConsumed == nil { - d.opts.argsConsumed = make(map[int]struct{}, len(d.opts.args)) - } - } - consumeAllArgs := func() { - initConsumed() - for i := range d.opts.args { - d.opts.argsConsumed[i] = struct{}{} - } - } - return template.FuncMap{ - "run": func(args ...string) (string, error) { - driverCopy := Driver{ - opts: d.opts, - config: d.config, - fs: d.fs, - baseDataDir: d.baseDataDir, - templateCtx: d.templateCtx, - execCommand: d.execCommand, - configRead: d.configRead, - cmdToSpec: d.cmdToSpec, - } - driverCopy.opts.argsConsumed = map[int]struct{}{} - driverCopy.opts.cobraCmd = nil - driverCopy.opts.helpWanted.helpFlag = "" - - b := &strings.Builder{} - err := driverCopy.Run(Ref(args[0]), Args(args[1:]...), Out(b)) - - if d.opts.dryrun { - b.WriteString("[") - b.WriteString(args[0]) - b.WriteString(" (dry-run)]") - } - - if d.opts.debug { - fmt.Fprintf(os.Stderr, "Output [%s] -> `%s`...\n", args[0], b) - } - return strings.TrimSpace(b.String()), err - }, - "summon": func(path string, arg ...any) (string, error) { - dest := os.TempDir() - if len(arg) > 0 { - if reflect.TypeOf(arg[0]).Kind() == reflect.String { - dest = arg[0].(string) - } - } - return d.Summon(Filename(path), Dest(dest)) - }, - "flagValue": func(flag string) (string, error) { - for _, toRender := range d.flagsToRender { - if toRender.name == flag { - toRender.explicit = true - return toRender.renderTemplate() - } - } - return "", nil - }, - "arg": func(index int, missingErrors ...string) (string, error) { - missingError := strings.Join(missingErrors, " ") - if d.opts.args == nil { - return "", fmt.Errorf(missingError) - } - if index >= len(d.opts.args) { - return "", fmt.Errorf("%s: index %v out of range, args: %s", missingError, index, d.opts.args) - } - - retrieved := d.opts.args[index] - initConsumed() - d.opts.argsConsumed[index] = struct{}{} - return retrieved, nil - }, - "args": func() []string { - consumeAllArgs() - - return d.opts.args - }, - "swallowargs": func() string { - consumeAllArgs() - - return "" - }, - } -} - func (d *Driver) copyOneFile(embeddedFile fs.File, filename, root string) (string, error) { destination := d.opts.destination diff --git a/pkg/summon/template.go b/pkg/summon/template.go new file mode 100644 index 0000000..a8c2fe8 --- /dev/null +++ b/pkg/summon/template.go @@ -0,0 +1,211 @@ +package summon + +import ( + "bytes" + "fmt" + "os" + "reflect" + "strings" + "text/template" + + "github.com/Masterminds/sprig/v3" + "github.com/cqroot/prompt" +) + +func (d *Driver) prepareTemplate() (*template.Template, error) { + t := d.templateCtx + + if t == nil { + t = template.New(Name) + } else { + var err error + t, err = t.Clone() + if err != nil { + return nil, err + } + } + + t.Option("missingkey=zero"). + Funcs(sprig.TxtFuncMap()). + Funcs(summonFuncMap(d)) + + return t, nil +} + +func executeTemplate(t *template.Template, data interface{}) (string, error) { + buf := &bytes.Buffer{} + err := t.Execute(buf, data) + + // The zero value for an interface is a nil interface{} which + // has a string representation of . Strip this out. + // https://github.com/golang/go/issues/24963 + return strings.ReplaceAll(buf.String(), "", ""), err +} + +func (d *Driver) renderTemplate(tmpl string) (string, error) { + t, err := d.prepareTemplate() + if err != nil { + return tmpl, err + } + + t, err = t.Parse(tmpl) + if err != nil { + return tmpl, err + } + + data := d.opts.data + return executeTemplate(t, data) +} + +func summonFuncMap(d *Driver) template.FuncMap { + initConsumed := func() { + if d.opts.argsConsumed == nil { + d.opts.argsConsumed = make(map[int]struct{}, len(d.opts.args)) + } + } + consumeAllArgs := func() { + initConsumed() + for i := range d.opts.args { + d.opts.argsConsumed[i] = struct{}{} + } + } + return template.FuncMap{ + "run": func(args ...string) (string, error) { + driverCopy := Driver{ + opts: d.opts, + config: d.config, + fs: d.fs, + baseDataDir: d.baseDataDir, + templateCtx: d.templateCtx, + execCommand: d.execCommand, + configRead: d.configRead, + cmdToSpec: d.cmdToSpec, + prompts: d.prompts, + prompter: d.prompter, + } + driverCopy.opts.argsConsumed = map[int]struct{}{} + driverCopy.opts.cobraCmd = nil + driverCopy.opts.helpWanted.helpFlag = "" + + b := &strings.Builder{} + err := driverCopy.Run(Ref(args[0]), Args(args[1:]...), Out(b)) + + if d.opts.dryrun { + b.WriteString("[") + b.WriteString(args[0]) + b.WriteString(" (dry-run)]") + } + + if d.opts.debug { + fmt.Fprintf(os.Stderr, "Output [%s] -> `%s`...\n", args[0], b) + } + return strings.TrimSpace(b.String()), err + }, + "summon": func(path string, arg ...any) (string, error) { + dest := os.TempDir() + if len(arg) > 0 { + if reflect.TypeOf(arg[0]).Kind() == reflect.String { + dest = arg[0].(string) + } + } + return d.Summon(Filename(path), Dest(dest)) + }, + "flagValue": func(flag string) (string, error) { + for _, toRender := range d.flagsToRender { + if toRender.name == flag { + toRender.explicit = true + return toRender.renderTemplate() + } + } + return "", nil + }, + "arg": func(index int, missingErrors ...string) (string, error) { + missingError := strings.Join(missingErrors, " ") + if d.opts.args == nil { + return "", fmt.Errorf(missingError) + } + if index >= len(d.opts.args) { + return "", fmt.Errorf("%s: index %v out of range, args: %s", missingError, index, d.opts.args) + } + + retrieved := d.opts.args[index] + initConsumed() + d.opts.argsConsumed[index] = struct{}{} + return retrieved, nil + }, + "args": func() []string { + consumeAllArgs() + + return d.opts.args + }, + "swallowargs": func() string { + consumeAllArgs() + + return "" + }, + "prompt": func(slot, ask string, params any) (result string, err error) { + defaultValue := "" + selectors := []string{} + + switch t := params.(type) { + case string: + defaultValue = t + case []any: + for _, e := range t { + selectors = append(selectors, e.(string)) + } + default: + return "", fmt.Errorf("last parameter should be a default value or a list of choices") + } + + d.prompter.NewPrompt(ask) + + if len(selectors) != 0 { + result, err = d.prompter.Choose(selectors) + } else { + result, err = d.prompter.Input(defaultValue) + } + + if err != nil { + return "", err + } + + // record result for future use + d.prompts[slot] = result + return result, nil + }, + "promptValue": func(slot string) (string, error) { + p, ok := d.prompts[slot] + if !ok { + return "", fmt.Errorf("no previous prompts were filled for slot '%s'", slot) + } + return p, nil + }, + } +} + +type Prompt struct { + pr *prompt.Prompt + promptStr string + selectors []string + defaultVal string +} + +func (p *Prompt) NewPrompt(userPrompt string) { + p.pr = prompt.New().Ask(userPrompt) +} + +func (p *Prompt) Choose(choices []string) (string, error) { + if p.pr == nil { + return "", fmt.Errorf("prompter is not initialized") + } + + return p.pr.Choose(choices) +} + +func (p *Prompt) Input(defaultVal string) (string, error) { + if p.pr == nil { + return "", fmt.Errorf("prompter is not initialized") + } + return p.pr.Input(defaultVal) +} diff --git a/pkg/summon/template_test.go b/pkg/summon/template_test.go new file mode 100644 index 0000000..098a499 --- /dev/null +++ b/pkg/summon/template_test.go @@ -0,0 +1,62 @@ +package summon + +import ( + "bytes" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" +) + +type testPrompter struct { + b *bytes.Buffer + choice int + userInput string +} + +func (tpr *testPrompter) NewPrompt(userPrompt string) { + tpr.b = bytes.NewBufferString(userPrompt) +} + +func (tpr *testPrompter) Choose(choices []string) (string, error) { + return choices[tpr.choice], nil +} + +func (tpr *testPrompter) Input(defaultVal string) (string, error) { + if tpr.userInput == "" { + return defaultVal, nil + } + return tpr.userInput, nil +} + +func TestPrompt(t *testing.T) { + testFs := fstest.MapFS{} + testFs["text.txt"] = &fstest.MapFile{Data: []byte("this is a text")} + + tpr := &testPrompter{ + choice: 1, + } + // create a summoner to summon text.txt at + s, err := New(testFs, WithPrompter(tpr)) + assert.NoError(t, err) + + ret, err := s.renderTemplate(`{{ prompt "slotA" "What is your name" "David" }}`) + assert.NoError(t, err) + + // check default value + assert.Equal(t, "What is your name", tpr.b.String()) + assert.Equal(t, "David", ret) + + promptVal, err := s.renderTemplate(`{{ promptValue "slotA" }}`) + assert.NoError(t, err) + assert.Equal(t, "David", promptVal) + + // test selection (choice is B by the testPrompter config) + ret, err = s.renderTemplate(`{{ prompt "continue" "Select One" (list "A" "B" "C") }}`) + assert.NoError(t, err) + + assert.Equal(t, "B", ret) + promptVal, err = s.renderTemplate(`{{ promptValue "continue" }}`) + assert.NoError(t, err) + assert.Equal(t, "B", promptVal) +}