mirror of
https://github.com/go-task/task.git
synced 2025-08-08 22:36:57 +02:00
feat(prompts): add ability for tasks to prompt user pre execution (#1163)
This commit is contained in:
@ -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,
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
4
docs/static/schema.json
vendored
4
docs/static/schema.json
vendored
@ -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"
|
||||
|
@ -22,6 +22,7 @@ const (
|
||||
CodeTaskInternal
|
||||
CodeTaskNameConflict
|
||||
CodeTaskCalledTooManyTimes
|
||||
CodeTaskCancelled
|
||||
)
|
||||
|
||||
// TaskError extends the standard error interface with a Code method. This code will
|
||||
|
@ -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
|
||||
}
|
||||
|
25
task.go
25
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)
|
||||
|
95
task_test.go
95
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"
|
||||
|
||||
|
@ -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),
|
||||
|
16
testdata/prompt/Taskfile.yml
vendored
Normal file
16
testdata/prompt/Taskfile.yml
vendored
Normal 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'
|
@ -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),
|
||||
|
Reference in New Issue
Block a user