1
0
mirror of https://github.com/go-task/task.git synced 2025-06-23 00:38:19 +02:00

feat(prompts): add ability for tasks to prompt user pre execution (#1163)

This commit is contained in:
Max Cheetham
2023-06-04 02:33:00 +01:00
committed by GitHub
parent 105756eb27
commit f815ce2901
11 changed files with 244 additions and 17 deletions

View File

@ -56,6 +56,7 @@ var flags struct {
watch bool watch bool
verbose bool verbose bool
silent bool silent bool
assumeYes bool
dry bool dry bool
summary bool summary bool
exitCode bool exitCode bool
@ -113,6 +114,7 @@ func run() error {
pflag.BoolVarP(&flags.watch, "watch", "w", false, "Enables watch of the given task.") pflag.BoolVarP(&flags.watch, "watch", "w", false, "Enables watch of the given task.")
pflag.BoolVarP(&flags.verbose, "verbose", "v", false, "Enables verbose mode.") pflag.BoolVarP(&flags.verbose, "verbose", "v", false, "Enables verbose mode.")
pflag.BoolVarP(&flags.silent, "silent", "s", false, "Disables echoing.") pflag.BoolVarP(&flags.silent, "silent", "s", false, "Disables echoing.")
pflag.BoolVarP(&flags.assumeYes, "yes", "y", false, "Assume \"yes\" as answer to all prompts.")
pflag.BoolVarP(&flags.parallel, "parallel", "p", false, "Executes tasks provided on command line in parallel.") pflag.BoolVarP(&flags.parallel, "parallel", "p", false, "Executes tasks provided on command line in parallel.")
pflag.BoolVarP(&flags.dry, "dry", "n", false, "Compiles and prints tasks in the order that they would be run, without executing them.") pflag.BoolVarP(&flags.dry, "dry", "n", false, "Compiles and prints tasks in the order that they would be run, without executing them.")
pflag.BoolVar(&flags.summary, "summary", false, "Show summary about a task.") pflag.BoolVar(&flags.summary, "summary", false, "Show summary about a task.")
@ -195,6 +197,7 @@ func run() error {
Watch: flags.watch, Watch: flags.watch,
Verbose: flags.verbose, Verbose: flags.verbose,
Silent: flags.silent, Silent: flags.silent,
AssumeYes: flags.assumeYes,
Dir: flags.dir, Dir: flags.dir,
Dry: flags.dry, Dry: flags.dry,
Entrypoint: flags.entrypoint, Entrypoint: flags.entrypoint,

View File

@ -44,6 +44,7 @@ If `--` is given, all remaning arguments will be assigned to a special
| | `--output-group-error-only` | `bool` | `false` | Swallow command output on zero exit code. | | | `--output-group-error-only` | `bool` | `false` | Swallow command output on zero exit code. |
| `-p` | `--parallel` | `bool` | `false` | Executes tasks provided on command line in parallel. | | `-p` | `--parallel` | `bool` | `false` | Executes tasks provided on command line in parallel. |
| `-s` | `--silent` | `bool` | `false` | Disables echoing. | | `-s` | `--silent` | `bool` | `false` | Disables echoing. |
| `-y` | `--yes` | `bool` | `false` | Assume "yes" as answer to all prompts. |
| | `--status` | `bool` | `false` | Exits with non-zero exit code if any of the given tasks is not up-to-date. | | | `--status` | `bool` | `false` | Exits with non-zero exit code if any of the given tasks is not up-to-date. |
| | `--summary` | `bool` | `false` | Show summary about a task. | | | `--summary` | `bool` | `false` | Show summary about a task. |
| `-t` | `--taskfile` | `string` | `Taskfile.yml` or `Taskfile.yaml` | | | `-t` | `--taskfile` | `string` | `Taskfile.yml` or `Taskfile.yaml` | |
@ -53,7 +54,8 @@ If `--` is given, all remaning arguments will be assigned to a special
## Exit Codes ## Exit Codes
Task will sometimes exit with specific exit codes. These codes are split into three groups with the following ranges: Task will sometimes exit with specific exit codes. These codes are split into
three groups with the following ranges:
- General errors (0-99) - General errors (0-99)
- Taskfile errors (100-199) - Taskfile errors (100-199)
@ -73,12 +75,13 @@ A full list of the exit codes and their descriptions can be found below:
| 202 | The user tried to invoke a task that is internal | | 202 | The user tried to invoke a task that is internal |
| 203 | There a multiple tasks with the same name or alias | | 203 | There a multiple tasks with the same name or alias |
| 204 | A task was called too many times | | 204 | A task was called too many times |
| 205 | A task was cancelled by the user |
These codes can also be found in the repository in [`errors/errors.go`](https://github.com/go-task/task/blob/main/errors/errors.go). These codes can also be found in the repository in
[`errors/errors.go`](https://github.com/go-task/task/blob/main/errors/errors.go).
:::info :::info When Task is run with the `-x`/`--exit-code` flag, the exit code of any
When Task is run with the `-x`/`--exit-code` flag, the exit code of any failed commands will be passed through to the user instead. failed commands will be passed through to the user instead. :::
:::
## JSON Output ## JSON Output
@ -206,6 +209,7 @@ vars:
| `deps` | [`[]Dependency`](#dependency) | | A list of dependencies of this task. Tasks defined here will run in parallel before this task. | | `deps` | [`[]Dependency`](#dependency) | | A list of dependencies of this task. Tasks defined here will run in parallel before this task. |
| `label` | `string` | | Overrides the name of the task in the output when a task is run. Supports variables. | | `label` | `string` | | Overrides the name of the task in the output when a task is run. Supports variables. |
| `desc` | `string` | | A short description of the task. This is displayed when calling `task --list`. | | `desc` | `string` | | A short description of the task. This is displayed when calling `task --list`. |
| `prompt` | `string` | | A prompt that will be presented before a task is run. Declining will cancel running the current and any subsequent tasks. |
| `summary` | `string` | | A longer description of the task. This is displayed when calling `task --summary [task]`. | | `summary` | `string` | | A longer description of the task. This is displayed when calling `task --summary [task]`. |
| `aliases` | `[]string` | | A list of alternative names by which the task can be called. | | `aliases` | `[]string` | | A list of alternative names by which the task can be called. |
| `sources` | `[]string` | | A list of sources to check before running this task. Relevant for `checksum` and `timestamp` methods. Can be file paths or star globs. | | `sources` | `[]string` | | A list of sources to check before running this task. Relevant for `checksum` and `timestamp` methods. Can be file paths or star globs. |
@ -223,7 +227,7 @@ vars:
| `prefix` | `string` | | Defines a string to prefix the output of tasks running in parallel. Only used when the output mode is `prefixed`. | | `prefix` | `string` | | Defines a string to prefix the output of tasks running in parallel. Only used when the output mode is `prefixed`. |
| `ignore_error` | `bool` | `false` | Continue execution if errors happen while executing commands. | | `ignore_error` | `bool` | `false` | Continue execution if errors happen while executing commands. |
| `run` | `string` | The one declared globally in the Taskfile or `always` | Specifies whether the task should run again or not if called more than once. Available options: `always`, `once` and `when_changed`. | | `run` | `string` | The one declared globally in the Taskfile or `always` | Specifies whether the task should run again or not if called more than once. Available options: `always`, `once` and `when_changed`. |
| `platforms` | `[]string` | All platforms | Specifies which platforms the task should be run on. [Valid GOOS and GOARCH values allowed](https://github.com/golang/go/blob/main/src/go/build/syslist.go). Task will be skipped otherwise. | | `platforms` | `[]string` | All platforms | Specifies which platforms the task should be run on. [Valid GOOS and GOARCH values allowed](https://github.com/golang/go/blob/main/src/go/build/syslist.go). Task will be skipped otherwise. |
| `set` | `[]string` | | Specify options for the [`set` builtin](https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html). | | `set` | `[]string` | | Specify options for the [`set` builtin](https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html). |
| `shopt` | `[]string` | | Specify option for the [`shopt` builtin](https://www.gnu.org/software/bash/manual/html_node/The-Shopt-Builtin.html). | | `shopt` | `[]string` | | Specify option for the [`shopt` builtin](https://www.gnu.org/software/bash/manual/html_node/The-Shopt-Builtin.html). |
@ -248,17 +252,17 @@ tasks:
#### Command #### Command
| Attribute | Type | Default | Description | | Attribute | Type | Default | Description |
| -------------- | ---------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------- | ---------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `cmd` | `string` | | The shell command to be executed. | | `cmd` | `string` | | The shell command to be executed. |
| `silent` | `bool` | `false` | Skips some output for this command. Note that STDOUT and STDERR of the commands will still be redirected. | | `silent` | `bool` | `false` | Skips some output for this command. Note that STDOUT and STDERR of the commands will still be redirected. |
| `task` | `string` | | Set this to trigger execution of another task instead of running a command. This cannot be set together with `cmd`. | | `task` | `string` | | Set this to trigger execution of another task instead of running a command. This cannot be set together with `cmd`. |
| `vars` | [`map[string]Variable`](#variable) | | Optional additional variables to be passed to the referenced task. Only relevant when setting `task` instead of `cmd`. | | `vars` | [`map[string]Variable`](#variable) | | Optional additional variables to be passed to the referenced task. Only relevant when setting `task` instead of `cmd`. |
| `ignore_error` | `bool` | `false` | Continue execution if errors happen while executing the command. | | `ignore_error` | `bool` | `false` | Continue execution if errors happen while executing the command. |
| `defer` | `string` | | Alternative to `cmd`, but schedules the command to be executed at the end of this task instead of immediately. This cannot be used together with `cmd`. | | `defer` | `string` | | Alternative to `cmd`, but schedules the command to be executed at the end of this task instead of immediately. This cannot be used together with `cmd`. |
| `platforms` | `[]string` | All platforms | Specifies which platforms the command should be run on. [Valid GOOS and GOARCH values allowed](https://github.com/golang/go/blob/main/src/go/build/syslist.go). Command will be skipped otherwise. | | `platforms` | `[]string` | All platforms | Specifies which platforms the command should be run on. [Valid GOOS and GOARCH values allowed](https://github.com/golang/go/blob/main/src/go/build/syslist.go). Command will be skipped otherwise. |
| `set` | `[]string` | | Specify options for the [`set` builtin](https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html). | | `set` | `[]string` | | Specify options for the [`set` builtin](https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html). |
| `shopt` | `[]string` | | Specify option for the [`shopt` builtin](https://www.gnu.org/software/bash/manual/html_node/The-Shopt-Builtin.html). | | `shopt` | `[]string` | | Specify option for the [`shopt` builtin](https://www.gnu.org/software/bash/manual/html_node/The-Shopt-Builtin.html). |
:::info :::info

View File

@ -1212,6 +1212,64 @@ tasks:
- echo "{{.MESSAGE}}" - echo "{{.MESSAGE}}"
``` ```
## Warning Prompts
Warning Prompts to prompt a user for confirmation before a task is executed.
Below is an example using `prompt` with a dangerous command, that is called between two safe commands
```yaml
version: '3'
tasks:
example:
cmds:
- task: not-dangerous
- task: dangerous
- task: another-not-dangerous
not-dangerous:
cmds:
- echo 'not dangerous command.'
another-not-dangerous:
cmds:
- echo 'another not dangerous command.'
dangerous:
prompt: This is a dangerous command.. Do you want to continue?
cmds:
- echo 'dangerous command.'
```
```bash
❯ task dangerous
task: "This is a dangerous command.. Do you want to continue?" [y/N]
```
### Prompt behaviour
Warning prompts are called before executing a task. If a prompt is denied Task will exit with [Exit code](api_reference.md#exit-codes) 205. If approved, Task will continue as normal.
```bash
❯ taskd --dir ./testdata/prompt example
task: [not-dangerous] echo 'not dangerous command.'
not dangerous command.
task: "This is a dangerous command.. Do you want to continue?" [y/N]
y
task: [dangerous] echo 'dangerous command.'
dangerous command.
task: [another-not-dangerous] echo 'another not dangerous command.'
another not dangerous command.
```
### Skipping Warning Prompts
To skip warning prompts automatically, you can use the [-y | --yes] option when calling the task. By including this option, all warnings, will be automatically confirmed, and no prompts will be shows.
## Silent mode ## Silent mode
Silent mode disables the echoing of commands before Task runs it. For the Silent mode disables the echoing of commands before Task runs it. For the

View File

@ -65,6 +65,10 @@
"description": "A short description of the task. This is displayed when calling `task --list`.", "description": "A short description of the task. This is displayed when calling `task --list`.",
"type": "string" "type": "string"
}, },
"prompt": {
"description": "A prompt that will be presented before a task is run. Declining will cancel running the current and any subsequent tasks.",
"type": "string"
},
"summary": { "summary": {
"description": "A longer description of the task. This is displayed when calling `task --summary [task]`.", "description": "A longer description of the task. This is displayed when calling `task --summary [task]`.",
"type": "string" "type": "string"

View File

@ -22,6 +22,7 @@ const (
CodeTaskInternal CodeTaskInternal
CodeTaskNameConflict CodeTaskNameConflict
CodeTaskCalledTooManyTimes CodeTaskCalledTooManyTimes
CodeTaskCancelled
) )
// TaskError extends the standard error interface with a Code method. This code will // TaskError extends the standard error interface with a Code method. This code will

View File

@ -89,7 +89,7 @@ type TaskCalledTooManyTimesError struct {
func (err *TaskCalledTooManyTimesError) Error() string { func (err *TaskCalledTooManyTimesError) Error() string {
return fmt.Sprintf( return fmt.Sprintf(
`task: maximum task call exceeded (%d) for task %q: probably an cyclic dep or infinite loop`, `task: Maximum task call exceeded (%d) for task %q: probably an cyclic dep or infinite loop`,
err.MaximumTaskCall, err.MaximumTaskCall,
err.TaskName, err.TaskName,
) )
@ -98,3 +98,19 @@ func (err *TaskCalledTooManyTimesError) Error() string {
func (err *TaskCalledTooManyTimesError) Code() int { func (err *TaskCalledTooManyTimesError) Code() int {
return CodeTaskCalledTooManyTimes return CodeTaskCalledTooManyTimes
} }
// TaskCancelledError is returned when the user does not accept an optional prompt to continue.
type TaskCancelledError struct {
TaskName string
}
func (err *TaskCancelledError) Error() string {
return fmt.Sprintf(
`task: %q Cancelled by user`,
err.TaskName,
)
}
func (err *TaskCancelledError) Code() int {
return CodeTaskCancelled
}

25
task.go
View File

@ -1,11 +1,13 @@
package task package task
import ( import (
"bufio"
"context" "context"
"fmt" "fmt"
"io" "io"
"os" "os"
"runtime" "runtime"
"strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
@ -34,6 +36,11 @@ const (
MaximumTaskCall = 100 MaximumTaskCall = 100
) )
func shouldPromptContinue(input string) bool {
input = strings.ToLower(strings.TrimSpace(input))
return slices.Contains([]string{"y", "yes"}, input)
}
// Executor executes a Taskfile // Executor executes a Taskfile
type Executor struct { type Executor struct {
Taskfile *taskfile.Taskfile Taskfile *taskfile.Taskfile
@ -45,6 +52,7 @@ type Executor struct {
Watch bool Watch bool
Verbose bool Verbose bool
Silent bool Silent bool
AssumeYes bool
Dry bool Dry bool
Summary bool Summary bool
Parallel bool Parallel bool
@ -94,6 +102,7 @@ func (e *Executor) Run(ctx context.Context, calls ...taskfile.Call) error {
} }
return &errors.TaskInternalError{TaskName: call.Task} return &errors.TaskInternalError{TaskName: call.Task}
} }
} }
if e.Summary { if e.Summary {
@ -139,6 +148,22 @@ func (e *Executor) RunTask(ctx context.Context, call taskfile.Call) error {
release := e.acquireConcurrencyLimit() release := e.acquireConcurrencyLimit()
defer release() defer release()
// check if the given task has a warning prompt
if t.Prompt != "" && !e.AssumeYes {
e.Logger.Outf(logger.Yellow, "task: %q [y/N]\n", t.Prompt)
reader := bufio.NewReader(e.Stdin)
userInput, err := reader.ReadString('\n')
if err != nil {
return err
}
userInput = strings.ToLower(strings.TrimSpace(userInput))
if !shouldPromptContinue(userInput) {
return &errors.TaskCancelledError{TaskName: call.Task}
}
}
return e.startExecution(ctx, t, func(ctx context.Context) error { return e.startExecution(ctx, t, func(ctx context.Context) error {
if !shouldRunOnCurrentPlatform(t.Platforms) { if !shouldRunOnCurrentPlatform(t.Platforms) {
e.Logger.VerboseOutf(logger.Yellow, `task: %q not for current platform - ignored\n`, call.Task) e.Logger.VerboseOutf(logger.Yellow, `task: %q not for current platform - ignored\n`, call.Task)

View File

@ -657,6 +657,101 @@ func TestLabelInSummary(t *testing.T) {
assert.Contains(t, buff.String(), "foobar") assert.Contains(t, buff.String(), "foobar")
} }
func TestPromptInSummary(t *testing.T) {
const dir = "testdata/prompt"
tests := []struct {
name string
input string
wantError bool
}{
{"test short approval", "y\n", false},
{"test long approval", "yes\n", false},
{"test uppercase approval", "Y\n", false},
{"test stops task", "n\n", true},
{"test junk value stops task", "foobar\n", true},
{"test Enter stops task", "\n", true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var inBuff bytes.Buffer
var outBuff bytes.Buffer
inBuff.Write([]byte(test.input))
e := task.Executor{
Dir: dir,
Stdin: &inBuff,
Stdout: &outBuff,
}
require.NoError(t, e.Setup())
err := e.Run(context.Background(), taskfile.Call{Task: "foo"})
if test.wantError {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestPromptWithIndirectTask(t *testing.T) {
const dir = "testdata/prompt"
var inBuff bytes.Buffer
var outBuff bytes.Buffer
inBuff.Write([]byte("y\n"))
e := task.Executor{
Dir: dir,
Stdin: &inBuff,
Stdout: &outBuff,
}
require.NoError(t, e.Setup())
err := e.Run(context.Background(), taskfile.Call{Task: "bar"})
assert.Contains(t, outBuff.String(), "show-prompt")
require.NoError(t, err)
}
func TestPromptAssumeYes(t *testing.T) {
const dir = "testdata/prompt"
tests := []struct {
name string
assumeYes bool
}{
{"--yes flag should skip prompt", true},
{"task should raise errors.TaskCancelledError", false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var inBuff bytes.Buffer
var outBuff bytes.Buffer
// always cancel the prompt so we can require.Error
inBuff.Write([]byte("\n"))
e := task.Executor{
Dir: dir,
Stdin: &inBuff,
Stdout: &outBuff,
AssumeYes: test.assumeYes,
}
require.NoError(t, e.Setup())
err := e.Run(context.Background(), taskfile.Call{Task: "foo"})
if !test.assumeYes {
require.Error(t, err)
return
}
})
}
}
func TestNoLabelInList(t *testing.T) { func TestNoLabelInList(t *testing.T) {
const dir = "testdata/label_list" const dir = "testdata/label_list"

View File

@ -15,6 +15,7 @@ type Task struct {
Deps []*Dep Deps []*Dep
Label string Label string
Desc string Desc string
Prompt string
Summary string Summary string
Aliases []string Aliases []string
Sources []string Sources []string
@ -76,6 +77,7 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error {
Deps []*Dep Deps []*Dep
Label string Label string
Desc string Desc string
Prompt string
Summary string Summary string
Aliases []string Aliases []string
Sources []string Sources []string
@ -104,6 +106,7 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error {
t.Deps = task.Deps t.Deps = task.Deps
t.Label = task.Label t.Label = task.Label
t.Desc = task.Desc t.Desc = task.Desc
t.Prompt = task.Prompt
t.Summary = task.Summary t.Summary = task.Summary
t.Aliases = task.Aliases t.Aliases = task.Aliases
t.Sources = task.Sources t.Sources = task.Sources
@ -142,6 +145,7 @@ func (t *Task) DeepCopy() *Task {
Deps: deepcopy.Slice(t.Deps), Deps: deepcopy.Slice(t.Deps),
Label: t.Label, Label: t.Label,
Desc: t.Desc, Desc: t.Desc,
Prompt: t.Prompt,
Summary: t.Summary, Summary: t.Summary,
Aliases: deepcopy.Slice(t.Aliases), Aliases: deepcopy.Slice(t.Aliases),
Sources: deepcopy.Slice(t.Sources), Sources: deepcopy.Slice(t.Sources),

16
testdata/prompt/Taskfile.yml vendored Normal file
View File

@ -0,0 +1,16 @@
version: 3
tasks:
foo:
prompt: Do you want to continue?
cmds:
- echo 'foo'
bar:
cmds:
- task: show-prompt
show-prompt:
summary: some text for the summary
prompt: Do you want to continue?
cmds:
- echo 'show-prompt'

View File

@ -46,6 +46,7 @@ func (e *Executor) compiledTask(call taskfile.Call, evaluateShVars bool) (*taskf
Task: origTask.Task, Task: origTask.Task,
Label: r.Replace(origTask.Label), Label: r.Replace(origTask.Label),
Desc: r.Replace(origTask.Desc), Desc: r.Replace(origTask.Desc),
Prompt: r.Replace(origTask.Prompt),
Summary: r.Replace(origTask.Summary), Summary: r.Replace(origTask.Summary),
Aliases: origTask.Aliases, Aliases: origTask.Aliases,
Sources: r.ReplaceSlice(origTask.Sources), Sources: r.ReplaceSlice(origTask.Sources),