diff --git a/completion/fish/task.fish b/completion/fish/task.fish index b2b9b045cb..b7066bb999 100644 --- a/completion/fish/task.fish +++ b/completion/fish/task.fish @@ -86,6 +86,7 @@ complete -c $GO_TASK_PROGNAME -s j -l json -d 'format task complete -c $GO_TASK_PROGNAME -s l -l list -d 'list tasks with descriptions' complete -c $GO_TASK_PROGNAME -l nested -d 'nest namespaces when listing as JSON' complete -c $GO_TASK_PROGNAME -l no-status -d 'ignore status when listing as JSON' +complete -c $GO_TASK_PROGNAME -l interactive -d 'prompt for missing required variables' complete -c $GO_TASK_PROGNAME -s o -l output -d 'set output style' -xa "interleaved group prefixed" complete -c $GO_TASK_PROGNAME -l output-group-begin -d 'message template before grouped output' complete -c $GO_TASK_PROGNAME -l output-group-end -d 'message template after grouped output' diff --git a/completion/ps/task.ps1 b/completion/ps/task.ps1 index 941c1d48c4..cb4ff7eb33 100644 --- a/completion/ps/task.ps1 +++ b/completion/ps/task.ps1 @@ -40,6 +40,7 @@ Register-ArgumentCompleter -CommandName task -ScriptBlock { [CompletionResult]::new('--list', '--list', [CompletionResultType]::ParameterName, 'list tasks'), [CompletionResult]::new('--nested', '--nested', [CompletionResultType]::ParameterName, 'nest namespaces in JSON'), [CompletionResult]::new('--no-status', '--no-status', [CompletionResultType]::ParameterName, 'ignore status in JSON'), + [CompletionResult]::new('--interactive', '--interactive', [CompletionResultType]::ParameterName, 'prompt for missing required variables'), [CompletionResult]::new('-o', '-o', [CompletionResultType]::ParameterName, 'set output style'), [CompletionResult]::new('--output', '--output', [CompletionResultType]::ParameterName, 'set output style'), [CompletionResult]::new('--output-group-begin', '--output-group-begin', [CompletionResultType]::ParameterName, 'template before group'), diff --git a/completion/zsh/_task b/completion/zsh/_task index 85a5dd1e67..6de70e6f08 100755 --- a/completion/zsh/_task +++ b/completion/zsh/_task @@ -71,6 +71,7 @@ _task() { '(-j --json)'{-j,--json}'[format task list as JSON]' '(--nested)--nested[nest namespaces when listing as JSON]' '(--no-status)--no-status[ignore status when listing as JSON]' + '(--interactive)--interactive[prompt for missing required variables]' '(-o --output)'{-o,--output}'[set output style]:style:(interleaved group prefixed)' '(--output-group-begin)--output-group-begin[message template before grouped output]:template text: ' '(--output-group-end)--output-group-end[message template after grouped output]:template text: ' diff --git a/executor.go b/executor.go index 3ca5fbcf66..38eb0d4c0c 100644 --- a/executor.go +++ b/executor.go @@ -43,6 +43,7 @@ type ( DisableFuzzy bool AssumeYes bool AssumeTerm bool // Used for testing + Interactive bool Dry bool Summary bool Parallel bool @@ -69,6 +70,7 @@ type ( fuzzyModel *fuzzy.Model fuzzyModelOnce sync.Once + promptedVars *ast.Vars // vars collected via interactive prompts concurrencySemaphore chan struct{} taskCallCount map[string]*int32 mkdirMutexMap map[string]*sync.Mutex @@ -353,6 +355,19 @@ func (o *assumeTermOption) ApplyToExecutor(e *Executor) { e.AssumeTerm = o.assumeTerm } +// WithInteractive tells the [Executor] to prompt for missing required variables. +func WithInteractive(interactive bool) ExecutorOption { + return &interactiveOption{interactive} +} + +type interactiveOption struct { + interactive bool +} + +func (o *interactiveOption) ApplyToExecutor(e *Executor) { + e.Interactive = o.interactive +} + // WithDry tells the [Executor] to output the commands that would be run without // actually running them. func WithDry(dry bool) ExecutorOption { diff --git a/go.mod b/go.mod index 6aa7cd9b3f..db6bcc4cf6 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,9 @@ require ( github.com/Masterminds/semver/v3 v3.4.0 github.com/alecthomas/chroma/v2 v2.20.0 github.com/chainguard-dev/git-urls v1.0.2 + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/davecgh/go-spew v1.1.1 github.com/dominikbraun/graph v0.23.0 github.com/elliotchance/orderedmap/v3 v3.1.0 @@ -36,11 +39,18 @@ require ( dario.cat/mergo v1.0.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect @@ -48,20 +58,29 @@ require ( github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/klauspost/pgzip v1.2.6 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // 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.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/u-root/u-root v0.15.1-0.20251014130006-62f7144b33da // indirect github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/crypto v0.45.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 219ab458d0..3cfde69684 100644 --- a/go.sum +++ b/go.sum @@ -19,8 +19,26 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +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/chainguard-dev/git-urls v1.0.2 h1:pSpT7ifrpc5X55n4aTTm7FFUE+ZQHKiqpiwNkJrVcKQ= github.com/chainguard-dev/git-urls v1.0.2/go.mod h1:rbGgj10OS7UgZlbzdUQIQpT0k/D4+An04HJY7Ol+Y/o= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= @@ -42,6 +60,8 @@ github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -50,8 +70,6 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= -github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM= github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= @@ -91,13 +109,25 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +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-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 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.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +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/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= @@ -109,10 +139,11 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/puzpuzpuz/xsync/v4 v4.1.0 h1:x9eHRl4QhZFIPJ17yl4KKW9xLyVWbb3/Yq4SXpjF71U= -github.com/puzpuzpuz/xsync/v4 v4.1.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo= github.com/puzpuzpuz/xsync/v4 v4.2.0 h1:dlxm77dZj2c3rxq0/XNvvUKISAmovoXF4a4qM6Wvkr0= github.com/puzpuzpuz/xsync/v4 v4.2.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo= +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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sajari/fuzzy v1.0.0 h1:+FmwVvJErsd0d0hAPlj4CxqxUtQY/fOoY0DwX4ykpRY= @@ -141,6 +172,8 @@ github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8 github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= @@ -162,6 +195,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/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-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/flags/flags.go b/internal/flags/flags.go index eb5930dc10..2395e8cc34 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -79,6 +79,7 @@ var ( ClearCache bool Timeout time.Duration CacheExpiryDuration time.Duration + Interactive bool ) func init() { @@ -128,6 +129,7 @@ func init() { pflag.BoolVarP(&Silent, "silent", "s", false, "Disables echoing.") pflag.BoolVar(&DisableFuzzy, "disable-fuzzy", getConfig(config, func() *bool { return config.DisableFuzzy }, false), "Disables fuzzy matching for task names.") pflag.BoolVarP(&AssumeYes, "yes", "y", false, "Assume \"yes\" as answer to all prompts.") + pflag.BoolVar(&Interactive, "interactive", getConfig(config, func() *bool { return config.Interactive }, false), "Prompt for missing required variables.") pflag.BoolVarP(&Parallel, "parallel", "p", false, "Executes tasks provided on command line in parallel.") pflag.BoolVarP(&Dry, "dry", "n", false, "Compiles and prints tasks in the order that they would be run, without executing them.") pflag.BoolVar(&Summary, "summary", false, "Show summary about a task.") @@ -252,6 +254,7 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) { task.WithSilent(Silent), task.WithDisableFuzzy(DisableFuzzy), task.WithAssumeYes(AssumeYes), + task.WithInteractive(Interactive), task.WithDry(Dry || Status), task.WithSummary(Summary), task.WithParallel(Parallel), diff --git a/internal/input/input.go b/internal/input/input.go new file mode 100644 index 0000000000..d48b5817a7 --- /dev/null +++ b/internal/input/input.go @@ -0,0 +1,223 @@ +package input + +import ( + "fmt" + "io" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/go-task/task/v3/errors" +) + +var ErrCancelled = errors.New("prompt cancelled") + +var ( + promptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) // cyan bold + cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) // cyan bold + selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Bold(true) // green bold + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray +) + +// Prompter handles interactive variable prompting +type Prompter struct { + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +// Text prompts the user for a text value +func (p *Prompter) Text(varName string) (string, error) { + m := newTextModel(varName) + + prog := tea.NewProgram(m, + tea.WithInput(p.Stdin), + tea.WithOutput(p.Stderr), + ) + + result, err := prog.Run() + if err != nil { + return "", err + } + + model := result.(textModel) + if model.cancelled { + return "", ErrCancelled + } + + return model.value, nil +} + +// Select prompts the user to select from a list of options +func (p *Prompter) Select(varName string, options []string) (string, error) { + if len(options) == 0 { + return "", errors.New("no options provided") + } + + m := newSelectModel(varName, options) + + prog := tea.NewProgram(m, + tea.WithInput(p.Stdin), + tea.WithOutput(p.Stderr), + ) + + result, err := prog.Run() + if err != nil { + return "", err + } + + model := result.(selectModel) + if model.cancelled { + return "", ErrCancelled + } + + return model.options[model.cursor], nil +} + +// Prompt prompts for a variable value, using Select if enum is provided, Text otherwise +func (p *Prompter) Prompt(varName string, enum []string) (string, error) { + if len(enum) > 0 { + return p.Select(varName, enum) + } + return p.Text(varName) +} + +// textModel is the Bubble Tea model for text input +type textModel struct { + varName string + textInput textinput.Model + value string + cancelled bool + done bool +} + +func newTextModel(varName string) textModel { + ti := textinput.New() + ti.Placeholder = "" + ti.Focus() + ti.CharLimit = 256 + ti.Width = 40 + + return textModel{ + varName: varName, + textInput: ti, + } +} + +func (m textModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m textModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + m.cancelled = true + m.done = true + return m, tea.Quit + case tea.KeyEnter: + m.value = m.textInput.Value() + m.done = true + return m, tea.Quit + } + } + + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd +} + +func (m textModel) View() string { + if m.done { + return "" + } + + prompt := promptStyle.Render(fmt.Sprintf("? Enter value for %s: ", m.varName)) + return prompt + m.textInput.View() + "\n" +} + +// selectModel is the Bubble Tea model for selection +type selectModel struct { + varName string + options []string + cursor int + cancelled bool + done bool +} + +func newSelectModel(varName string, options []string) selectModel { + return selectModel{ + varName: varName, + options: options, + cursor: 0, + } +} + +func (m selectModel) Init() tea.Cmd { + return nil +} + +func (m selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + m.cancelled = true + m.done = true + return m, tea.Quit + case tea.KeyUp, tea.KeyShiftTab: + if m.cursor > 0 { + m.cursor-- + } + case tea.KeyDown, tea.KeyTab: + if m.cursor < len(m.options)-1 { + m.cursor++ + } + case tea.KeyEnter: + m.done = true + return m, tea.Quit + } + + // Also handle j/k for vim users + switch msg.String() { + case "k": + if m.cursor > 0 { + m.cursor-- + } + case "j": + if m.cursor < len(m.options)-1 { + m.cursor++ + } + } + } + + return m, nil +} + +func (m selectModel) View() string { + if m.done { + return "" + } + + var b strings.Builder + + b.WriteString(promptStyle.Render(fmt.Sprintf("? Select value for %s:", m.varName))) + b.WriteString("\n") + + for i, opt := range m.options { + if i == m.cursor { + b.WriteString(cursorStyle.Render("❯ ")) + b.WriteString(selectedStyle.Render(opt)) + } else { + b.WriteString(" " + opt) + } + b.WriteString("\n") + } + + b.WriteString(dimStyle.Render(" (↑/↓ to move, enter to select, esc to cancel)")) + + return b.String() +} diff --git a/requires.go b/requires.go index 119b073ba2..7babdb4d13 100644 --- a/requires.go +++ b/requires.go @@ -4,35 +4,180 @@ import ( "slices" "github.com/go-task/task/v3/errors" + "github.com/go-task/task/v3/internal/input" + "github.com/go-task/task/v3/internal/term" "github.com/go-task/task/v3/taskfile/ast" ) -func (e *Executor) areTaskRequiredVarsSet(t *ast.Task) error { - if t.Requires == nil || len(t.Requires.Vars) == 0 { +func (e *Executor) canPrompt() bool { + return e.Interactive && (e.AssumeTerm || term.IsTerminal()) +} + +func (e *Executor) newPrompter() *input.Prompter { + return &input.Prompter{ + Stdin: e.Stdin, + Stdout: e.Stdout, + Stderr: e.Stderr, + } +} + +// promptDepsVars traverses the dependency tree, collects all missing required +// variables, and prompts for them upfront. This is used for deps which execute +// in parallel, so all prompts must happen before execution to avoid interleaving. +// Prompted values are stored in e.promptedVars for injection into task calls. +func (e *Executor) promptDepsVars(calls []*Call) error { + if !e.canPrompt() { return nil } - var missingVars []errors.MissingVar - for _, requiredVar := range t.Requires.Vars { - _, ok := t.Vars.Get(requiredVar.Name) - if !ok { - missingVars = append(missingVars, errors.MissingVar{ - Name: requiredVar.Name, - AllowedValues: requiredVar.Enum, - }) + // Collect all missing vars from the dependency tree + visited := make(map[string]bool) + varsMap := make(map[string]*ast.VarsWithValidation) + + var collect func(call *Call) error + collect = func(call *Call) error { + compiledTask, err := e.FastCompiledTask(call) + if err != nil { + return err + } + + for _, v := range getMissingRequiredVars(compiledTask) { + if _, exists := varsMap[v.Name]; !exists { + varsMap[v.Name] = v + } + } + + // Check visited AFTER collecting vars to handle duplicate task calls with different vars + if visited[call.Task] { + return nil + } + visited[call.Task] = true + + for _, dep := range compiledTask.Deps { + depCall := &Call{ + Task: dep.Task, + Vars: dep.Vars, + Silent: dep.Silent, + } + if err := collect(depCall); err != nil { + return err + } } + + return nil } - if len(missingVars) > 0 { - return &errors.TaskMissingRequiredVarsError{ - TaskName: t.Name(), - MissingVars: missingVars, + for _, call := range calls { + if err := collect(call); err != nil { + return err } } + if len(varsMap) == 0 { + return nil + } + + prompter := e.newPrompter() + e.promptedVars = ast.NewVars() + + for _, v := range varsMap { + value, err := prompter.Prompt(v.Name, v.Enum) + if err != nil { + if errors.Is(err, input.ErrCancelled) { + return &errors.TaskCancelledByUserError{TaskName: "interactive prompt"} + } + return err + } + e.promptedVars.Set(v.Name, ast.Var{Value: value}) + } + return nil } +// promptTaskVars prompts for any missing required vars from a single task. +// Used for sequential task calls (cmds) where we can prompt just-in-time. +// Returns true if any vars were prompted (caller should recompile the task). +func (e *Executor) promptTaskVars(t *ast.Task, call *Call) (bool, error) { + if !e.canPrompt() || t.Requires == nil || len(t.Requires.Vars) == 0 { + return false, nil + } + + // Find missing vars, excluding already prompted ones + var missing []*ast.VarsWithValidation + for _, v := range getMissingRequiredVars(t) { + if e.promptedVars != nil { + if _, ok := e.promptedVars.Get(v.Name); ok { + continue + } + } + missing = append(missing, v) + } + + if len(missing) == 0 { + return false, nil + } + + prompter := e.newPrompter() + + for _, v := range missing { + value, err := prompter.Prompt(v.Name, v.Enum) + if err != nil { + if errors.Is(err, input.ErrCancelled) { + return false, &errors.TaskCancelledByUserError{TaskName: t.Name()} + } + return false, err + } + + // Add to call.Vars for recompilation + if call.Vars == nil { + call.Vars = ast.NewVars() + } + call.Vars.Set(v.Name, ast.Var{Value: value}) + + // Cache for reuse by other tasks + if e.promptedVars == nil { + e.promptedVars = ast.NewVars() + } + e.promptedVars.Set(v.Name, ast.Var{Value: value}) + } + + return true, nil +} + +// getMissingRequiredVars returns required vars that are not set in the task's vars. +func getMissingRequiredVars(t *ast.Task) []*ast.VarsWithValidation { + if t.Requires == nil { + return nil + } + var missing []*ast.VarsWithValidation + for _, v := range t.Requires.Vars { + if _, ok := t.Vars.Get(v.Name); !ok { + missing = append(missing, v) + } + } + return missing +} + +func (e *Executor) areTaskRequiredVarsSet(t *ast.Task) error { + missing := getMissingRequiredVars(t) + if len(missing) == 0 { + return nil + } + + missingVars := make([]errors.MissingVar, len(missing)) + for i, v := range missing { + missingVars[i] = errors.MissingVar{ + Name: v.Name, + AllowedValues: v.Enum, + } + } + + return &errors.TaskMissingRequiredVarsError{ + TaskName: t.Name(), + MissingVars: missingVars, + } +} + func (e *Executor) areTaskRequiredVarsAllowedValuesSet(t *ast.Task) error { if t.Requires == nil || len(t.Requires.Vars) == 0 { return nil @@ -50,7 +195,6 @@ func (e *Executor) areTaskRequiredVarsAllowedValuesSet(t *ast.Task) error { Name: requiredVar.Name, }) } - } if len(notAllowedValuesVars) > 0 { diff --git a/task.go b/task.go index 489ef7e5dd..9eb9d72100 100644 --- a/task.go +++ b/task.go @@ -73,6 +73,11 @@ func (e *Executor) Run(ctx context.Context, calls ...*Call) error { return nil } + // Prompt for all required vars from deps upfront (parallel execution) + if err := e.promptDepsVars(calls); err != nil { + return err + } + regularCalls, watchCalls, err := e.splitRegularAndWatchCalls(calls...) if err != nil { return err @@ -120,6 +125,19 @@ func (e *Executor) splitRegularAndWatchCalls(calls ...*Call) (regularCalls []*Ca // RunTask runs a task by its name func (e *Executor) RunTask(ctx context.Context, call *Call) error { + // Inject prompted vars into call if available + if e.promptedVars != nil { + if call.Vars == nil { + call.Vars = ast.NewVars() + } + for name, v := range e.promptedVars.All() { + // Only inject if not already set in call + if _, ok := call.Vars.Get(name); !ok { + call.Vars.Set(name, v) + } + } + } + t, err := e.FastCompiledTask(call) if err != nil { return err @@ -129,6 +147,19 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error { return nil } + // Prompt for missing required vars (just-in-time for sequential task calls) + prompted, err := e.promptTaskVars(t, call) + if err != nil { + return err + } + if prompted { + // Recompile with the new vars + t, err = e.FastCompiledTask(call) + if err != nil { + return err + } + } + if err := e.areTaskRequiredVarsSet(t); err != nil { return err } diff --git a/taskrc/ast/taskrc.go b/taskrc/ast/taskrc.go index 8952315d62..4d65b1efd4 100644 --- a/taskrc/ast/taskrc.go +++ b/taskrc/ast/taskrc.go @@ -14,6 +14,7 @@ type TaskRC struct { Verbose *bool `yaml:"verbose"` DisableFuzzy *bool `yaml:"disable-fuzzy"` Concurrency *int `yaml:"concurrency"` + Interactive *bool `yaml:"interactive"` Remote Remote `yaml:"remote"` Failfast bool `yaml:"failfast"` Experiments map[string]int `yaml:"experiments"` @@ -56,5 +57,6 @@ func (t *TaskRC) Merge(other *TaskRC) { t.Verbose = cmp.Or(other.Verbose, t.Verbose) t.DisableFuzzy = cmp.Or(other.DisableFuzzy, t.DisableFuzzy) t.Concurrency = cmp.Or(other.Concurrency, t.Concurrency) + t.Interactive = cmp.Or(other.Interactive, t.Interactive) t.Failfast = cmp.Or(other.Failfast, t.Failfast) } diff --git a/testdata/interactive_vars/.taskrc.yml b/testdata/interactive_vars/.taskrc.yml new file mode 100644 index 0000000000..9f4af05635 --- /dev/null +++ b/testdata/interactive_vars/.taskrc.yml @@ -0,0 +1 @@ +interactive: true diff --git a/testdata/interactive_vars/Taskfile.yml b/testdata/interactive_vars/Taskfile.yml new file mode 100644 index 0000000000..49f68a2fa1 --- /dev/null +++ b/testdata/interactive_vars/Taskfile.yml @@ -0,0 +1,108 @@ +version: '3' + +tasks: + # Simple text input prompt + greet: + desc: Greet someone by name + requires: + vars: + - NAME + cmds: + - echo "Hello, {{.NAME}}!" + + # Enum selection (dropdown menu) + deploy: + desc: Deploy to an environment + requires: + vars: + - name: ENVIRONMENT + enum: [dev, staging, prod] + cmds: + - echo "Deploying to {{.ENVIRONMENT}}..." + + # Multiple variables at once + release: + desc: Create a release with version and environment + requires: + vars: + - VERSION + - name: ENVIRONMENT + enum: [dev, staging, prod] + cmds: + - echo "Releasing {{.VERSION}} to {{.ENVIRONMENT}}" + + # Nested dependencies - all prompts happen upfront + full-deploy: + desc: Full deployment pipeline with nested deps + deps: + - task: build + - task: test + cmds: + - task: deploy + + build: + requires: + vars: + - name: BUILD_MODE + enum: [debug, release] + cmds: + - echo "Building in {{.BUILD_MODE}} mode..." + + test: + requires: + vars: + - name: TEST_SUITE + enum: [unit, integration, e2e, all] + cmds: + - echo "Running {{.TEST_SUITE}} tests..." + + # Variable already set - no prompt shown + greet-world: + desc: Greet the world (no prompt needed) + vars: + NAME: World + requires: + vars: + - NAME + cmds: + - echo "Hello, {{.NAME}}!" + + # Complex scenario with multiple levels + pipeline: + desc: Run the full CI/CD pipeline + cmds: + - task: setup + - task: build + - task: test + - task: deploy + + setup: + requires: + vars: + - PROJECT_NAME + cmds: + - echo "Setting up project {{.PROJECT_NAME}}..." + + # Docker example with multiple selections + docker-build: + desc: Build a Docker image + requires: + vars: + - IMAGE_NAME + - IMAGE_TAG + - name: PLATFORM + enum: [linux/amd64, linux/arm64, linux/arm/v7] + cmds: + - echo "Building {{.IMAGE_NAME}}:{{.IMAGE_TAG}} for {{.PLATFORM}}" + + # Database migration example + db-migrate: + desc: Run database migrations + requires: + vars: + - name: DIRECTION + enum: [up, down] + - name: DATABASE + enum: [postgres, mysql, sqlite] + cmds: + - echo "Running {{.DIRECTION}} migrations on {{.DATABASE}}" diff --git a/website/src/docs/guide.md b/website/src/docs/guide.md index ef59375cb0..94a8b4a6cd 100644 --- a/website/src/docs/guide.md +++ b/website/src/docs/guide.md @@ -1127,6 +1127,65 @@ This is supported only for string variables. ::: +### Prompting for missing variables interactively + +If you want Task to prompt users for missing required variables instead of +failing, you can enable interactive mode in your `.taskrc.yml`: + +```yaml +# ~/.taskrc.yml +interactive: true +``` + +When enabled, Task will display an interactive prompt for any missing required +variable. For variables with an `enum`, a selection menu is shown. For variables +without an enum, a text input is displayed. + +```yaml +# Taskfile.yml +version: '3' + +tasks: + deploy: + requires: + vars: + - name: ENVIRONMENT + enum: [dev, staging, prod] + - VERSION + cmds: + - echo "Deploying {{.VERSION}} to {{.ENVIRONMENT}}" +``` + +```shell +$ task deploy +? Select value for ENVIRONMENT: +❯ dev + staging + prod +? Enter value for VERSION: 1.0.0 +Deploying 1.0.0 to prod +``` + +If the variable is already set (via CLI, environment, or Taskfile), no prompt +is shown: + +```shell +$ task deploy ENVIRONMENT=prod VERSION=1.0.0 +Deploying 1.0.0 to prod +``` + +::: info + +Interactive prompts require a TTY (terminal). Task automatically detects +non-interactive environments like GitHub Actions, GitLab CI, and other CI +pipelines where stdin/stdout are not connected to a terminal. In these cases, +prompts are skipped and missing variables will cause an error as usual. + +You can enable prompts from the command line with `--interactive` or by setting +`interactive: true` in your `.taskrc.yml`. + +::: + ## Variables Task allows you to set variables using the `vars` keyword. The following diff --git a/website/src/docs/reference/cli.md b/website/src/docs/reference/cli.md index c434e5b854..84617fc4bd 100644 --- a/website/src/docs/reference/cli.md +++ b/website/src/docs/reference/cli.md @@ -301,6 +301,19 @@ Automatically answer "yes" to all prompts. task deploy --yes ``` +#### `--interactive` + +Enable interactive prompts for missing required variables. When a required +variable is not provided, Task will prompt for input instead of failing. + +Task automatically detects non-TTY environments (like CI pipelines) and +skips prompts. This flag can also be set in `.taskrc.yml` to enable prompts +by default. + +```bash +task deploy --interactive +``` + ## Exit Codes Task uses specific exit codes to indicate different types of errors: diff --git a/website/src/docs/reference/config.md b/website/src/docs/reference/config.md index a0129e8f7a..8e518d2fb8 100644 --- a/website/src/docs/reference/config.md +++ b/website/src/docs/reference/config.md @@ -124,6 +124,20 @@ concurrency: 4 failfast: true ``` +### `interactive` + +- **Type**: `boolean` +- **Default**: `false` +- **Description**: Prompt for missing required variables instead of failing. + When enabled, Task will display an interactive prompt for any missing required + variable. Requires a TTY. Task automatically detects non-TTY environments + (CI pipelines, etc.) and skips prompts. +- **CLI equivalent**: [`--interactive`](./cli.md#--interactive) + +```yaml +interactive: true +``` + ## Example Configuration Here's a complete example of a `.taskrc.yml` file with all available options: diff --git a/website/src/docs/reference/schema.md b/website/src/docs/reference/schema.md index f28b299857..d0227d5da3 100644 --- a/website/src/docs/reference/schema.md +++ b/website/src/docs/reference/schema.md @@ -632,7 +632,7 @@ tasks: #### `requires` - **Type**: `Requires` -- **Description**: Required variables with optional enums +- **Description**: Required variables with optional enum validation ```yaml tasks: @@ -657,6 +657,9 @@ tasks: - ./deploy.sh ``` +See [Prompting for missing variables interactively](/docs/guide#prompting-for-missing-variables-interactively) +for information on enabling interactive prompts for missing required variables. + #### `watch` - **Type**: `bool` diff --git a/website/src/public/schema-taskrc.json b/website/src/public/schema-taskrc.json index 34c6ed5336..d69b54be11 100644 --- a/website/src/public/schema-taskrc.json +++ b/website/src/public/schema-taskrc.json @@ -70,6 +70,11 @@ "description": "When running tasks in parallel, stop all tasks if one fails.", "type": "boolean", "default": false + }, + "interactive": { + "description": "Prompt for missing required variables instead of failing. Requires a TTY.", + "type": "boolean", + "default": false } }, "additionalProperties": false diff --git a/website/src/public/schema.json b/website/src/public/schema.json index a7faa3a973..e3dfa94589 100644 --- a/website/src/public/schema.json +++ b/website/src/public/schema.json @@ -600,7 +600,7 @@ "name": { "type": "string" }, "enum": { "type": "array", "items": { "type": "string" } } }, - "required": ["name", "enum"], + "required": ["name"], "additionalProperties": false } ]