From 652ecf480eea428e8f7b634ed116a719c6ec4d36 Mon Sep 17 00:00:00 2001 From: nxtcoder17 Date: Sun, 10 Nov 2024 21:38:37 +0530 Subject: [PATCH] feat: interactive commands support fixes issue with dumping stdout and stderr of commands, there was a bug in current implementation, as goroutines for reading and dumping stdout ere not going through a waitgroup, which caused them to abruptly exit, without logging the remaining stuffs --- examples/run1/Runfile | 5 ++ pkg/runfile/run.go | 88 ++++++++++++++++++++++----------- pkg/runfile/task-parser.go | 18 ++++--- pkg/runfile/task-parser_test.go | 43 ++++++++++++++++ pkg/runfile/task.go | 2 + 5 files changed, 118 insertions(+), 38 deletions(-) diff --git a/examples/run1/Runfile b/examples/run1/Runfile index 1a12795..b1b4bad 100644 --- a/examples/run1/Runfile +++ b/examples/run1/Runfile @@ -4,3 +4,8 @@ tasks: echo: cmd: - echo "hello from run1" + + node:shell: + interactive: true + cmd: + - node diff --git a/pkg/runfile/run.go b/pkg/runfile/run.go index 5c46382..0191c81 100644 --- a/pkg/runfile/run.go +++ b/pkg/runfile/run.go @@ -1,13 +1,14 @@ package runfile import ( - "bufio" + "errors" "fmt" "io" "os" "os/exec" "path/filepath" "strings" + "sync" fn "github.com/nxtcoder17/runfile/pkg/functions" "golang.org/x/sync/errgroup" @@ -20,8 +21,9 @@ type cmdArgs struct { cmd string - stdout io.Writer - stderr io.Writer + interactive bool + stdout io.Writer + stderr io.Writer } func createCommand(ctx Context, args cmdArgs) *exec.Cmd { @@ -46,6 +48,10 @@ func createCommand(ctx Context, args cmdArgs) *exec.Cmd { c.Stdout = args.stdout c.Stderr = args.stderr + if args.interactive { + c.Stdin = os.Stdin + } + return c } @@ -55,6 +61,33 @@ type runTaskArgs struct { envOverrides map[string]string } +func processOutput(writer io.Writer, reader io.Reader, prefix *string) { + prevByte := byte('\n') + msg := make([]byte, 1) + for { + n, err := reader.Read(msg) + if err != nil { + // logger.Info("stdout", "msg", string(msg[:n]), "err", err) + if errors.Is(err, io.EOF) { + os.Stdout.Write(msg[:n]) + return + } + } + + if n != 1 { + continue + } + + if prevByte == '\n' && prefix != nil { + // os.Stdout.WriteString(fmt.Sprintf("HERE... msg: '%s'", msg[:n])) + os.Stdout.WriteString(*prefix) + } + + writer.Write(msg[:n]) + prevByte = msg[0] + } +} + func runTask(ctx Context, rf *Runfile, args runTaskArgs) *Error { runfilePath := fn.Must(filepath.Rel(rf.attrs.RootRunfilePath, rf.attrs.RunfilePath)) @@ -108,44 +141,39 @@ func runTask(ctx Context, rf *Runfile, args runTaskArgs) *Error { stdoutR, stdoutW := io.Pipe() stderrR, stderrW := io.Pipe() + wg := sync.WaitGroup{} + + wg.Add(1) go func() { - r := bufio.NewReader(stdoutR) - for { - b, err := r.ReadBytes('\n') - if err != nil { - logger.Info("stdout", "msg", string(b), "err", err) - // return - break - } - fmt.Fprintf(os.Stdout, "%s %s", ctx.theme.TaskPrefixStyle.Render(fmt.Sprintf("[%s]", strings.Join(trail, "/"))), b) - } + defer wg.Done() + logPrefix := fmt.Sprintf("%s ", ctx.theme.TaskPrefixStyle.Render(fmt.Sprintf("[%s]", strings.Join(trail, "/")))) + processOutput(os.Stdout, stdoutR, &logPrefix) }() + wg.Add(1) go func() { - r := bufio.NewReader(stderrR) - for { - b, err := r.ReadBytes('\n') - if err != nil { - fmt.Printf("hello err: %+v\n", err) - logger.Info("stderr", "err", err) - // return - break - } - fmt.Fprintf(os.Stderr, "%s %s", ctx.theme.TaskPrefixStyle.Render(fmt.Sprintf("[%s]", strings.Join(trail, "/"))), b) - } + defer wg.Done() + logPrefix := fmt.Sprintf("%s ", ctx.theme.TaskPrefixStyle.Render(fmt.Sprintf("[%s]", strings.Join(trail, "/")))) + processOutput(os.Stderr, stderrR, &logPrefix) }() cmd := createCommand(ctx, cmdArgs{ - shell: pt.Shell, - env: ToEnviron(pt.Env), - cmd: command.Command, - workingDir: pt.WorkingDir, - stdout: stdoutW, - stderr: stderrW, + shell: pt.Shell, + env: ToEnviron(pt.Env), + cmd: command.Command, + workingDir: pt.WorkingDir, + interactive: pt.Interactive, + stdout: stdoutW, + stderr: stderrW, }) if err := cmd.Run(); err != nil { return formatErr(CommandFailed).WithErr(err) } + + stdoutW.Close() + stderrW.Close() + + wg.Wait() } return nil diff --git a/pkg/runfile/task-parser.go b/pkg/runfile/task-parser.go index 46700cb..550e465 100644 --- a/pkg/runfile/task-parser.go +++ b/pkg/runfile/task-parser.go @@ -13,10 +13,11 @@ import ( ) type ParsedTask struct { - Shell []string `json:"shell"` - WorkingDir string `json:"workingDir"` - Env map[string]string `json:"environ"` - Commands []CommandJson `json:"commands"` + Shell []string `json:"shell"` + WorkingDir string `json:"workingDir"` + Env map[string]string `json:"environ"` + Interactive bool `json:"interactive,omitempty"` + Commands []CommandJson `json:"commands"` } func ParseTask(ctx Context, rf *Runfile, task Task) (*ParsedTask, *Error) { @@ -136,10 +137,11 @@ func ParseTask(ctx Context, rf *Runfile, task Task) (*ParsedTask, *Error) { } return &ParsedTask{ - Shell: task.Shell, - WorkingDir: *task.Dir, - Env: fn.MapMerge(globalEnv, taskDotenvVars, taskEnvVars), - Commands: commands, + Shell: task.Shell, + WorkingDir: *task.Dir, + Interactive: task.Interactive, + Env: fn.MapMerge(globalEnv, taskDotenvVars, taskEnvVars), + Commands: commands, }, nil } diff --git a/pkg/runfile/task-parser_test.go b/pkg/runfile/task-parser_test.go index e00e246..eda7590 100644 --- a/pkg/runfile/task-parser_test.go +++ b/pkg/runfile/task-parser_test.go @@ -29,6 +29,11 @@ func TestParseTask(t *testing.T) { return false } + if got.Interactive != want.Interactive { + t.Logf("interactive not equal") + return false + } + if len(got.Env) != len(want.Env) { t.Logf("environments not equal") return false @@ -629,6 +634,44 @@ echo "hi" }, wantErr: true, }, + + { + name: "[task] interactive task", + args: args{ + ctx: nil, + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + ignoreSystemEnv: true, + Interactive: true, + Commands: []any{ + "echo i will call hello, now", + map[string]any{ + "run": "hello", + }, + }, + }, + "hello": { + ignoreSystemEnv: true, + Commands: []any{ + "echo hello everyone", + }, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + WorkingDir: fn.Must(os.Getwd()), + Interactive: true, + Commands: []CommandJson{ + {Command: "echo i will call hello, now"}, + {Run: "hello"}, + }, + }, + wantErr: false, + }, } testGlobalEnvVars := []test{ diff --git a/pkg/runfile/task.go b/pkg/runfile/task.go index 2baa192..cce3133 100644 --- a/pkg/runfile/task.go +++ b/pkg/runfile/task.go @@ -41,6 +41,8 @@ type Task struct { Requires []*Requires `json:"requires,omitempty"` + Interactive bool `json:"interactive,omitempty"` + // List of commands to be executed in given shell (default: sh) // can take multiple forms // - simple string