diff --git a/cmd/task/task.go b/cmd/task/task.go index 09334c96..e6f012d5 100644 --- a/cmd/task/task.go +++ b/cmd/task/task.go @@ -56,6 +56,7 @@ var flags struct { watch bool verbose bool silent bool + assumeYes bool dry bool summary 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.verbose, "verbose", "v", false, "Enables verbose mode.") 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.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.") @@ -195,6 +197,7 @@ func run() error { Watch: flags.watch, Verbose: flags.verbose, Silent: flags.silent, + AssumeYes: flags.assumeYes, Dir: flags.dir, Dry: flags.dry, Entrypoint: flags.entrypoint, diff --git a/docs/docs/api_reference.md b/docs/docs/api_reference.md index b62cbf7b..7f06a532 100644 --- a/docs/docs/api_reference.md +++ b/docs/docs/api_reference.md @@ -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. | | `-p` | `--parallel` | `bool` | `false` | Executes tasks provided on command line in parallel. | | `-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. | | | `--summary` | `bool` | `false` | Show summary about a task. | | `-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 -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) - 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 | | 203 | There a multiple tasks with the same name or alias | | 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 -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. -::: +:::info 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. ::: ## 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. | | `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`. | +| `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]`. | | `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. | @@ -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`. | | `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`. | -| `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). | | `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 -| Attribute | Type | Default | Description | -| -------------- | ---------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `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. | -| `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`. | -| `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`. | +| Attribute | Type | Default | Description | +| -------------- | ---------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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. | +| `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`. | +| `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`. | | `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). | -| `shopt` | `[]string` | | Specify option for the [`shopt` builtin](https://www.gnu.org/software/bash/manual/html_node/The-Shopt-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). | :::info diff --git a/docs/docs/usage.md b/docs/docs/usage.md index 27936a22..81fd40e3 100644 --- a/docs/docs/usage.md +++ b/docs/docs/usage.md @@ -1212,6 +1212,64 @@ tasks: - 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 disables the echoing of commands before Task runs it. For the diff --git a/docs/static/schema.json b/docs/static/schema.json index 0c9c0f4e..8e15219e 100644 --- a/docs/static/schema.json +++ b/docs/static/schema.json @@ -65,6 +65,10 @@ "description": "A short description of the task. This is displayed when calling `task --list`.", "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": { "description": "A longer description of the task. This is displayed when calling `task --summary [task]`.", "type": "string" diff --git a/errors/errors.go b/errors/errors.go index c777faa8..9c43987f 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -22,6 +22,7 @@ const ( CodeTaskInternal CodeTaskNameConflict CodeTaskCalledTooManyTimes + CodeTaskCancelled ) // TaskError extends the standard error interface with a Code method. This code will diff --git a/errors/errors_task.go b/errors/errors_task.go index 92109ceb..b3fcaeaa 100644 --- a/errors/errors_task.go +++ b/errors/errors_task.go @@ -89,7 +89,7 @@ type TaskCalledTooManyTimesError struct { func (err *TaskCalledTooManyTimesError) Error() string { 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.TaskName, ) @@ -98,3 +98,19 @@ func (err *TaskCalledTooManyTimesError) Error() string { func (err *TaskCalledTooManyTimesError) Code() int { 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 +} diff --git a/task.go b/task.go index e1c7dbfc..4e4a731d 100644 --- a/task.go +++ b/task.go @@ -1,11 +1,13 @@ package task import ( + "bufio" "context" "fmt" "io" "os" "runtime" + "strings" "sync" "sync/atomic" "time" @@ -34,6 +36,11 @@ const ( MaximumTaskCall = 100 ) +func shouldPromptContinue(input string) bool { + input = strings.ToLower(strings.TrimSpace(input)) + return slices.Contains([]string{"y", "yes"}, input) +} + // Executor executes a Taskfile type Executor struct { Taskfile *taskfile.Taskfile @@ -45,6 +52,7 @@ type Executor struct { Watch bool Verbose bool Silent bool + AssumeYes bool Dry bool Summary bool Parallel bool @@ -94,6 +102,7 @@ func (e *Executor) Run(ctx context.Context, calls ...taskfile.Call) error { } return &errors.TaskInternalError{TaskName: call.Task} } + } if e.Summary { @@ -139,6 +148,22 @@ func (e *Executor) RunTask(ctx context.Context, call taskfile.Call) error { release := e.acquireConcurrencyLimit() 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 { if !shouldRunOnCurrentPlatform(t.Platforms) { e.Logger.VerboseOutf(logger.Yellow, `task: %q not for current platform - ignored\n`, call.Task) diff --git a/task_test.go b/task_test.go index 4046a7a8..bee3a506 100644 --- a/task_test.go +++ b/task_test.go @@ -657,6 +657,101 @@ func TestLabelInSummary(t *testing.T) { 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) { const dir = "testdata/label_list" diff --git a/taskfile/task.go b/taskfile/task.go index d02e4b84..04ed6f1e 100644 --- a/taskfile/task.go +++ b/taskfile/task.go @@ -15,6 +15,7 @@ type Task struct { Deps []*Dep Label string Desc string + Prompt string Summary string Aliases []string Sources []string @@ -76,6 +77,7 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error { Deps []*Dep Label string Desc string + Prompt string Summary string Aliases []string Sources []string @@ -104,6 +106,7 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error { t.Deps = task.Deps t.Label = task.Label t.Desc = task.Desc + t.Prompt = task.Prompt t.Summary = task.Summary t.Aliases = task.Aliases t.Sources = task.Sources @@ -142,6 +145,7 @@ func (t *Task) DeepCopy() *Task { Deps: deepcopy.Slice(t.Deps), Label: t.Label, Desc: t.Desc, + Prompt: t.Prompt, Summary: t.Summary, Aliases: deepcopy.Slice(t.Aliases), Sources: deepcopy.Slice(t.Sources), diff --git a/testdata/prompt/Taskfile.yml b/testdata/prompt/Taskfile.yml new file mode 100644 index 00000000..947c2270 --- /dev/null +++ b/testdata/prompt/Taskfile.yml @@ -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' diff --git a/variables.go b/variables.go index 76187e6f..fec752de 100644 --- a/variables.go +++ b/variables.go @@ -46,6 +46,7 @@ func (e *Executor) compiledTask(call taskfile.Call, evaluateShVars bool) (*taskf Task: origTask.Task, Label: r.Replace(origTask.Label), Desc: r.Replace(origTask.Desc), + Prompt: r.Replace(origTask.Prompt), Summary: r.Replace(origTask.Summary), Aliases: origTask.Aliases, Sources: r.ReplaceSlice(origTask.Sources),