From dcc512993aebb46ffa0cf0c3c78e6d8c71712017 Mon Sep 17 00:00:00 2001 From: nxtcoder17 Date: Wed, 11 Dec 2024 23:52:43 +0530 Subject: [PATCH 1/3] feat: refactorings and better tests --- Runfile => Runfile.yml | 10 +- cmd/run/completions.go | 24 +- cmd/run/completions/{bash => }/run.bash | 0 cmd/run/completions/{fish => }/run.fish | 0 cmd/run/completions/{ps => }/run.ps | Bin cmd/run/completions/{zsh => }/run.zsh | 0 cmd/run/main.go | 91 +- errors/errors.go | 98 ++ examples/Runfile.yml | 10 +- {pkg/functions => functions}/helpers.go | 13 + {pkg/functions => functions}/maps.go | 0 go.mod | 12 +- go.sum | 23 +- {pkg/logging => logging}/logger.go | 17 + {pkg/logging => logging}/theme.go | 0 parser/parse-command.go | 52 + parser/parse-dotenv.go | 48 + .../parse-dotenv_test.go | 19 +- parser/parse-env.go | 126 +++ parser/parse-env_test.go | 127 +++ parser/parse-includes.go | 28 + parser/parse-task.go | 98 ++ parser/parse-task_test.go | 615 ++++++++++++ parser/parser.go | 68 ++ pkg/errors/message.go | 64 -- pkg/errors/operations.go | 9 - pkg/runfile/context.go | 175 ---- pkg/runfile/errors.go | 75 -- pkg/runfile/parser.go | 70 -- pkg/runfile/run.go | 338 ------- pkg/runfile/run_test.go | 58 -- pkg/runfile/runfile.go | 79 -- pkg/runfile/task-parser.go | 224 ----- pkg/runfile/task-parser_test.go | 915 ------------------ pkg/runfile/tui.go | 25 - runner/run-task.go | 174 ++++ runner/runner.go | 124 +++ types/parsed-types.go | 9 + pkg/runfile/task.go => types/types.go | 37 +- 39 files changed, 1747 insertions(+), 2108 deletions(-) rename Runfile => Runfile.yml (85%) rename cmd/run/completions/{bash => }/run.bash (100%) rename cmd/run/completions/{fish => }/run.fish (100%) rename cmd/run/completions/{ps => }/run.ps (100%) rename cmd/run/completions/{zsh => }/run.zsh (100%) create mode 100644 errors/errors.go rename {pkg/functions => functions}/helpers.go (67%) rename {pkg/functions => functions}/maps.go (100%) rename {pkg/logging => logging}/logger.go (84%) rename {pkg/logging => logging}/theme.go (100%) create mode 100644 parser/parse-command.go create mode 100644 parser/parse-dotenv.go rename pkg/runfile/parser_test.go => parser/parse-dotenv_test.go (87%) create mode 100644 parser/parse-env.go create mode 100644 parser/parse-env_test.go create mode 100644 parser/parse-includes.go create mode 100644 parser/parse-task.go create mode 100644 parser/parse-task_test.go create mode 100644 parser/parser.go delete mode 100644 pkg/errors/message.go delete mode 100644 pkg/errors/operations.go delete mode 100644 pkg/runfile/context.go delete mode 100644 pkg/runfile/errors.go delete mode 100644 pkg/runfile/parser.go delete mode 100644 pkg/runfile/run.go delete mode 100644 pkg/runfile/run_test.go delete mode 100644 pkg/runfile/runfile.go delete mode 100644 pkg/runfile/task-parser.go delete mode 100644 pkg/runfile/task-parser_test.go delete mode 100644 pkg/runfile/tui.go create mode 100644 runner/run-task.go create mode 100644 runner/runner.go create mode 100644 types/parsed-types.go rename pkg/runfile/task.go => types/types.go (69%) diff --git a/Runfile b/Runfile.yml similarity index 85% rename from Runfile rename to Runfile.yml index 1585b7d..60f97cb 100644 --- a/Runfile +++ b/Runfile.yml @@ -1,7 +1,3 @@ -# vim: set ft=yaml: - -version: 0.0.1 - tasks: build: cmd: @@ -16,10 +12,14 @@ tasks: - |+ run cook clean - test: + test:old: cmd: - go test -json ./pkg/runfile | gotestfmt + test: + cmd: + - go test -json ./parser/... | gotestfmt + test:only-failing: cmd: - go test -json ./pkg/runfile | gotestfmt --hide successful-tests diff --git a/cmd/run/completions.go b/cmd/run/completions.go index e23df15..f68e2b5 100644 --- a/cmd/run/completions.go +++ b/cmd/run/completions.go @@ -6,7 +6,7 @@ import ( "io" "log/slog" - "github.com/nxtcoder17/runfile/pkg/runfile" + "github.com/nxtcoder17/runfile/parser" ) func generateShellCompletion(_ context.Context, writer io.Writer, rfpath string) error { @@ -20,7 +20,7 @@ func generateShellCompletion(_ context.Context, writer io.Writer, rfpath string) // panic(err) // } - runfile, err := runfile.Parse(rfpath) + runfile, err := parser.Parse(rfpath) if err != nil { slog.Error("parsing, got", "err", err) panic(err) @@ -30,17 +30,17 @@ func generateShellCompletion(_ context.Context, writer io.Writer, rfpath string) fmt.Fprintf(writer, "%s\n", k) } - m, err := runfile.ParseIncludes() - if err != nil { - slog.Error("parsing, got", "err", err) - panic(err) - } + // m, err := runfile.ParseIncludes() + // if err != nil { + // slog.Error("parsing, got", "err", err) + // panic(err) + // } - for k, v := range m { - for tn := range v.Runfile.Tasks { - fmt.Fprintf(writer, "%s:%s\n", k, tn) - } - } + // for k, v := range m { + // for tn := range v.Runfile.Tasks { + // fmt.Fprintf(writer, "%s:%s\n", k, tn) + // } + // } return nil } diff --git a/cmd/run/completions/bash/run.bash b/cmd/run/completions/run.bash similarity index 100% rename from cmd/run/completions/bash/run.bash rename to cmd/run/completions/run.bash diff --git a/cmd/run/completions/fish/run.fish b/cmd/run/completions/run.fish similarity index 100% rename from cmd/run/completions/fish/run.fish rename to cmd/run/completions/run.fish diff --git a/cmd/run/completions/ps/run.ps b/cmd/run/completions/run.ps similarity index 100% rename from cmd/run/completions/ps/run.ps rename to cmd/run/completions/run.ps diff --git a/cmd/run/completions/zsh/run.zsh b/cmd/run/completions/run.zsh similarity index 100% rename from cmd/run/completions/zsh/run.zsh rename to cmd/run/completions/run.zsh diff --git a/cmd/run/main.go b/cmd/run/main.go index c6e1215..2516782 100644 --- a/cmd/run/main.go +++ b/cmd/run/main.go @@ -10,13 +10,20 @@ import ( "path/filepath" "strings" "syscall" + "time" - "github.com/nxtcoder17/runfile/pkg/logging" - "github.com/nxtcoder17/runfile/pkg/runfile" + "github.com/nxtcoder17/runfile/errors" + "github.com/nxtcoder17/runfile/logging" + "github.com/nxtcoder17/runfile/runner" + + // "github.com/nxtcoder17/runfile/pkg/runfile" + + // "github.com/nxtcoder17/runfile/pkg/runfile" + "github.com/nxtcoder17/runfile/parser" "github.com/urfave/cli/v3" ) -var Version string = "nightly" +var Version string = fmt.Sprintf("nightly | %s", time.Now().Format(time.RFC3339)) var runfileNames []string = []string{ "Runfile", @@ -24,16 +31,16 @@ var runfileNames []string = []string{ "Runfile.yaml", } -//go:embed completions/fish/run.fish +//go:embed completions/run.fish var shellCompletionFISH string -//go:embed completions/bash/run.bash +//go:embed completions/run.bash var shellCompletionBASH string -//go:embed completions/zsh/run.zsh +//go:embed completions/run.zsh var shellCompletionZSH string -//go:embed completions/ps/run.ps +//go:embed completions/run.ps var shellCompletionPS string func main() { @@ -72,8 +79,10 @@ func main() { Aliases: []string{"ls"}, }, }, + // ShellCompletionCommandName: "completion:shell", EnableShellCompletion: true, + // DefaultCommand: "help", ShellComplete: func(ctx context.Context, c *cli.Command) { if c.NArg() > 0 { @@ -88,6 +97,33 @@ func main() { generateShellCompletion(ctx, c.Root().Writer, runfilePath) }, + + Commands: []*cli.Command{ + { + Name: "shell:completion", + Suggest: true, + Action: func(ctx context.Context, c *cli.Command) error { + if c.NArg() != 2 { + return fmt.Errorf("needs argument one of [bash,zsh,fish,ps]") + } + + switch c.Args().Slice()[1] { + case "fish": + fmt.Fprint(c.Writer, shellCompletionFISH) + case "bash": + fmt.Fprint(c.Writer, shellCompletionBASH) + case "zsh": + fmt.Fprint(c.Writer, shellCompletionZSH) + case "ps": + fmt.Fprint(c.Writer, shellCompletionPS) + } + + return nil + }, + }, + }, + + Suggest: true, Action: func(ctx context.Context, c *cli.Command) error { parallel := c.Bool("parallel") watch := c.Bool("watch") @@ -114,7 +150,7 @@ func main() { return err } - rf, err2 := runfile.Parse(runfilePath) + rf, err2 := parser.Parse(runfilePath) if err2 != nil { slog.Error("parsing runfile, got", "err", err2) panic(err2) @@ -154,45 +190,31 @@ func main() { } logger := logging.New(logging.Options{ + ShowCaller: true, SlogKeyAsPrefix: "task", ShowDebugLogs: debug, SetAsDefaultLogger: true, }) - return rf.Run(runfile.NewContext(ctx, logger), runfile.RunArgs{ + return runner.Run(runner.NewContext(ctx, logger), rf, runner.RunArgs{ Tasks: args, ExecuteInParallel: parallel, Watch: watch, Debug: debug, KVs: kv, }) - }, - Commands: []*cli.Command{ - { - Name: "shell:completion", - Action: func(ctx context.Context, c *cli.Command) error { - if c.NArg() != 2 { - return fmt.Errorf("needs argument one of [bash,zsh,fish,ps]") - } - - switch c.Args().Slice()[1] { - case "fish": - fmt.Fprint(c.Writer, shellCompletionFISH) - case "bash": - fmt.Fprint(c.Writer, shellCompletionBASH) - case "zsh": - fmt.Fprint(c.Writer, shellCompletionZSH) - case "ps": - fmt.Fprint(c.Writer, shellCompletionPS) - } - return nil - }, - }, + // return rf.Run(runfile.NewContext(ctx, logger), runfile.RunArgs{ + // Tasks: args, + // ExecuteInParallel: parallel, + // Watch: watch, + // Debug: debug, + // KVs: kv, + // }) }, } - ctx, cf := signal.NotifyContext(context.TODO(), os.Interrupt, syscall.SIGTERM) + ctx, cf := signal.NotifyContext(context.TODO(), syscall.SIGINT, syscall.SIGTERM) defer cf() go func() { @@ -202,10 +224,13 @@ func main() { }() if err := cmd.Run(ctx, os.Args); err != nil { - errm, ok := err.(*runfile.Error) + errm, ok := err.(*errors.Error) slog.Debug("got", "err", err) if ok { if errm != nil { + // errm.Error() + // TODO: change it to a better logging + // slog.Error("got", "err", errm) errm.Log() } } else { diff --git a/errors/errors.go b/errors/errors.go new file mode 100644 index 0000000..9ef69e5 --- /dev/null +++ b/errors/errors.go @@ -0,0 +1,98 @@ +package errors + +import ( + "fmt" + "log/slog" + "runtime" +) + +type Error struct { + msg string + // kv is a slice of slog Attributes i.e. ("key", "value") + keys []string + // kv map[string]any + kv []any + + traces []string + + err error +} + +// Error implements error. +func (e *Error) Error() string { + return fmt.Sprintf("%v {%#v}", e.err, e.kv) +} + +func (e *Error) Log() { + args := make([]any, 0, len(e.kv)) + args = append(args, e.kv...) + args = append(args, "traces", e.traces) + slog.Error(e.msg, args...) +} + +var _ error = (*Error)(nil) + +func Err(msg string) *Error { + return &Error{msg: msg} +} + +func (e *Error) Wrap(err error) *Error { + _, file, line, _ := runtime.Caller(1) + e.traces = append(e.traces, fmt.Sprintf("%s:%d", file, line)) + e.err = err + return e +} + +func (e *Error) WrapStr(msg string) *Error { + e.err = fmt.Errorf(msg) + return e +} + +func (e *Error) KV(kv ...any) *Error { + // if e.kv == nil { + // e.kv = make(map[string]any) + // } + + // for i := 0; i < len(kv); i += 2 { + // // e.keys = append(e.keys, kv[i].(string)) + // e.kv[kv[i].(string)] = kv[i+1] + // } + e.kv = append(e.kv, kv...) + + return e +} + +func WithErr(err error) *Error { + _, file, line, _ := runtime.Caller(1) + err2, ok := err.(*Error) + if !ok { + err2 = &Error{err: err} + } + + err2.traces = append(err2.traces, fmt.Sprintf("%s:%d", file, line)) + return err2 +} + +// ERROR constants +var ( + ErrReadRunfile = Err("failed to read runfile") + ErrParseRunfile = Err("failed to read runfile") + + ErrParseIncludes = Err("failed to parse includes") + ErrParseDotEnv = Err("failed to parse dotenv file") + ErrInvalidDotEnv = Err("invalid dotenv file") + + ErrInvalidEnvVar = Err("invalid env var") + ErrRequiredEnvVar = Err("required env var") + ErrInvalidDefaultValue = Err("invalid default value for env var") + + ErrEvalEnvVarSh = Err("failed while executing env-var sh script") + + ErrTaskNotFound = Err("task not found") + ErrTaskFailed = Err("task failed") + ErrTaskParsingFailed = Err("task parsing failed") + ErrTaskRequirementNotMet = Err("task requirements not met") + ErrTaskInvalidWorkingDir = Err("task invalid working directory") + + ErrTaskInvalidCommand = Err("task invalid command") +) diff --git a/examples/Runfile.yml b/examples/Runfile.yml index e08b4f2..1b44a33 100644 --- a/examples/Runfile.yml +++ b/examples/Runfile.yml @@ -62,11 +62,11 @@ tasks: laundry: name: laundry shell: ["node", "-e"] - env: - k4: - default: - sh: |+ - console.log('1234' == '23344') + # env: + # k4: + # default: + # sh: |+ + # console.log('1234' == '23344') cmd: - run: cook - console.log(process.env.k4) diff --git a/pkg/functions/helpers.go b/functions/helpers.go similarity index 67% rename from pkg/functions/helpers.go rename to functions/helpers.go index 51b6ccb..8d67603 100644 --- a/pkg/functions/helpers.go +++ b/functions/helpers.go @@ -1,5 +1,10 @@ package functions +import ( + "fmt" + "os" +) + func DefaultIfNil[T any](v *T, dv T) T { if v == nil { return dv @@ -20,3 +25,11 @@ func Must[T any](v T, err error) T { func New[T any](v T) *T { return &v } + +func ToEnviron(m map[string]string) []string { + results := os.Environ() + for k, v := range m { + results = append(results, fmt.Sprintf("%s=%v", k, v)) + } + return results +} diff --git a/pkg/functions/maps.go b/functions/maps.go similarity index 100% rename from pkg/functions/maps.go rename to functions/maps.go diff --git a/go.mod b/go.mod index 57fe56a..f989270 100644 --- a/go.mod +++ b/go.mod @@ -3,22 +3,25 @@ module github.com/nxtcoder17/runfile go 1.22.7 require ( + github.com/alecthomas/chroma v0.10.0 github.com/alecthomas/chroma/v2 v2.14.0 github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbletea v1.2.4 github.com/charmbracelet/lipgloss v1.0.0 github.com/go-task/slim-sprig/v3 v3.0.0 github.com/joho/godotenv v1.5.1 + github.com/muesli/termenv v0.15.2 + github.com/nxtcoder17/fwatcher v1.0.3-0.20241210142126-8465e393708a github.com/phuslu/log v1.0.112 - github.com/urfave/cli/v3 v3.0.0-alpha9 - golang.org/x/sync v0.9.0 + github.com/urfave/cli/v3 v3.0.0-beta1 + golang.org/x/sync v0.10.0 sigs.k8s.io/yaml v1.4.0 ) require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/x/ansi v0.4.5 // indirect + github.com/charmbracelet/x/ansi v0.5.2 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect @@ -29,10 +32,9 @@ require ( 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.15.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/stretchr/testify v1.9.0 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect - golang.org/x/sys v0.27.0 // indirect + golang.org/x/sys v0.28.0 // indirect golang.org/x/text v0.3.8 // indirect ) diff --git a/go.sum b/go.sum index 30e8090..8dc321e 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= +github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= @@ -16,12 +18,14 @@ github.com/charmbracelet/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM= github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= -github.com/charmbracelet/x/ansi v0.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSexTdRM= -github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/ansi v0.5.2 h1:dEa1x2qdOZXD/6439s+wF7xjV+kZLu/iN00GuXXrU9E= +github.com/charmbracelet/x/ansi v0.5.2/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= @@ -49,6 +53,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/nxtcoder17/fwatcher v1.0.3-0.20241210142126-8465e393708a h1:lAVn2+j+f1Nfe5ndya0ByTAZ30h5Y7bb4kSMQQXGVAw= +github.com/nxtcoder17/fwatcher v1.0.3-0.20241210142126-8465e393708a/go.mod h1:MNmSwXYOrqp7U1pUxh0GWB5skpjFTWTQXhAA0+sPJcU= github.com/phuslu/log v1.0.112 h1:vQ0ZFd5O+in/0IQAcjuEl6wRkHiQPw7T0sqwmOjpL0U= github.com/phuslu/log v1.0.112/go.mod h1:F8osGJADo5qLK/0F88djWwdyoZZ9xDJQL1HYRHFEkS0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -56,22 +62,27 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN 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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/urfave/cli/v3 v3.0.0-alpha9 h1:P0RMy5fQm1AslQS+XCmy9UknDXctOmG/q/FZkUFnJSo= github.com/urfave/cli/v3 v3.0.0-alpha9/go.mod h1:0kK/RUFHyh+yIKSfWxwheGndfnrvYSmYFVeKCh03ZUc= +github.com/urfave/cli/v3 v3.0.0-beta1 h1:6DTaaUarcM0wX7qj5Hcvs+5Dm3dyUTBbEwIWAjcw9Zg= +github.com/urfave/cli/v3 v3.0.0-beta1/go.mod h1:FnIeEMYu+ko8zP1F9Ypr3xkZMIDqW3DR92yUtY39q1Y= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= -golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= -golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= diff --git a/pkg/logging/logger.go b/logging/logger.go similarity index 84% rename from pkg/logging/logger.go rename to logging/logger.go index 8b92ce2..1867f4d 100644 --- a/pkg/logging/logger.go +++ b/logging/logger.go @@ -5,6 +5,8 @@ import ( "io" "log/slog" "os" + "path/filepath" + "runtime" "github.com/phuslu/log" ) @@ -86,11 +88,26 @@ func New(opts Options) *slog.Logger { fmt.Fprintf(w, "%s ", opts.Theme.LogLevelStyles[ParseLogLevel(args.Level)].Render(args.Level)) } + if opts.ShowCaller { + _, file, line, _ := runtime.Caller(7) + fmt.Fprintf(w, "%s:%d ", file, line) + } + fmt.Fprint(w, opts.Theme.MessageStyle.Render(args.Message)) for i := range args.KeyValues { if args.KeyValues[i].Key == opts.SlogKeyAsPrefix { continue } + + if args.KeyValues[i].Key == "runfile" { + pwd, _ := os.Getwd() + relpath, _ := filepath.Rel(pwd, args.KeyValues[i].Value) + if relpath == filepath.Base(args.KeyValues[i].Value) { + continue + } + args.KeyValues[i].Value = relpath + } + fmt.Fprintf(w, " %s%s%v", opts.Theme.SlogKeyStyle.Render(args.KeyValues[i].Key), opts.Theme.SlogKeyStyle.Faint(true).Render(opts.keyValueSeparator), opts.Theme.MessageStyle.Render(args.KeyValues[i].Value)) } diff --git a/pkg/logging/theme.go b/logging/theme.go similarity index 100% rename from pkg/logging/theme.go rename to logging/theme.go diff --git a/parser/parse-command.go b/parser/parse-command.go new file mode 100644 index 0000000..70143ae --- /dev/null +++ b/parser/parse-command.go @@ -0,0 +1,52 @@ +package parser + +import ( + "encoding/json" + "fmt" + + "github.com/nxtcoder17/runfile/errors" + "github.com/nxtcoder17/runfile/types" +) + +func parseCommand(prf *types.ParsedRunfile, command any) (*types.ParsedCommandJson, error) { + ferr := func(err error) error { + return errors.ErrTaskInvalidCommand.Wrap(err).KV("command", command) + } + + switch c := command.(type) { + case string: + { + return &types.ParsedCommandJson{Command: c}, nil + } + case map[string]any: + { + var cj types.CommandJson + b, err := json.Marshal(c) + if err != nil { + return nil, ferr(err) + } + + if err := json.Unmarshal(b, &cj); err != nil { + return nil, ferr(err) + } + + if cj.Run == "" && cj.Command == "" { + return nil, ferr(fmt.Errorf("key: 'run' or 'cmd', must be specified when setting command in json format")) + } + + var pcj types.ParsedCommandJson + pcj.Run = cj.Run + pcj.Command = cj.Command + + if _, ok := prf.Tasks[cj.Run]; !ok { + return nil, errors.ErrTaskNotFound.Wrap(fmt.Errorf("run target, not found")).KV("command", command, "run-target", cj.Run) + } + + return &pcj, nil + } + default: + { + return nil, ferr(fmt.Errorf("invalid command type, must be either a string or an object")) + } + } +} diff --git a/parser/parse-dotenv.go b/parser/parse-dotenv.go new file mode 100644 index 0000000..2331f92 --- /dev/null +++ b/parser/parse-dotenv.go @@ -0,0 +1,48 @@ +package parser + +import ( + "fmt" + "io" + "os" + "path/filepath" + + "github.com/joho/godotenv" + "github.com/nxtcoder17/runfile/errors" +) + +func parseDotEnv(reader io.Reader) (map[string]string, error) { + m, err := godotenv.Parse(reader) + if err != nil { + return nil, errors.ErrParseDotEnv.Wrap(err) + } + return m, nil +} + +// parseDotEnv parses the .env file and returns a slice of strings as in os.Environ() + +func parseDotEnvFiles(files ...string) (map[string]string, error) { + results := make(map[string]string) + + for i := range files { + if !filepath.IsAbs(files[i]) { + return nil, errors.ErrInvalidDotEnv.Wrap(fmt.Errorf("dotenv file paths must be absolute")).KV("dotenv", files[i]) + } + + f, err := os.Open(files[i]) + if err != nil { + return nil, errors.ErrInvalidDotEnv.Wrap(err).KV("dotenv", files[i]) + } + + m, err2 := parseDotEnv(f) + if err2 != nil { + return nil, errors.ErrInvalidDotEnv.KV("dotenv", files[i]) + } + f.Close() + + for k, v := range m { + results[k] = v + } + } + + return results, nil +} diff --git a/pkg/runfile/parser_test.go b/parser/parse-dotenv_test.go similarity index 87% rename from pkg/runfile/parser_test.go rename to parser/parse-dotenv_test.go index a33331f..a485683 100644 --- a/pkg/runfile/parser_test.go +++ b/parser/parse-dotenv_test.go @@ -1,4 +1,4 @@ -package runfile +package parser import ( "bytes" @@ -7,10 +7,11 @@ import ( "testing" ) -func Test_parseDotEnvFile(t *testing.T) { +func Test_ParseDotEnvFile(t *testing.T) { type args struct { reader io.Reader } + tests := []struct { name string args args @@ -18,7 +19,7 @@ func Test_parseDotEnvFile(t *testing.T) { wantErr bool }{ { - name: "key=", + name: "1. key=", args: args{ reader: bytes.NewBuffer([]byte(`key=`)), }, @@ -28,7 +29,7 @@ func Test_parseDotEnvFile(t *testing.T) { wantErr: false, }, { - name: "key=1", + name: "2. key=1", args: args{ reader: bytes.NewBuffer([]byte(`key=1`)), }, @@ -38,7 +39,7 @@ func Test_parseDotEnvFile(t *testing.T) { wantErr: false, }, { - name: "key=one", + name: "3. key=one", args: args{ reader: bytes.NewBuffer([]byte(`key=one`)), }, @@ -48,7 +49,7 @@ func Test_parseDotEnvFile(t *testing.T) { wantErr: false, }, { - name: "key='one'", + name: "4. key='one'", args: args{ reader: bytes.NewBuffer([]byte(`key='one'`)), }, @@ -58,7 +59,7 @@ func Test_parseDotEnvFile(t *testing.T) { wantErr: false, }, { - name: `key='o"ne'`, + name: `5. key='o"ne'`, args: args{ reader: bytes.NewBuffer([]byte(`key='o"ne'`)), }, @@ -68,7 +69,7 @@ func Test_parseDotEnvFile(t *testing.T) { wantErr: false, }, { - name: `key="one"`, + name: `6. key="one"`, args: args{ reader: bytes.NewBuffer([]byte(`key="one"`)), }, @@ -78,7 +79,7 @@ func Test_parseDotEnvFile(t *testing.T) { wantErr: false, }, { - name: `key=sample==`, + name: `7. key=sample==`, args: args{ reader: bytes.NewBuffer([]byte(`key=sample==`)), }, diff --git a/parser/parse-env.go b/parser/parse-env.go new file mode 100644 index 0000000..c30fbe0 --- /dev/null +++ b/parser/parse-env.go @@ -0,0 +1,126 @@ +package parser + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "os/exec" + "strings" + + "github.com/nxtcoder17/runfile/errors" + fn "github.com/nxtcoder17/runfile/functions" + "github.com/nxtcoder17/runfile/types" +) + +type evaluationParams struct { + Env map[string]string +} + +/* +EnvVar can be provided in multiple forms: + +> key1: "value1" +or, + +> key1: +> default: "value1" +or, + +> key1: +> default: +> sh: "echo value1" +or, + +> key1: +> required: true +or, + +> key1: +> sh: "echo hi" +*/ +func parseEnvVars(ctx context.Context, ev types.EnvVar, params evaluationParams) (map[string]string, error) { + env := make(map[string]string, len(ev)) + for k, v := range ev { + attr := []any{slog.Group("env", "key", k, "value", v)} + switch v := v.(type) { + case string: + env[k] = v + case map[string]any: + if ev, ok := os.LookupEnv(k); ok { + env[k] = ev + continue + } + + if s, ok := params.Env[k]; ok { + env[k] = s + continue + } + + // CASE: not found + + // handle field: "required" + if hasRequired, ok := v["required"]; ok { + required, ok := hasRequired.(bool) + if !ok { + return nil, errors.ErrInvalidEnvVar.Wrap(fmt.Errorf("required field must be a boolean")).KV(attr...) + } + + if required { + return nil, errors.ErrRequiredEnvVar.KV(attr...) + } + } + + if defaultVal, ok := v["default"]; ok { + pDefaults, err := parseEnvVars(ctx, types.EnvVar{k: defaultVal}, params) + if err != nil { + return nil, errors.ErrInvalidDefaultValue.Wrap(err).KV(attr...) + } + + if dv, ok := pDefaults[k]; ok { + env[k] = dv + continue + } + } + + b, err := json.Marshal(v) + if err != nil { + return nil, errors.ErrInvalidEnvVar.Wrap(err).KV(attr...) + } + + var specials struct { + Sh *string `json:"sh"` + } + + if err := json.Unmarshal(b, &specials); err != nil { + return nil, errors.ErrInvalidEnvVar.Wrap(err).KV(attr...) + } + + switch { + case specials.Sh != nil: + { + value := new(bytes.Buffer) + cmd := exec.CommandContext(ctx, "sh", "-c", *specials.Sh) + cmd.Env = fn.ToEnviron(params.Env) + cmd.Stdout = value + if err := cmd.Run(); err != nil { + return nil, errors.ErrEvalEnvVarSh.Wrap(err).KV(attr...) + } + + env[k] = strings.TrimSpace(value.String()) + } + default: + { + return nil, errors.ErrInvalidEnvVar.Wrap(fmt.Errorf("invalid env format")).KV(attr...) + } + } + + default: + env[k] = fmt.Sprintf("%v", v) + } + } + + return env, nil +} diff --git a/parser/parse-env_test.go b/parser/parse-env_test.go new file mode 100644 index 0000000..7c70a9f --- /dev/null +++ b/parser/parse-env_test.go @@ -0,0 +1,127 @@ +package parser + +import ( + "context" + "reflect" + "testing" + + . "github.com/nxtcoder17/runfile/types" +) + +func Test_ParseEnvVars(t *testing.T) { + type args struct { + envVars EnvVar + testingEnv map[string]string + } + + type test struct { + name string + args args + want map[string]string + wantErr bool + } + + tests := []test{ + { + name: "1. must fail [when] required env is not provided", + args: args{ + envVars: EnvVar{ + "hello": map[string]any{ + "required": true, + }, + }, + testingEnv: nil, + }, + want: nil, + wantErr: true, + }, + { + name: "2. must pass [when] required env is provided", + args: args{ + envVars: EnvVar{ + "hello": map[string]any{ + "required": true, + }, + }, + testingEnv: map[string]string{ + "hello": "world", + }, + }, + want: map[string]string{ + "hello": "world", + }, + wantErr: false, + }, + { + name: "3. must fail [when] default not provided", + args: args{ + envVars: EnvVar{ + "hello": map[string]any{ + "required": true, + }, + }, + }, + wantErr: true, + }, + { + name: "4. must pass [when] default value is provided", + args: args{ + envVars: EnvVar{ + "hello": map[string]any{ + "default": "world", + }, + }, + testingEnv: nil, + }, + want: map[string]string{ + "hello": "world", + }, + wantErr: false, + }, + { + name: "5. must fail [when] default sh command exits with non-zero", + args: args{ + envVars: EnvVar{ + "hello": map[string]any{ + "default": map[string]any{ + "sh": "exit 1", + }, + }, + }, + }, + wantErr: true, + }, + { + name: "6. must pass [when] default sh command exits with zero", + args: args{ + envVars: EnvVar{ + "hello": map[string]any{ + "default": map[string]any{ + "sh": "echo hi", + }, + }, + }, + }, + want: map[string]string{ + "hello": "hi", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseEnvVars(context.TODO(), tt.args.envVars, evaluationParams{ + Env: tt.args.testingEnv, + }) + if (err != nil) != tt.wantErr { + t.Errorf("ParseEnvVars():> got = %v, error = %v, wantErr %v", got, err, tt.wantErr) + return + } + + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("parseEnvVars():> \n\tgot:\t%v,\n\twant:\t%v", got, tt.want) + } + }) + } +} diff --git a/parser/parse-includes.go b/parser/parse-includes.go new file mode 100644 index 0000000..7109c60 --- /dev/null +++ b/parser/parse-includes.go @@ -0,0 +1,28 @@ +package parser + +import ( + "github.com/nxtcoder17/runfile/errors" + "github.com/nxtcoder17/runfile/types" +) + +func parseIncludes(includes map[string]types.IncludeSpec) (map[string]*types.ParsedRunfile, error) { + m := make(map[string]*types.ParsedRunfile, len(includes)) + for k, v := range includes { + r, err := parse(v.Runfile) + if err != nil { + return nil, errors.ErrParseIncludes.Wrap(err).KV("include", v.Runfile) + } + + for it := range r.Tasks { + if v.Dir != "" { + nt := r.Tasks[it] + nt.Dir = &v.Dir + r.Tasks[it] = nt + } + } + + m[k] = r + } + + return m, nil +} diff --git a/parser/parse-task.go b/parser/parse-task.go new file mode 100644 index 0000000..b69f921 --- /dev/null +++ b/parser/parse-task.go @@ -0,0 +1,98 @@ +package parser + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/nxtcoder17/runfile/errors" + fn "github.com/nxtcoder17/runfile/functions" + "github.com/nxtcoder17/runfile/types" +) + +func ParseTask(ctx context.Context, prf *types.ParsedRunfile, task types.Task) (*types.ParsedTask, error) { + workingDir := filepath.Dir(prf.Metadata.RunfilePath) + if task.Metadata.RunfilePath != nil { + workingDir = filepath.Dir(*task.Metadata.RunfilePath) + } + + taskEnv := prf.Env + + if taskEnv == nil { + taskEnv = make(map[string]string) + } + + tdotenv, err := parseDotEnvFiles(task.DotEnv...) + if err != nil { + return nil, err + } + + for k, v := range tdotenv { + taskEnv[k] = v + } + + tenv, err := parseEnvVars(ctx, task.Env, evaluationParams{ + Env: prf.Env, + }) + if err != nil { + return nil, err + } + + for k, v := range tenv { + taskEnv[k] = v + } + + for _, requirement := range task.Requires { + if requirement == nil { + continue + } + + if requirement.Sh != nil { + cmd := exec.CommandContext(ctx, "sh", "-c", *requirement.Sh) + cmd.Env = fn.ToEnviron(taskEnv) + cmd.Stdout = fn.Must(os.OpenFile(os.DevNull, os.O_WRONLY, 0o755)) + cmd.Stderr = fn.Must(os.OpenFile(os.DevNull, os.O_WRONLY, 0o755)) + cmd.Dir = workingDir + if err := cmd.Run(); err != nil { + return nil, errors.ErrTaskRequirementNotMet.Wrap(err).KV("requirement", *requirement.Sh) + } + continue + } + } + + if task.Shell == nil { + task.Shell = []string{"sh", "-c"} + } + + if task.Dir == nil { + task.Dir = fn.New(fn.Must(os.Getwd())) + } + + fi, err := os.Stat(*task.Dir) + if err != nil { + return nil, errors.ErrTaskInvalidWorkingDir.Wrap(err).KV("working-dir", *task.Dir) + } + + if !fi.IsDir() { + return nil, errors.ErrTaskInvalidWorkingDir.Wrap(fmt.Errorf("path is not a directory")).KV("working-dir", *task.Dir) + } + + commands := make([]types.ParsedCommandJson, 0, len(task.Commands)) + for i := range task.Commands { + c2, err := parseCommand(prf, task.Commands[i]) + if err != nil { + return nil, err + } + commands = append(commands, *c2) + } + + return &types.ParsedTask{ + Shell: task.Shell, + WorkingDir: *task.Dir, + Interactive: task.Interactive, + Env: taskEnv, + Commands: commands, + }, nil +} diff --git a/parser/parse-task_test.go b/parser/parse-task_test.go new file mode 100644 index 0000000..b1cb638 --- /dev/null +++ b/parser/parse-task_test.go @@ -0,0 +1,615 @@ +package parser + +import ( + "context" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + fn "github.com/nxtcoder17/runfile/functions" + . "github.com/nxtcoder17/runfile/types" +) + +func Test_ParseTask(t *testing.T) { + type args struct { + ctx context.Context + rf *ParsedRunfile + taskName string + } + + areEqual := func(t *testing.T, got, want *ParsedTask) bool { + if want == nil { + return false + } + + if strings.Join(got.Shell, ",") != strings.Join(want.Shell, ",") { + t.Logf("shell not equal") + 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 + } + + gkeys := fn.MapKeys(got.Env) + + for _, k := range gkeys { + v, ok := want.Env[k] + if !ok || v != got.Env[k] { + t.Logf("environments not equal") + return false + } + } + + if got.WorkingDir != want.WorkingDir { + t.Logf("working dir not equal") + return false + } + + if fmt.Sprintf("%#v", got.Commands) != fmt.Sprintf("%#v", want.Commands) { + t.Logf("commands not equal:\n got:\t%#v\nwant:\t%#v", got.Commands, want.Commands) + return false + } + + return true + } + + // for dotenv test + dotenvTestFile, err := os.CreateTemp(os.TempDir(), ".env") + if err != nil { + t.Error(err) + return + } + fmt.Fprintf(dotenvTestFile, "hello=world\n") + dotenvTestFile.Close() + + type test struct { + name string + args args + want *ParsedTask + wantErr bool + } + + // testRequires := []test{ + // { + // name: "1: [requires] condition specified, but it neither has 'sh' or 'gotmpl' key", + // args: args{ + // rf: &ParsedRunfile{ + // Tasks: map[string]Task{ + // "test": { + // Shell: nil, + // // ignoreSystemEnv: true, + // Requires: []*Requires{ + // {}, + // }, + // Commands: nil, + // }, + // }, + // }, + // taskName: "test", + // }, + // want: &ParsedTask{ + // Shell: []string{"sh", "-c"}, + // WorkingDir: fn.Must(os.Getwd()), + // Commands: []ParsedCommandJson{}, + // }, + // wantErr: false, + // }, + // + // { + // name: "[requires] condition specified, with gotmpl key", + // args: args{ + // rf: &ParsedRunfile{ + // Tasks: map[string]Task{ + // "test": { + // Shell: nil, + // Requires: []*Requires{ + // { + // GoTmpl: fn.New(`eq 5 5`), + // }, + // }, + // Commands: nil, + // }, + // }, + // }, + // taskName: "test", + // }, + // want: &ParsedTask{ + // Shell: []string{"sh", "-c"}, + // WorkingDir: fn.Must(os.Getwd()), + // Commands: []ParsedCommandJson{}, + // }, + // wantErr: false, + // }, + // + // { + // name: "[requires] condition specified, with sh key", + // args: args{ + // rf: &ParsedRunfile{ + // Tasks: map[string]Task{ + // "test": { + // Shell: nil, + // Requires: []*Requires{ + // { + // Sh: fn.New(`echo hello`), + // }, + // }, + // Commands: nil, + // }, + // }, + // }, + // taskName: "test", + // }, + // want: &ParsedTask{ + // Shell: []string{"sh", "-c"}, + // WorkingDir: fn.Must(os.Getwd()), + // Commands: []ParsedCommandJson{}, + // }, + // wantErr: false, + // }, + // + // { + // name: "[unhappy/requires] condition specified, with sh key", + // args: args{ + // rf: &ParsedRunfile{ + // Tasks: map[string]Task{ + // "test": { + // Shell: nil, + // Requires: []*Requires{ + // { + // Sh: fn.New(`echo hello && exit 1`), + // }, + // }, + // Commands: nil, + // }, + // }, + // }, + // taskName: "test", + // }, + // want: &ParsedTask{ + // Shell: []string{"sh", "-c"}, + // WorkingDir: fn.Must(os.Getwd()), + // Commands: []ParsedCommandJson{}, + // }, + // wantErr: true, + // }, + // } + + tests := []test{ + { + name: "[shell] if not specified, defaults to [sh, -c]", + args: args{ + ctx: nil, + rf: &ParsedRunfile{ + Tasks: map[string]Task{ + "test": { + Shell: nil, + Dir: fn.New("."), + Commands: nil, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + WorkingDir: ".", + Commands: []ParsedCommandJson{}, + }, + wantErr: false, + }, + { + name: "[shell] if specified, must be acknowledged", + args: args{ + ctx: nil, + rf: &ParsedRunfile{ + Tasks: map[string]Task{ + "test": { + Shell: []string{"python", "-c"}, + Dir: fn.New("."), + Commands: nil, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"python", "-c"}, + WorkingDir: ".", + Commands: []ParsedCommandJson{}, + }, + wantErr: false, + }, + { + name: "[env] key: value", + args: args{ + ctx: nil, + rf: &ParsedRunfile{ + Tasks: map[string]Task{ + "test": { + Shell: []string{"sh", "-c"}, + Env: map[string]any{ + "hello": "hi", + "k1": 1, + }, + Dir: fn.New("."), + Commands: nil, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + Env: map[string]string{ + "hello": "hi", + "k1": "1", + }, + WorkingDir: ".", + Commands: []ParsedCommandJson{}, + }, + wantErr: false, + }, + { + name: "[env] key: JSON object format", + args: args{ + ctx: nil, + rf: &ParsedRunfile{ + Tasks: map[string]Task{ + "test": { + Env: map[string]any{ + "hello": map[string]any{ + "sh": "echo hi", + }, + }, + Dir: fn.New("."), + Commands: nil, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + Env: map[string]string{ + "hello": "hi", + }, + WorkingDir: ".", + Commands: []ParsedCommandJson{}, + }, + wantErr: false, + }, + { + name: "[unhappy/env] JSON object format [must throw err, when] sh key does not exist in value", + args: args{ + rf: &ParsedRunfile{ + Tasks: map[string]Task{ + "test": { + Env: map[string]any{ + "k1": map[string]any{ + "asdfasf": "asdfad", + }, + }, + }, + }, + }, + taskName: "test", + }, + wantErr: true, + }, + { + name: "[unhappy/env] JSON object format [must throw err, when] sh (key)'s value is not a string", + args: args{ + rf: &ParsedRunfile{ + Tasks: map[string]Task{ + "test": { + Env: map[string]any{ + "k1": map[string]any{ + "sh": []string{"asdfsadf"}, + }, + }, + }, + }, + }, + taskName: "test", + }, + wantErr: true, + }, + { + name: "[dotenv] loads environment from given file", + args: args{ + rf: &ParsedRunfile{ + Tasks: map[string]Task{ + "test": { + DotEnv: []string{ + dotenvTestFile.Name(), + }, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + WorkingDir: fn.Must(os.Getwd()), + Env: map[string]string{ + "hello": "world", + }, + Commands: []ParsedCommandJson{}, + }, + wantErr: false, + }, + { + name: "[unhappy/dotenv] throws err, when file does not exist", + args: args{ + rf: &ParsedRunfile{ + Tasks: map[string]Task{ + "test": { + DotEnv: []string{ + "/tmp/env-aasfksadjfkl", + }, + }, + }, + }, + taskName: "test", + }, + wantErr: true, + }, + { + name: "[unhappy/dotenv] throws err, when filepath exists [but] is not a file (might be a directory or something else)", + args: args{ + rf: &ParsedRunfile{ + Tasks: map[string]Task{ + "test": { + DotEnv: []string{ + "/tmp", + }, + }, + }, + }, + taskName: "test", + }, + wantErr: true, + }, + { + name: "[working_dir] if not specified, should be current working directory", + args: args{ + ctx: nil, + rf: &ParsedRunfile{ + Tasks: map[string]Task{ + "test": { + Commands: nil, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + WorkingDir: fn.Must(os.Getwd()), + Commands: []ParsedCommandJson{}, + }, + wantErr: false, + }, + { + name: "[working_dir] when specified, must be acknowledged", + args: args{ + ctx: nil, + rf: &ParsedRunfile{ + Tasks: map[string]Task{ + "test": { + Dir: fn.New("/tmp"), + Commands: nil, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + WorkingDir: "/tmp", + Commands: []ParsedCommandJson{}, + }, + wantErr: false, + }, + { + name: "[unhappy/working_dir] must throw err, when directory does not exist", + args: args{ + rf: &ParsedRunfile{ + Tasks: map[string]Task{ + "test": { + Dir: fn.New("/tmp/xsdfjasdfkjdskfjasl"), + }, + }, + }, + taskName: "test", + }, + wantErr: true, + }, + { + name: "[unhappy/working_dir] must throw err, when directory specified is not a directory (might be something else, or a file)", + args: args{ + rf: &ParsedRunfile{ + Tasks: map[string]Task{ + "test": { + Dir: fn.New(filepath.Join(fn.Must(os.Getwd()), "task.go")), + }, + }, + }, + taskName: "test", + }, + wantErr: true, + }, + { + name: "[commands] string commands: single line", + args: args{ + ctx: nil, + rf: &ParsedRunfile{ + Tasks: map[string]Task{ + "test": { + Commands: []any{ + "echo hello", + }, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + WorkingDir: fn.Must(os.Getwd()), + Commands: []ParsedCommandJson{ + {Command: "echo hello"}, + }, + }, + wantErr: false, + }, + + { + name: "[commands] string commands: multiline", + args: args{ + ctx: nil, + rf: &ParsedRunfile{ + Tasks: map[string]Task{ + "test": { + Commands: []any{ + ` +echo "hello" +echo "hi" +`, + }, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + WorkingDir: fn.Must(os.Getwd()), + Commands: []ParsedCommandJson{ + { + Command: ` +echo "hello" +echo "hi" +`, + }, + }, + }, + wantErr: false, + }, + + { + name: "[commands] JSON commands", + args: args{ + ctx: nil, + rf: &ParsedRunfile{ + Tasks: map[string]Task{ + "test": { + Commands: []any{ + "echo i will call hello, now", + map[string]any{ + "run": "hello", + }, + }, + }, + "hello": { + Commands: []any{ + "echo hello everyone", + }, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + WorkingDir: fn.Must(os.Getwd()), + Commands: []ParsedCommandJson{ + {Command: "echo i will call hello, now"}, + {Run: "hello"}, + }, + }, + wantErr: false, + }, + { + name: "[unhappy/commands] JSON commands [must throw err, when] run target does not exist", + args: args{ + rf: &ParsedRunfile{ + Tasks: map[string]Task{ + "test": { + Commands: []any{ + "echo i will call hello, now", + map[string]any{ + "run": "hello", + }, + }, + }, + }, + }, + taskName: "test", + }, + wantErr: true, + }, + + { + name: "[task] interactive task", + args: args{ + ctx: nil, + rf: &ParsedRunfile{ + Tasks: map[string]Task{ + "test": { + Interactive: true, + Commands: []any{ + "echo i will call hello, now", + map[string]any{ + "run": "hello", + }, + }, + }, + "hello": { + Commands: []any{ + "echo hello everyone", + }, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + WorkingDir: fn.Must(os.Getwd()), + Interactive: true, + Commands: []ParsedCommandJson{ + {Command: "echo i will call hello, now"}, + {Run: "hello"}, + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseTask(context.TODO(), tt.args.rf, tt.args.rf.Tasks[tt.args.taskName]) + if (err != nil) != tt.wantErr { + t.Errorf("ParseTask(), got = %v, error = %v, wantErr %v", got, err, tt.wantErr) + return + } + + if !reflect.DeepEqual(got, tt.want) { + if !areEqual(t, got, tt.want) { + t.Errorf("ParseTask():> \n\tgot:\t%v,\n\twant:\t%v", got, tt.want) + } + } + }) + } +} diff --git a/parser/parser.go b/parser/parser.go new file mode 100644 index 0000000..730e76c --- /dev/null +++ b/parser/parser.go @@ -0,0 +1,68 @@ +package parser + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/nxtcoder17/runfile/errors" + fn "github.com/nxtcoder17/runfile/functions" + "github.com/nxtcoder17/runfile/types" + "sigs.k8s.io/yaml" +) + +func parse(file string) (*types.ParsedRunfile, error) { + var runfile types.Runfile + f, err := os.ReadFile(file) + if err != nil { + return nil, errors.ErrReadRunfile.Wrap(err).KV("file", file) + } + + if err = yaml.Unmarshal(f, &runfile); err != nil { + return nil, errors.ErrParseRunfile.Wrap(err).KV("file", file) + } + + var prf types.ParsedRunfile + + // prf.Metadata.RunfilePath = fn.Must(filepath.Rel(fn.Must(os.Getwd()), file)) + prf.Metadata.RunfilePath = file + + m, err := parseIncludes(runfile.Includes) + if err != nil { + return nil, err + } + + prf.Tasks = runfile.Tasks + for k, iprf := range m { + for taskName, task := range iprf.Tasks { + task.Metadata.RunfilePath = &iprf.Metadata.RunfilePath + // task.Metadata.RunfilePath = fn.New(fn.Must(filepath.Rel(fn.Must(os.Getwd()), iprf.Metadata.RunfilePath))) + prf.Tasks[fmt.Sprintf("%s:%s", k, taskName)] = task + } + } + + dotEnvFiles := make([]string, 0, len(runfile.DotEnv)) + for i := range runfile.DotEnv { + dotEnvFiles = append(dotEnvFiles, fn.Must(filepath.Abs(runfile.DotEnv[i]))) + } + + // dotenvVars, err := parseDotEnvFiles(runfile.DotEnv...) + dotenvVars, err := parseDotEnvFiles(dotEnvFiles...) + if err != nil { + return nil, err + } + + envVars, err := parseEnvVars(context.TODO(), runfile.Env, evaluationParams{Env: dotenvVars}) + if err != nil { + return nil, err + } + + prf.Env = fn.MapMerge(dotenvVars, envVars) + + return &prf, nil +} + +func Parse(file string) (*types.ParsedRunfile, error) { + return parse(file) +} diff --git a/pkg/errors/message.go b/pkg/errors/message.go deleted file mode 100644 index ef09b7b..0000000 --- a/pkg/errors/message.go +++ /dev/null @@ -1,64 +0,0 @@ -package errors - -import ( - "encoding/json" - "log/slog" -) - -type Message struct { - text string - err error - - metadataKeys map[string]int - metadata []any -} - -var _ error = (*Message)(nil) - -func New(text string, err error) *Message { - return &Message{text: text, err: err} -} - -func (m Message) WithErr(err error) *Message { - m.err = err - return &m -} - -func (m Message) WithMetadata(metaAttrs ...any) *Message { - if m.metadataKeys == nil { - m.metadataKeys = make(map[string]int) - } - - m.metadata = append(m.metadata, metaAttrs...) - - return &m -} - -func (m *Message) Error() string { - m2 := map[string]any{ - "text": m.text, - "metadata": m.metadata, - } - if m.err != nil { - m2["error"] = m.err.Error() - } - - b, err := json.Marshal(m2) - if err != nil { - slog.Error("marshalling, got", "err", err) - panic(err) - } - - return string(b) -} - -func (m *Message) Log() { - if m.err == nil { - slog.Error(m.text, m.metadata...) - return - } - // args := []any{"err", m.err} - // args = append(args, m.metadata...) - // slog.Error(m.text, args...) - slog.Error(m.err.Error(), m.metadata...) -} diff --git a/pkg/errors/operations.go b/pkg/errors/operations.go deleted file mode 100644 index 1352bcd..0000000 --- a/pkg/errors/operations.go +++ /dev/null @@ -1,9 +0,0 @@ -package errors - -import ( - "errors" -) - -func Is(err1, err2 error) bool { - return errors.Is(err1, err2) -} diff --git a/pkg/runfile/context.go b/pkg/runfile/context.go deleted file mode 100644 index 14d22d3..0000000 --- a/pkg/runfile/context.go +++ /dev/null @@ -1,175 +0,0 @@ -package runfile - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "log/slog" - "os" - "strings" - "text/template" - - sprig "github.com/go-task/slim-sprig/v3" - fn "github.com/nxtcoder17/runfile/pkg/functions" - "github.com/nxtcoder17/runfile/pkg/logging" -) - -type Context struct { - context.Context - *slog.Logger - - // RunfilePath string - // Taskname string - - theme *logging.Theme -} - -func NewContext(ctx context.Context, logger *slog.Logger) Context { - lgr := logger - if lgr == nil { - lgr = slog.Default() - } - - return Context{Context: ctx, Logger: lgr, theme: logging.DefaultTheme()} -} - -type EvaluationArgs struct { - Shell []string - Env map[string]string -} - -func ToEnviron(m map[string]string) []string { - results := os.Environ() - for k, v := range m { - results = append(results, fmt.Sprintf("%s=%v", k, v)) - } - return results -} - -type EnvKV struct { - Key string - - Value *string `json:"value"` - Sh *string `json:"sh"` - GoTmpl *string `json:"gotmpl"` -} - -func (ejv EnvKV) Parse(ctx Context, args EvaluationArgs) (*string, *Error) { - switch { - case ejv.Value != nil: - { - return ejv.Value, nil - } - case ejv.Sh != nil: - { - value := new(bytes.Buffer) - - cmd := createCommand(ctx, cmdArgs{ - shell: args.Shell, - env: ToEnviron(args.Env), - cmd: *ejv.Sh, - stdout: value, - }) - if err := cmd.Run(); err != nil { - return nil, TaskEnvCommandFailed.WithErr(err) - } - - return fn.New(strings.TrimSpace(value.String())), nil - } - case ejv.GoTmpl != nil: - { - t := template.New(ejv.Key).Funcs(sprig.FuncMap()) - t, err := t.Parse(fmt.Sprintf(`{{ %s }}`, *ejv.GoTmpl)) - if err != nil { - return nil, TaskEnvGoTmplFailed.WithErr(err) - } - - value := new(bytes.Buffer) - if err := t.ExecuteTemplate(value, ejv.Key, map[string]string{}); err != nil { - return nil, TaskEnvGoTmplFailed.WithErr(err) - } - - return fn.New(strings.TrimSpace(value.String())), nil - } - default: - { - return nil, TaskEnvInvalid.WithErr(fmt.Errorf("failed to parse, unknown format, one of [value, sh, gotmpl] must be set")) - } - } -} - -func parseEnvVars(ctx Context, ev EnvVar, args EvaluationArgs) (map[string]string, *Error) { - env := make(map[string]string, len(ev)) - for k, v := range ev { - attr := []any{slog.Group("env", "key", k, "value", v)} - switch v := v.(type) { - case string: - env[k] = v - case map[string]any: - b, err := json.Marshal(v) - if err != nil { - return nil, TaskEnvInvalid.WithErr(err).WithMetadata(attr...) - } - - var envAsJson struct { - *EnvKV - Required bool - Default *EnvKV - } - - if err := json.Unmarshal(b, &envAsJson); err != nil { - return nil, TaskEnvInvalid.WithErr(err).WithMetadata(attr...) - } - - switch { - case envAsJson.Required: - { - isDefined := false - if _, ok := os.LookupEnv(k); ok { - isDefined = true - } - - if !isDefined { - if _, ok := args.Env[k]; ok { - isDefined = true - } - } - - if !isDefined { - return nil, TaskEnvRequired.WithMetadata(attr...) - } - } - - case envAsJson.EnvKV != nil: - { - envAsJson.Key = k - s, err := envAsJson.EnvKV.Parse(ctx, args) - if err != nil { - return nil, err.WithMetadata(attr...) - } - env[k] = *s - } - - case envAsJson.Default != nil: - { - envAsJson.Default.Key = k - s, err := envAsJson.Default.Parse(ctx, args) - if err != nil { - return nil, err.WithMetadata(attr...) - } - env[k] = *s - } - default: - { - return nil, TaskEnvInvalid.WithErr(fmt.Errorf("either required, value, sh, gotmpl or default, must be defined")).WithMetadata(attr...) - } - } - - default: - env[k] = fmt.Sprintf("%v", v) - } - } - - return env, nil -} diff --git a/pkg/runfile/errors.go b/pkg/runfile/errors.go deleted file mode 100644 index 8d62389..0000000 --- a/pkg/runfile/errors.go +++ /dev/null @@ -1,75 +0,0 @@ -package runfile - -import ( - "github.com/nxtcoder17/runfile/pkg/errors" -) - -type Error struct { - TaskName string - Runfile string - - *errors.Message -} - -var _ error = (*Error)(nil) - -func NewError(taskName string, runfile string) *Error { - return &Error{ - TaskName: taskName, - Runfile: runfile, - Message: &errors.Message{}, - } -} - -func (e *Error) WithTask(task string) *Error { - e.TaskName = task - e.Message = e.Message.WithMetadata("task", task) - return e -} - -func (e *Error) WithRunfile(rf string) *Error { - e.Runfile = rf - e.Message = e.Message.WithMetadata("runfile", rf) - return e -} - -func (e *Error) WithErr(err error) *Error { - e.Message = e.Message.WithErr(err) - return e -} - -func (e Error) WithMetadata(attrs ...any) *Error { - e.Message = e.Message.WithMetadata(attrs...) - return &e -} - -var ( - RunfileReadFailed = &Error{Message: errors.New("Runfile Read Failed", nil)} - RunfileParsingFailed = &Error{Message: errors.New("Runfile Parsing Failed", nil)} -) - -var ( - TaskNotFound = &Error{Message: errors.New("Task Not Found", nil)} - TaskFailed = &Error{Message: errors.New("Task Failed", nil)} - - TaskWorkingDirectoryInvalid = &Error{Message: errors.New("Task Working Directory Invalid", nil)} - - TaskRequirementFailed = &Error{Message: errors.New("Task Requirement Failed", nil)} - TaskRequirementIncorrect = &Error{Message: errors.New("Task Requirement Incorrect", nil)} - - TaskEnvInvalid = &Error{Message: errors.New("Task Env is invalid", nil)} - TaskEnvRequired = &Error{Message: errors.New("Task Env Required, but not provided", nil)} - TaskEnvCommandFailed = &Error{Message: errors.New("Task Env command failed", nil)} - TaskEnvGoTmplFailed = &Error{Message: errors.New("Task Env GoTemplate failed", nil)} -) - -var ( - DotEnvNotFound = &Error{Message: errors.New("DotEnv Not Found", nil)} - DotEnvInvalid = &Error{Message: errors.New("Dotenv Invalid", nil)} - DotEnvParsingFailed = &Error{Message: errors.New("DotEnv Parsing Failed", nil)} -) - -var ( - CommandFailed = &Error{Message: errors.New("Command Failed", nil)} - CommandInvalid = &Error{Message: errors.New("Command Invalid", nil)} -) diff --git a/pkg/runfile/parser.go b/pkg/runfile/parser.go deleted file mode 100644 index ffaa946..0000000 --- a/pkg/runfile/parser.go +++ /dev/null @@ -1,70 +0,0 @@ -package runfile - -import ( - "fmt" - "io" - "os" - "path/filepath" - - "github.com/joho/godotenv" -) - -func parseDotEnv(reader io.Reader) (map[string]string, *Error) { - m, err := godotenv.Parse(reader) - if err != nil { - return nil, DotEnvParsingFailed.WithErr(err) - } - return m, nil -} - -// parseDotEnv parses the .env file and returns a slice of strings as in os.Environ() - -func parseDotEnvFiles(files ...string) (map[string]string, *Error) { - results := make(map[string]string) - - for i := range files { - if !filepath.IsAbs(files[i]) { - return nil, DotEnvInvalid.WithErr(fmt.Errorf("dotenv file paths must be absolute")).WithMetadata("dotenv", files[i]) - } - - f, err := os.Open(files[i]) - if err != nil { - return nil, DotEnvInvalid.WithErr(err).WithMetadata("dotenv", files[i]) - } - - m, err2 := parseDotEnv(f) - if err2 != nil { - return nil, err2.WithMetadata("dotenv", files[i]) - } - f.Close() - - for k, v := range m { - results[k] = v - } - - } - - return results, nil -} - -func ParseIncludes(rf *Runfile) (map[string]ParsedIncludeSpec, *Error) { - m := make(map[string]ParsedIncludeSpec, len(rf.Includes)) - for k, v := range rf.Includes { - r, err := Parse(v.Runfile) - if err != nil { - return nil, err - } - - for it := range r.Tasks { - if v.Dir != "" { - nt := r.Tasks[it] - nt.Dir = &v.Dir - r.Tasks[it] = nt - } - } - - m[k] = ParsedIncludeSpec{Runfile: r} - } - - return m, nil -} diff --git a/pkg/runfile/run.go b/pkg/runfile/run.go deleted file mode 100644 index 4de3fcb..0000000 --- a/pkg/runfile/run.go +++ /dev/null @@ -1,338 +0,0 @@ -package runfile - -import ( - "bufio" - "bytes" - "errors" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "strings" - "sync" - - "github.com/alecthomas/chroma/v2/quick" - "github.com/charmbracelet/lipgloss" - "github.com/muesli/termenv" - fn "github.com/nxtcoder17/runfile/pkg/functions" - "golang.org/x/sync/errgroup" -) - -type cmdArgs struct { - shell []string - env []string // [key=value, key=value, ...] - workingDir string - - cmd string - - interactive bool - stdout io.Writer - stderr io.Writer -} - -func createCommand(ctx Context, args cmdArgs) *exec.Cmd { - if args.shell == nil { - args.shell = []string{"sh", "-c"} - } - - if args.stdout == nil { - args.stdout = os.Stdout - } - - if args.stderr == nil { - args.stderr = os.Stderr - } - - shell := args.shell[0] - - cargs := append(args.shell[1:], args.cmd) - c := exec.CommandContext(ctx, shell, cargs...) - c.Dir = args.workingDir - c.Env = append(os.Environ(), args.env...) - c.Stdout = args.stdout - c.Stderr = args.stderr - - if args.interactive { - c.Stdin = os.Stdin - } - - return c -} - -type runTaskArgs struct { - taskTrail []string - taskName string - envOverrides map[string]string -} - -type outputWriter struct { - mu sync.Mutex - writer io.Writer -} - -func (ow *outputWriter) Write(p []byte) (n int, err error) { - ow.mu.Lock() - defer ow.mu.Unlock() - return ow.writer.Write(p) -} - -func processOutput(writer io.Writer, reader io.Reader, prefix *string) { - prevByte := byte('\n') - msg := make([]byte, 1024) - for { - n, err := reader.Read(msg) - if err != nil { - // logger.Info("stdout", "msg", string(msg[:n]), "err", err) - if errors.Is(err, io.EOF) { - writer.Write(msg[:n]) - return - } - } - - if n > 0 { - for i := 0; i < n; i++ { - if prevByte == '\n' && prefix != nil { - writer.Write([]byte(*prefix)) // Write prefix at the start of a line - } - writer.Write([]byte{msg[i]}) // Write the current byte - prevByte = msg[i] - } - } - } -} - -func processOutputLineByLine(writer io.Writer, reader io.Reader, prefix *string) { - r := bufio.NewReader(reader) - for { - b, err := r.ReadBytes('\n') - if err != nil { - // logger.Info("stdout", "msg", string(msg[:n]), "err", err) - if errors.Is(err, io.EOF) { - writer.Write([]byte(*prefix)) - writer.Write(b) - return - } - } - - writer.Write([]byte(*prefix)) - writer.Write(b) - } -} - -func isDarkTheme() bool { - return termenv.NewOutput(os.Stdout).HasDarkBackground() -} - -func runTask(ctx Context, rf *Runfile, args runTaskArgs) *Error { - runfilePath := fn.Must(filepath.Rel(rf.attrs.RootRunfilePath, rf.attrs.RunfilePath)) - - trail := append(args.taskTrail, args.taskName) - - formatErr := func(err *Error) *Error { - if runfilePath != "." { - return err.WithTask(strings.Join(trail, "/")).WithRunfile(runfilePath) - } - return err.WithTask(strings.Join(trail, "/")) - } - - logger := ctx.With("task", args.taskName, "runfile", runfilePath) - logger.Debug("running task") - - task, ok := rf.Tasks[args.taskName] - if !ok { - return formatErr(TaskNotFound) - } - - task.Name = args.taskName - if task.Env == nil { - task.Env = make(EnvVar) - } - - for k, v := range args.envOverrides { - task.Env[k] = v - } - - pt, err := ParseTask(ctx, rf, task) - if err != nil { - return formatErr(err) - } - - for _, command := range pt.Commands { - logger.Debug("running command task", "command.run", command.Run, "parent.task", args.taskName) - - if command.If != nil && !*command.If { - logger.Debug("skipping execution for failed `if`", "command", command.Run) - continue - } - - if command.Run != "" { - if err := runTask(ctx, rf, runTaskArgs{ - taskTrail: trail, - taskName: command.Run, - envOverrides: pt.Env, - }); err != nil { - return err - } - continue - } - - // stdoutR, stdoutW := io.Pipe() - // stderrR, stderrW := io.Pipe() - - // wg := sync.WaitGroup{} - - // [snippet source](https://rderik.com/blog/identify-if-output-goes-to-the-terminal-or-is-being-redirected-in-golang/) - // stdout, _ := os.Stdout.Stat() - // stderr, _ := os.Stderr.Stat() - // isTTY := ((stdout.Mode() & os.ModeCharDevice) == os.ModeCharDevice) && ((stderr.Mode() & os.ModeCharDevice) == os.ModeCharDevice) - // - // if isTTY { - // go func() { - // defer wg.Done() - // logPrefix := fmt.Sprintf("%s ", ctx.theme.TaskPrefixStyle.Render(fmt.Sprintf("[%s]", strings.Join(trail, "/")))) - // processOutput(os.Stdout, stdoutR, &logPrefix) - // - // stderrPrefix := fmt.Sprintf("%s ", ctx.theme.TaskPrefixStyle.Render(fmt.Sprintf("[%s/stderr]", strings.Join(trail, "/")))) - // processOutput(os.Stderr, stderrR, &stderrPrefix) - // }() - // } else { - // wg.Add(1) - // go func() { - // defer wg.Done() - // logPrefix := fmt.Sprintf("%s ", ctx.theme.TaskPrefixStyle.Render(fmt.Sprintf("[%s]", strings.Join(trail, "/")))) - // processOutput(os.Stdout, stdoutR, &logPrefix) - // // if pt.Interactive { - // // processOutput(os.Stdout, stdoutR, &logPrefix) - // // return - // // } - // // processOutputLineByLine(os.Stdout, stdoutR, &logPrefix) - // }() - // - // wg.Add(1) - // go func() { - // defer wg.Done() - // logPrefix := fmt.Sprintf("%s ", ctx.theme.TaskPrefixStyle.Render(fmt.Sprintf("[%s/stderr]", strings.Join(trail, "/")))) - // processOutput(os.Stderr, stderrR, &logPrefix) - // // if pt.Interactive { - // // processOutput(os.Stderr, stderrR, &logPrefix) - // // return - // // } - // // processOutputLineByLine(os.Stderr, stderrR, &logPrefix) - // }() - // } - - borderColor := "#4388cc" - if !isDarkTheme() { - borderColor = "#3d5485" - } - s := lipgloss.NewStyle().BorderForeground(lipgloss.Color(borderColor)).PaddingLeft(1).PaddingRight(1).Border(lipgloss.RoundedBorder(), true, true, true, true) - - hlCode := new(bytes.Buffer) - colorscheme := "onedark" - if !isDarkTheme() { - colorscheme = "monokailight" - } - quick.Highlight(hlCode, strings.TrimSpace(command.Command), "bash", "terminal16m", colorscheme) - - fmt.Printf("%s\n", s.Render(hlCode.String())) - - cmd := createCommand(ctx, cmdArgs{ - shell: pt.Shell, - env: ToEnviron(pt.Env), - cmd: command.Command, - workingDir: pt.WorkingDir, - interactive: pt.Interactive, - stdout: os.Stdout, - stderr: os.Stderr, - // stdout: stdoutW, - // stderr: stderrW, - }) - - if err := cmd.Run(); err != nil { - return formatErr(CommandFailed).WithErr(err) - } - - // stdoutW.Close() - // stderrW.Close() - // - // wg.Wait() - } - - return nil -} - -type RunArgs struct { - Tasks []string - ExecuteInParallel bool - Watch bool - Debug bool - KVs map[string]string -} - -func (rf *Runfile) Run(ctx Context, args RunArgs) *Error { - includes, err := rf.ParseIncludes() - if err != nil { - return err - } - - for _, taskName := range args.Tasks { - for k, v := range includes { - for tn := range v.Runfile.Tasks { - if taskName == fmt.Sprintf("%s:%s", k, tn) { - return runTask(ctx, v.Runfile, runTaskArgs{taskName: tn}) - } - } - } - - task, ok := rf.Tasks[taskName] - if !ok { - errAttr := []any{"task", taskName} - if rf.attrs.RunfilePath != rf.attrs.RootRunfilePath { - errAttr = append(errAttr, "runfile", fn.Must(filepath.Rel(rf.attrs.RootRunfilePath, rf.attrs.RunfilePath))) - } - return TaskNotFound.WithMetadata(errAttr...) - } - - // INFO: adding parsed KVs as environments to the specified tasks - for k, v := range args.KVs { - if task.Env == nil { - task.Env = EnvVar{} - } - task.Env[k] = v - } - - rf.Tasks[taskName] = task - } - - if args.ExecuteInParallel { - ctx.Debug("running in parallel mode", "tasks", args.Tasks) - g := new(errgroup.Group) - - for _, _tn := range args.Tasks { - tn := _tn - g.Go(func() error { - return runTask(ctx, rf, runTaskArgs{taskName: tn}) - }) - } - - // Wait for all tasks to finish - if err := g.Wait(); err != nil { - err2, ok := err.(*Error) - if ok { - return err2 - } - return TaskFailed.WithErr(err) - } - - return nil - } - - for _, tn := range args.Tasks { - if err := runTask(ctx, rf, runTaskArgs{taskName: tn}); err != nil { - return err - } - } - - return nil -} diff --git a/pkg/runfile/run_test.go b/pkg/runfile/run_test.go deleted file mode 100644 index 1252a44..0000000 --- a/pkg/runfile/run_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package runfile - -import ( - "context" - "log/slog" - "reflect" - "testing" - "time" -) - -func Test_runTask(t *testing.T) { - type args struct { - rf *Runfile - args runTaskArgs - } - tests := []struct { - name string - args args - want *Error - }{ - { - name: "1. Task Not Found", - args: args{ - rf: &Runfile{ - Tasks: map[string]Task{}, - }, - args: runTaskArgs{ - taskName: "sample", - }, - }, - want: TaskNotFound, - }, - - { - name: "1. Task Not Found", - args: args{ - rf: &Runfile{ - Tasks: map[string]Task{}, - }, - args: runTaskArgs{ - taskName: "sample", - }, - }, - want: TaskNotFound, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctx, cf := context.WithTimeout(context.TODO(), 2*time.Second) - defer cf() - - err := runTask(NewContext(ctx, slog.Default()), tt.args.rf, tt.args.args) - if !reflect.DeepEqual(err, tt.want) { - t.Errorf("runTask() = %v, want %v", err, tt.want) - } - }) - } -} diff --git a/pkg/runfile/runfile.go b/pkg/runfile/runfile.go deleted file mode 100644 index 8353048..0000000 --- a/pkg/runfile/runfile.go +++ /dev/null @@ -1,79 +0,0 @@ -package runfile - -import ( - "os" - "path/filepath" - - fn "github.com/nxtcoder17/runfile/pkg/functions" - "sigs.k8s.io/yaml" -) - -type attrs struct { - RootRunfilePath string - RunfilePath string -} - -type Runfile struct { - attrs attrs - - Version string `json:"version,omitempty"` - Includes map[string]IncludeSpec `json:"includes"` - Env EnvVar `json:"env,omitempty"` - DotEnv []string `json:"dotEnv,omitempty"` - Tasks map[string]Task `json:"tasks"` -} - -type IncludeSpec struct { - Runfile string `json:"runfile"` - Dir string `json:"dir,omitempty"` -} - -type ParsedIncludeSpec struct { - Runfile *Runfile -} - -func Parse(file string) (*Runfile, *Error) { - var runfile Runfile - f, err := os.ReadFile(file) - if err != nil { - return &runfile, RunfileReadFailed.WithErr(err).WithMetadata("file", file) - } - if err = yaml.Unmarshal(f, &runfile); err != nil { - return nil, RunfileParsingFailed.WithErr(err).WithMetadata("file", file) - } - - runfile.attrs.RunfilePath = fn.Must(filepath.Abs(file)) - runfile.attrs.RootRunfilePath = runfile.attrs.RunfilePath - return &runfile, nil -} - -func (rf *Runfile) parse(file string) (*Runfile, *Error) { - r, err := Parse(file) - if err != nil { - return nil, err - } - r.attrs.RootRunfilePath = rf.attrs.RunfilePath - return r, nil -} - -func (rf *Runfile) ParseIncludes() (map[string]ParsedIncludeSpec, *Error) { - m := make(map[string]ParsedIncludeSpec, len(rf.Includes)) - for k, v := range rf.Includes { - r, err := rf.parse(v.Runfile) - if err != nil { - return nil, err.WithMetadata("includes", v.Runfile) - } - - for it := range r.Tasks { - if v.Dir != "" { - nt := r.Tasks[it] - nt.Dir = &v.Dir - r.Tasks[it] = nt - } - } - - m[k] = ParsedIncludeSpec{Runfile: r} - } - - return m, nil -} diff --git a/pkg/runfile/task-parser.go b/pkg/runfile/task-parser.go deleted file mode 100644 index 5b2f1b6..0000000 --- a/pkg/runfile/task-parser.go +++ /dev/null @@ -1,224 +0,0 @@ -package runfile - -import ( - "bytes" - "encoding/json" - "fmt" - "os" - "path/filepath" - "text/template" - - sprig "github.com/go-task/slim-sprig/v3" - fn "github.com/nxtcoder17/runfile/pkg/functions" -) - -type ParsedTask struct { - Shell []string `json:"shell"` - WorkingDir string `json:"workingDir"` - Env map[string]string `json:"environ"` - Interactive bool `json:"interactive,omitempty"` - Commands []ParsedCommandJson `json:"commands"` -} - -func evalGoTemplateCondition(tpl string) (bool, *Error) { - t := template.New("requirement") - t = t.Funcs(sprig.FuncMap()) - templateExpr := fmt.Sprintf(`{{ %s }}`, tpl) - t, err := t.Parse(templateExpr) - if err != nil { - return false, TaskRequirementIncorrect.WithErr(err).WithMetadata("requirement", tpl) - } - b := new(bytes.Buffer) - if err := t.ExecuteTemplate(b, "requirement", map[string]string{}); err != nil { - return false, TaskRequirementIncorrect.WithErr(err).WithMetadata("requirement", tpl) - } - - if b.String() != "true" { - return false, TaskRequirementFailed.WithErr(fmt.Errorf("template must have evaluated to true")).WithMetadata("requirement", tpl) - } - - return true, nil -} - -func ParseTask(ctx Context, rf *Runfile, task Task) (*ParsedTask, *Error) { - globalEnv := make(map[string]string) - - if rf.Env != nil { - genv, err := parseEnvVars(ctx, rf.Env, EvaluationArgs{ - Shell: nil, - Env: nil, - }) - if err != nil { - return nil, err - } - for k, v := range genv { - globalEnv[k] = v - } - } - - if rf.DotEnv != nil { - dotEnvPaths, err := resolveDotEnvFiles(filepath.Dir(rf.attrs.RunfilePath), rf.DotEnv...) - if err != nil { - return nil, err - } - m, err := parseDotEnvFiles(dotEnvPaths...) - if err != nil { - return nil, err - } - for k, v := range m { - globalEnv[k] = v - } - } - - for _, requirement := range task.Requires { - if requirement == nil { - continue - } - - if requirement.Sh != nil { - cmd := createCommand(ctx, cmdArgs{ - shell: []string{"sh", "-c"}, - env: ToEnviron(globalEnv), - workingDir: filepath.Dir(rf.attrs.RunfilePath), - cmd: *requirement.Sh, - stdout: fn.Must(os.OpenFile(os.DevNull, os.O_WRONLY, 0o755)), - stderr: fn.Must(os.OpenFile(os.DevNull, os.O_WRONLY, 0o755)), - }) - if err := cmd.Run(); err != nil { - return nil, TaskRequirementFailed.WithErr(err).WithMetadata("requirement", *requirement.Sh) - } - continue - } - - if requirement.GoTmpl != nil { - if _, err := evalGoTemplateCondition(*requirement.GoTmpl); err != nil { - return nil, err - } - continue - } - } - - if task.Shell == nil { - task.Shell = []string{"sh", "-c"} - } - - if task.Dir == nil { - task.Dir = fn.New(fn.Must(os.Getwd())) - } - - fi, err2 := os.Stat(*task.Dir) - if err2 != nil { - return nil, TaskWorkingDirectoryInvalid.WithErr(err2).WithMetadata("working-dir", *task.Dir) - } - - if !fi.IsDir() { - return nil, TaskWorkingDirectoryInvalid.WithErr(fmt.Errorf("path is not a directory")).WithMetadata("working-dir", *task.Dir) - } - - taskDotEnvPaths, err := resolveDotEnvFiles(filepath.Dir(rf.attrs.RunfilePath), task.DotEnv...) - if err != nil { - return nil, err - } - - taskDotenvVars, err := parseDotEnvFiles(taskDotEnvPaths...) - if err != nil { - return nil, err - } - - // INFO: keys from task.Env will override those coming from dotenv files, when duplicated - taskEnvVars, err := parseEnvVars(ctx, task.Env, EvaluationArgs{ - Shell: task.Shell, - Env: fn.MapMerge(globalEnv, taskDotenvVars), - }) - if err != nil { - return nil, err - } - - commands := make([]ParsedCommandJson, 0, len(task.Commands)) - for i := range task.Commands { - c2, err := parseCommand(rf, task.Commands[i]) - if err != nil { - return nil, err - } - commands = append(commands, *c2) - } - - return &ParsedTask{ - Shell: task.Shell, - WorkingDir: *task.Dir, - Interactive: task.Interactive, - Env: fn.MapMerge(globalEnv, taskDotenvVars, taskEnvVars), - Commands: commands, - }, nil -} - -// returns absolute paths to dotenv files -func resolveDotEnvFiles(pwd string, dotEnvFiles ...string) ([]string, *Error) { - paths := make([]string, 0, len(dotEnvFiles)) - - for _, v := range dotEnvFiles { - dotenvPath := v - if !filepath.IsAbs(v) { - dotenvPath = filepath.Join(pwd, v) - } - fi, err := os.Stat(dotenvPath) - if err != nil { - return nil, DotEnvNotFound.WithErr(err).WithMetadata("dotenv", dotenvPath) - } - - if fi.IsDir() { - return nil, DotEnvInvalid.WithErr(fmt.Errorf("dotenv path must be a file, but it is a directory")).WithMetadata("dotenv", dotenvPath) - } - - paths = append(paths, dotenvPath) - } - - return paths, nil -} - -func parseCommand(rf *Runfile, command any) (*ParsedCommandJson, *Error) { - switch c := command.(type) { - case string: - { - return &ParsedCommandJson{Command: c}, nil - } - case map[string]any: - { - var cj CommandJson - b, err := json.Marshal(c) - if err != nil { - return nil, CommandInvalid.WithErr(err).WithMetadata("command", command) - } - - if err := json.Unmarshal(b, &cj); err != nil { - return nil, CommandInvalid.WithErr(err).WithMetadata("command", command) - } - - if cj.Run == "" && cj.Command == "" { - return nil, CommandInvalid.WithErr(fmt.Errorf("key: 'run'/'cmd', must be specified when setting command in json format")).WithMetadata("command", cj) - } - - var pcj ParsedCommandJson - pcj.Run = cj.Run - pcj.Command = cj.Command - - if cj.If != nil { - ok, _ := evalGoTemplateCondition(*cj.If) - // if err != nil { - // return nil, err - // } - pcj.If = &ok - } - - // if _, ok := rf.Tasks[cj.Run]; !ok { - // return nil, CommandInvalid.WithErr(fmt.Errorf("run target, not found")).WithMetadata("command", command, "run-target", cj.Run) - // } - - return &pcj, nil - } - default: - { - return nil, CommandInvalid.WithMetadata("command", command) - } - } -} diff --git a/pkg/runfile/task-parser_test.go b/pkg/runfile/task-parser_test.go deleted file mode 100644 index eda7590..0000000 --- a/pkg/runfile/task-parser_test.go +++ /dev/null @@ -1,915 +0,0 @@ -package runfile - -import ( - "context" - "fmt" - "log/slog" - "os" - "path/filepath" - "strings" - "testing" - - fn "github.com/nxtcoder17/runfile/pkg/functions" -) - -func TestParseTask(t *testing.T) { - type args struct { - ctx context.Context - rf *Runfile - taskName string - } - - areEqual := func(t *testing.T, got, want *ParsedTask) bool { - if want == nil { - return false - } - - if strings.Join(got.Shell, ",") != strings.Join(want.Shell, ",") { - t.Logf("shell not equal") - 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 - } - - gkeys := fn.MapKeys(got.Env) - - for _, k := range gkeys { - v, ok := want.Env[k] - if !ok || v != got.Env[k] { - t.Logf("environments not equal") - return false - } - } - - if got.WorkingDir != want.WorkingDir { - t.Logf("working dir not equal") - return false - } - - if fmt.Sprintf("%#v", got.Commands) != fmt.Sprintf("%#v", want.Commands) { - t.Logf("commands not equal:\n got:\t%#v\nwant:\t%#v", got.Commands, want.Commands) - return false - } - - return true - } - - // for dotenv test - dotenvTestFile, err := os.CreateTemp(os.TempDir(), ".env") - if err != nil { - t.Error(err) - return - } - fmt.Fprintf(dotenvTestFile, "hello=world\n") - dotenvTestFile.Close() - - type test struct { - name string - args args - want *ParsedTask - wantErr bool - } - - testRequires := []test{ - { - name: "[requires] condition specified, but it neither has 'sh' or 'gotmpl' key", - args: args{ - rf: &Runfile{ - Tasks: map[string]Task{ - "test": { - Shell: nil, - ignoreSystemEnv: true, - Requires: []*Requires{ - {}, - }, - Commands: nil, - }, - }, - }, - taskName: "test", - }, - want: &ParsedTask{ - Shell: []string{"sh", "-c"}, - WorkingDir: fn.Must(os.Getwd()), - Commands: []CommandJson{}, - }, - wantErr: false, - }, - - { - name: "[requires] condition specified, with gotmpl key", - args: args{ - rf: &Runfile{ - Tasks: map[string]Task{ - "test": { - Shell: nil, - ignoreSystemEnv: true, - Requires: []*Requires{ - { - GoTmpl: fn.New(`eq 5 5`), - }, - }, - Commands: nil, - }, - }, - }, - taskName: "test", - }, - want: &ParsedTask{ - Shell: []string{"sh", "-c"}, - WorkingDir: fn.Must(os.Getwd()), - Commands: []CommandJson{}, - }, - wantErr: false, - }, - - { - name: "[requires] condition specified, with sh key", - args: args{ - rf: &Runfile{ - Tasks: map[string]Task{ - "test": { - Shell: nil, - ignoreSystemEnv: true, - Requires: []*Requires{ - { - Sh: fn.New(`echo hello`), - }, - }, - Commands: nil, - }, - }, - }, - taskName: "test", - }, - want: &ParsedTask{ - Shell: []string{"sh", "-c"}, - WorkingDir: fn.Must(os.Getwd()), - Commands: []CommandJson{}, - }, - wantErr: false, - }, - - { - name: "[unhappy/requires] condition specified, with sh key", - args: args{ - rf: &Runfile{ - Tasks: map[string]Task{ - "test": { - Shell: nil, - ignoreSystemEnv: true, - Requires: []*Requires{ - { - Sh: fn.New(`echo hello && exit 1`), - }, - }, - Commands: nil, - }, - }, - }, - taskName: "test", - }, - want: &ParsedTask{ - Shell: []string{"sh", "-c"}, - WorkingDir: fn.Must(os.Getwd()), - Commands: []CommandJson{}, - }, - wantErr: true, - }, - } - - testEnviroments := []test{ - { - name: "[unhappy/env] required env, not provided", - args: args{ - rf: &Runfile{ - Tasks: map[string]Task{ - "test": { - ignoreSystemEnv: true, - Env: EnvVar{ - "hello": map[string]any{ - "required": true, - }, - }, - Commands: nil, - }, - }, - }, - taskName: "test", - }, - want: nil, - wantErr: true, - }, - { - name: "[env] required env, provided", - args: args{ - rf: &Runfile{ - Tasks: map[string]Task{ - "test": { - ignoreSystemEnv: true, - Env: EnvVar{ - "hello": map[string]any{ - "required": true, - }, - }, - DotEnv: []string{ - dotenvTestFile.Name(), - }, - Commands: nil, - }, - }, - }, - taskName: "test", - }, - want: &ParsedTask{ - Shell: []string{"sh", "-c"}, - WorkingDir: fn.Must(os.Getwd()), - Env: map[string]string{ - "hello": "world", - }, - Commands: []CommandJson{}, - }, - wantErr: false, - }, - } - - tests := []test{ - { - name: "[shell] if not specified, defaults to [sh, -c]", - args: args{ - ctx: nil, - rf: &Runfile{ - Tasks: map[string]Task{ - "test": { - Shell: nil, - ignoreSystemEnv: true, - Dir: fn.New("."), - Commands: nil, - }, - }, - }, - taskName: "test", - }, - want: &ParsedTask{ - Shell: []string{"sh", "-c"}, - WorkingDir: ".", - Commands: []CommandJson{}, - }, - wantErr: false, - }, - { - name: "[shell] if specified, must be acknowledged", - args: args{ - ctx: nil, - rf: &Runfile{ - Tasks: map[string]Task{ - "test": { - Shell: []string{"python", "-c"}, - ignoreSystemEnv: true, - Dir: fn.New("."), - Commands: nil, - }, - }, - }, - taskName: "test", - }, - want: &ParsedTask{ - Shell: []string{"python", "-c"}, - WorkingDir: ".", - Commands: []CommandJson{}, - }, - wantErr: false, - }, - { - name: "[env] key: value", - args: args{ - ctx: nil, - rf: &Runfile{ - Tasks: map[string]Task{ - "test": { - Shell: []string{"sh", "-c"}, - ignoreSystemEnv: true, - Env: map[string]any{ - "hello": "hi", - "k1": 1, - }, - Dir: fn.New("."), - Commands: nil, - }, - }, - }, - taskName: "test", - }, - want: &ParsedTask{ - Shell: []string{"sh", "-c"}, - Env: map[string]string{ - "hello": "hi", - "k1": "1", - }, - WorkingDir: ".", - Commands: []CommandJson{}, - }, - wantErr: false, - }, - { - name: "[env] key: JSON object format", - args: args{ - ctx: nil, - rf: &Runfile{ - Tasks: map[string]Task{ - "test": { - ignoreSystemEnv: true, - Env: map[string]any{ - "hello": map[string]any{ - "sh": "echo hi", - }, - }, - Dir: fn.New("."), - Commands: nil, - }, - }, - }, - taskName: "test", - }, - want: &ParsedTask{ - Shell: []string{"sh", "-c"}, - Env: map[string]string{ - "hello": "hi", - }, - WorkingDir: ".", - Commands: []CommandJson{}, - }, - wantErr: false, - }, - { - name: "[unhappy/env] JSON object format [must throw err, when] sh key does not exist in value", - args: args{ - rf: &Runfile{ - Tasks: map[string]Task{ - "test": { - ignoreSystemEnv: true, - Env: map[string]any{ - "k1": map[string]any{ - "asdfasf": "asdfad", - }, - }, - }, - }, - }, - taskName: "test", - }, - wantErr: true, - }, - { - name: "[unhappy/env] JSON object format [must throw err, when] sh (key)'s value is not a string", - args: args{ - rf: &Runfile{ - Tasks: map[string]Task{ - "test": { - ignoreSystemEnv: true, - Env: map[string]any{ - "k1": map[string]any{ - "sh": []string{"asdfsadf"}, - }, - }, - }, - }, - }, - taskName: "test", - }, - wantErr: true, - }, - { - name: "[dotenv] loads environment from given file", - args: args{ - rf: &Runfile{ - Tasks: map[string]Task{ - "test": { - ignoreSystemEnv: true, - DotEnv: []string{ - dotenvTestFile.Name(), - }, - }, - }, - }, - taskName: "test", - }, - want: &ParsedTask{ - Shell: []string{"sh", "-c"}, - WorkingDir: fn.Must(os.Getwd()), - Env: map[string]string{ - "hello": "world", - }, - Commands: []CommandJson{}, - }, - wantErr: false, - }, - { - name: "[unhappy/dotenv] throws err, when file does not exist", - args: args{ - rf: &Runfile{ - Tasks: map[string]Task{ - "test": { - ignoreSystemEnv: true, - DotEnv: []string{ - "/tmp/env-aasfksadjfkl", - }, - }, - }, - }, - taskName: "test", - }, - wantErr: true, - }, - { - name: "[unhappy/dotenv] throws err, when filepath exists [but] is not a file (might be a directory or something else)", - args: args{ - rf: &Runfile{ - Tasks: map[string]Task{ - "test": { - ignoreSystemEnv: true, - DotEnv: []string{ - "/tmp", - }, - }, - }, - }, - taskName: "test", - }, - wantErr: true, - }, - { - name: "[working_dir] if not specified, should be current working directory", - args: args{ - ctx: nil, - rf: &Runfile{ - Tasks: map[string]Task{ - "test": { - ignoreSystemEnv: true, - Commands: nil, - }, - }, - }, - taskName: "test", - }, - want: &ParsedTask{ - Shell: []string{"sh", "-c"}, - WorkingDir: fn.Must(os.Getwd()), - Commands: []CommandJson{}, - }, - wantErr: false, - }, - { - name: "[working_dir] when specified, must be acknowledged", - args: args{ - ctx: nil, - rf: &Runfile{ - Tasks: map[string]Task{ - "test": { - ignoreSystemEnv: true, - Dir: fn.New("/tmp"), - Commands: nil, - }, - }, - }, - taskName: "test", - }, - want: &ParsedTask{ - Shell: []string{"sh", "-c"}, - WorkingDir: "/tmp", - Commands: []CommandJson{}, - }, - wantErr: false, - }, - { - name: "[unhappy/working_dir] must throw err, when directory does not exist", - args: args{ - rf: &Runfile{ - Tasks: map[string]Task{ - "test": { - ignoreSystemEnv: true, - Dir: fn.New("/tmp/xsdfjasdfkjdskfjasl"), - }, - }, - }, - taskName: "test", - }, - wantErr: true, - }, - { - name: "[unhappy/working_dir] must throw err, when directory specified is not a directory (might be something else, or a file)", - args: args{ - rf: &Runfile{ - Tasks: map[string]Task{ - "test": { - ignoreSystemEnv: true, - Dir: fn.New(filepath.Join(fn.Must(os.Getwd()), "task.go")), - }, - }, - }, - taskName: "test", - }, - wantErr: true, - }, - { - name: "[commands] string commands: single line", - args: args{ - ctx: nil, - rf: &Runfile{ - Tasks: map[string]Task{ - "test": { - ignoreSystemEnv: true, - Commands: []any{ - "echo hello", - }, - }, - }, - }, - taskName: "test", - }, - want: &ParsedTask{ - Shell: []string{"sh", "-c"}, - WorkingDir: fn.Must(os.Getwd()), - Commands: []CommandJson{ - {Command: "echo hello"}, - }, - }, - wantErr: false, - }, - - { - name: "[commands] string commands: multiline", - args: args{ - ctx: nil, - rf: &Runfile{ - Tasks: map[string]Task{ - "test": { - ignoreSystemEnv: true, - Commands: []any{ - ` -echo "hello" -echo "hi" -`, - }, - }, - }, - }, - taskName: "test", - }, - want: &ParsedTask{ - Shell: []string{"sh", "-c"}, - WorkingDir: fn.Must(os.Getwd()), - Commands: []CommandJson{ - { - Command: ` -echo "hello" -echo "hi" -`, - }, - }, - }, - wantErr: false, - }, - - { - name: "[commands] JSON commands", - args: args{ - ctx: nil, - rf: &Runfile{ - Tasks: map[string]Task{ - "test": { - ignoreSystemEnv: 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()), - Commands: []CommandJson{ - {Command: "echo i will call hello, now"}, - {Run: "hello"}, - }, - }, - wantErr: false, - }, - { - name: "[unhappy/commands] JSON commands [must throw err, when] run target does not exist", - args: args{ - rf: &Runfile{ - Tasks: map[string]Task{ - "test": { - ignoreSystemEnv: true, - Commands: []any{ - "echo i will call hello, now", - map[string]any{ - "run": "hello", - }, - }, - }, - }, - }, - taskName: "test", - }, - 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{ - { - name: "1. testing global env key-value item", - args: args{ - ctx: nil, - rf: &Runfile{ - Env: map[string]any{ - "k1": "v1", - }, - Tasks: map[string]Task{ - "test": { - ignoreSystemEnv: true, - Commands: []any{ - "echo hi", - }, - }, - }, - }, - taskName: "test", - }, - want: &ParsedTask{ - Shell: []string{"sh", "-c"}, - WorkingDir: fn.Must(os.Getwd()), - Env: map[string]string{ - "k1": "v1", - }, - Commands: []CommandJson{ - {Command: "echo hi"}, - }, - }, - wantErr: false, - }, - { - name: "2. testing global env key-shell value", - args: args{ - ctx: nil, - rf: &Runfile{ - Env: map[string]any{ - "k1": map[string]any{ - "sh": "echo hi", - }, - }, - Tasks: map[string]Task{ - "test": { - ignoreSystemEnv: true, - Commands: []any{ - "echo hi", - }, - }, - }, - }, - taskName: "test", - }, - want: &ParsedTask{ - Shell: []string{"sh", "-c"}, - WorkingDir: fn.Must(os.Getwd()), - Env: map[string]string{ - "k1": "hi", - }, - Commands: []CommandJson{ - {Command: "echo hi"}, - }, - }, - wantErr: false, - }, - { - name: "3. testing global env-var default value", - args: args{ - ctx: nil, - rf: &Runfile{ - Env: map[string]any{ - "k1": map[string]any{ - "default": map[string]any{ - "value": "default-value", - }, - }, - }, - Tasks: map[string]Task{ - "test": { - ignoreSystemEnv: true, - Commands: []any{ - "echo hi", - }, - }, - }, - }, - taskName: "test", - }, - want: &ParsedTask{ - Shell: []string{"sh", "-c"}, - WorkingDir: fn.Must(os.Getwd()), - Env: map[string]string{ - "k1": "default-value", - }, - Commands: []CommandJson{ - {Command: "echo hi"}, - }, - }, - wantErr: false, - }, - { - name: "4. overriding global env var at task level", - args: args{ - ctx: nil, - rf: &Runfile{ - Env: map[string]any{ - "k1": "v1", - }, - Tasks: map[string]Task{ - "test": { - ignoreSystemEnv: true, - Env: EnvVar{ - "k1": "task-level-v1", - }, - Commands: []any{ - "echo hi", - }, - }, - }, - }, - taskName: "test", - }, - want: &ParsedTask{ - Shell: []string{"sh", "-c"}, - WorkingDir: fn.Must(os.Getwd()), - Env: map[string]string{ - "k1": "task-level-v1", - }, - Commands: []CommandJson{ - {Command: "echo hi"}, - }, - }, - wantErr: false, - }, - - { - name: "5. required global env var", - args: args{ - ctx: nil, - rf: &Runfile{ - Env: map[string]any{ - "k1": map[string]any{ - "required": true, - }, - }, - Tasks: map[string]Task{ - "test": { - ignoreSystemEnv: true, - Commands: []any{ - "echo hi", - }, - }, - }, - }, - taskName: "test", - }, - want: nil, - wantErr: true, - }, - } - - testGlobalDotEnv := []test{ - { - name: "1. testing global env key-value item", - args: args{ - ctx: nil, - rf: &Runfile{ - DotEnv: []string{ - dotenvTestFile.Name(), - }, - Tasks: map[string]Task{ - "test": { - ignoreSystemEnv: true, - Commands: []any{ - "echo hi", - }, - }, - }, - }, - taskName: "test", - }, - want: &ParsedTask{ - Shell: []string{"sh", "-c"}, - WorkingDir: fn.Must(os.Getwd()), - Env: map[string]string{ - "hello": "world", - }, - Commands: []CommandJson{ - {Command: "echo hi"}, - }, - }, - wantErr: false, - }, - { - name: "2. fails when dotenv file not found", - args: args{ - ctx: nil, - rf: &Runfile{ - DotEnv: []string{ - dotenvTestFile.Name() + "2", - }, - Tasks: map[string]Task{ - "test": { - ignoreSystemEnv: true, - Commands: []any{ - "echo hi", - }, - }, - }, - }, - taskName: "test", - }, - want: nil, - wantErr: true, - }, - } - - tests = append(tests, testRequires...) - tests = append(tests, testEnviroments...) - tests = append(tests, testGlobalEnvVars...) - tests = append(tests, testGlobalDotEnv...) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := ParseTask(NewContext(context.TODO(), slog.Default()), tt.args.rf, tt.args.rf.Tasks[tt.args.taskName]) - if (err != nil) != tt.wantErr { - t.Errorf("ParseTask(), got = %v, error = %v, wantErr %v", got, err, tt.wantErr) - return - } - - // if !reflect.DeepEqual(got, tt.want) { - if !tt.wantErr { - if !areEqual(t, got, tt.want) { - t.Errorf("ParseTask():> \n\tgot:\t%v,\n\twant:\t%v", got, tt.want) - } - } - }) - } -} diff --git a/pkg/runfile/tui.go b/pkg/runfile/tui.go deleted file mode 100644 index 7136084..0000000 --- a/pkg/runfile/tui.go +++ /dev/null @@ -1,25 +0,0 @@ -package runfile - -import ( - "github.com/charmbracelet/bubbles/textarea" - tea "github.com/charmbracelet/bubbletea" -) - -type model struct { - label string -} - -func (m model) Init() tea.Cmd { - return textarea.Blink -} - -func (m model) View() string { - // s := lipgloss.NewStyle().Border(lipgloss.BlockBorder(), false, false, false, true) - // return fmt.Sprintf( - // "%s%s%s", - // m.viewport.View(), - // gap, - // m.textarea.View(), - // ) - return "" -} diff --git a/runner/run-task.go b/runner/run-task.go new file mode 100644 index 0000000..1932669 --- /dev/null +++ b/runner/run-task.go @@ -0,0 +1,174 @@ +package runner + +import ( + "bytes" + "fmt" + "os" + "strings" + + "github.com/alecthomas/chroma/quick" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + "github.com/nxtcoder17/runfile/errors" + fn "github.com/nxtcoder17/runfile/functions" + "github.com/nxtcoder17/runfile/parser" + "github.com/nxtcoder17/runfile/types" +) + +type runTaskArgs struct { + taskTrail []string + taskName string + envOverrides map[string]string +} + +func isDarkTheme() bool { + return termenv.NewOutput(os.Stdout).HasDarkBackground() +} + +func runTask(ctx Context, prf *types.ParsedRunfile, args runTaskArgs) error { + runfilePath := prf.Metadata.RunfilePath + task := prf.Tasks[args.taskName] + + if task.Metadata.RunfilePath != nil { + runfilePath = *task.Metadata.RunfilePath + } + + trail := append(args.taskTrail, args.taskName) + + logger := ctx.With("task", args.taskName, "runfile", runfilePath) + logger.Debug("running task") + + task, ok := prf.Tasks[args.taskName] + if !ok { + return errors.ErrTaskNotFound + } + + task.Name = args.taskName + + pt, err := parser.ParseTask(ctx, prf, task) + if err != nil { + return errors.ErrTaskParsingFailed.Wrap(err) + } + + for _, command := range pt.Commands { + logger.Debug("running command task", "command.run", command.Run, "parent.task", args.taskName) + + if command.If != nil && !*command.If { + logger.Debug("skipping execution for failed `if`", "command", command.Run) + continue + } + + if command.Run != "" { + if err := runTask(ctx, prf, runTaskArgs{ + taskTrail: trail, + taskName: command.Run, + envOverrides: pt.Env, + }); err != nil { + return errors.WithErr(err) + } + continue + } + + // stdoutR, stdoutW := io.Pipe() + // stderrR, stderrW := io.Pipe() + + // wg := sync.WaitGroup{} + + // [snippet source](https://rderik.com/blog/identify-if-output-goes-to-the-terminal-or-is-being-redirected-in-golang/) + // stdout, _ := os.Stdout.Stat() + // stderr, _ := os.Stderr.Stat() + // isTTY := ((stdout.Mode() & os.ModeCharDevice) == os.ModeCharDevice) && ((stderr.Mode() & os.ModeCharDevice) == os.ModeCharDevice) + // + // if isTTY { + // go func() { + // defer wg.Done() + // logPrefix := fmt.Sprintf("%s ", ctx.theme.TaskPrefixStyle.Render(fmt.Sprintf("[%s]", strings.Join(trail, "/")))) + // processOutput(os.Stdout, stdoutR, &logPrefix) + // + // stderrPrefix := fmt.Sprintf("%s ", ctx.theme.TaskPrefixStyle.Render(fmt.Sprintf("[%s/stderr]", strings.Join(trail, "/")))) + // processOutput(os.Stderr, stderrR, &stderrPrefix) + // }() + // } else { + // wg.Add(1) + // go func() { + // defer wg.Done() + // logPrefix := fmt.Sprintf("%s ", ctx.theme.TaskPrefixStyle.Render(fmt.Sprintf("[%s]", strings.Join(trail, "/")))) + // processOutput(os.Stdout, stdoutR, &logPrefix) + // // if pt.Interactive { + // // processOutput(os.Stdout, stdoutR, &logPrefix) + // // return + // // } + // // processOutputLineByLine(os.Stdout, stdoutR, &logPrefix) + // }() + // + // wg.Add(1) + // go func() { + // defer wg.Done() + // logPrefix := fmt.Sprintf("%s ", ctx.theme.TaskPrefixStyle.Render(fmt.Sprintf("[%s/stderr]", strings.Join(trail, "/")))) + // processOutput(os.Stderr, stderrR, &logPrefix) + // // if pt.Interactive { + // // processOutput(os.Stderr, stderrR, &logPrefix) + // // return + // // } + // // processOutputLineByLine(os.Stderr, stderrR, &logPrefix) + // }() + // } + + borderColor := "#4388cc" + if !isDarkTheme() { + borderColor = "#3d5485" + } + s := lipgloss.NewStyle().BorderForeground(lipgloss.Color(borderColor)).PaddingLeft(1).PaddingRight(1).Border(lipgloss.RoundedBorder(), true, true, true, true) + + hlCode := new(bytes.Buffer) + // choose colorschemes from `https://swapoff.org/chroma/playground/` + colorscheme := "catppuccin-macchiato" + if !isDarkTheme() { + colorscheme = "monokailight" + } + quick.Highlight(hlCode, strings.TrimSpace(command.Command), "bash", "terminal16m", colorscheme) + + fmt.Printf("%s\n", s.Render(hlCode.String())) + + cmd := CreateCommand(ctx, CmdArgs{ + Shell: pt.Shell, + Env: fn.ToEnviron(pt.Env), + Cmd: command.Command, + WorkingDir: pt.WorkingDir, + interactive: pt.Interactive, + Stdout: os.Stdout, + Stderr: os.Stderr, + }) + + // ex := executor.NewExecutor(executor.ExecutorArgs{ + // Logger: logger, + // Command: func(c context.Context) *exec.Cmd { + // return CreateCommand(c, CmdArgs{ + // Shell: pt.Shell, + // Env: fn.ToEnviron(pt.Env), + // Cmd: command.Command, + // WorkingDir: pt.WorkingDir, + // interactive: pt.Interactive, + // Stdout: os.Stdout, + // Stderr: os.Stderr, + // // stdout: stdoutW, + // // stderr: stderrW, + // }) + // }, + // }) + + if err := cmd.Run(); err != nil { + return errors.ErrTaskFailed.Wrap(err).KV("cmd", cmd.String()) + } + + // stdoutW.Close() + // stderrW.Close() + + // wg.Wait() + // if err := ex.Exec(); err != nil { + // return errors.ErrTaskFailed.Wrap(err).KV("task", args.taskName) + // } + } + + return nil +} diff --git a/runner/runner.go b/runner/runner.go new file mode 100644 index 0000000..2b1518a --- /dev/null +++ b/runner/runner.go @@ -0,0 +1,124 @@ +package runner + +import ( + "context" + "io" + "log/slog" + "os" + "os/exec" + + "github.com/nxtcoder17/runfile/errors" + "github.com/nxtcoder17/runfile/types" + "golang.org/x/sync/errgroup" +) + +type CmdArgs struct { + Shell []string + Env []string // [key=value, key=value, ...] + WorkingDir string + + Cmd string + + interactive bool + Stdout io.Writer + Stderr io.Writer +} + +func CreateCommand(ctx context.Context, args CmdArgs) *exec.Cmd { + if args.Shell == nil { + args.Shell = []string{"sh", "-c"} + } + + if args.Stdout == nil { + args.Stdout = os.Stdout + } + + if args.Stderr == nil { + args.Stderr = os.Stderr + } + + shell := args.Shell[0] + + cargs := append(args.Shell[1:], args.Cmd) + c := exec.CommandContext(ctx, shell, cargs...) + c.Dir = args.WorkingDir + c.Env = args.Env + c.Stdout = args.Stdout + c.Stderr = args.Stderr + + if args.interactive { + c.Stdin = os.Stdin + } + + return c +} + +type RunArgs struct { + Tasks []string + ExecuteInParallel bool + Watch bool + Debug bool + KVs map[string]string +} + +type Context struct { + context.Context + *slog.Logger +} + +func NewContext(ctx context.Context, logger *slog.Logger) Context { + return Context{Context: ctx, Logger: logger} +} + +func Run(ctx Context, prf *types.ParsedRunfile, args RunArgs) error { + // INFO: adding parsed KVs as environments to the specified tasks + for k, v := range args.KVs { + if prf.Env == nil { + prf.Env = make(map[string]string) + } + prf.Env[k] = v + } + + attr := func(taskName string) []any { + return []any{ + "runfile", prf.Metadata.RunfilePath, + "task", taskName, + } + } + + for _, taskName := range args.Tasks { + if _, ok := prf.Tasks[taskName]; !ok { + return errors.ErrTaskNotFound.KV(attr(taskName)...) + } + } + + if args.ExecuteInParallel { + ctx.Debug("running in parallel mode", "tasks", args.Tasks) + g := new(errgroup.Group) + + for _, _tn := range args.Tasks { + tn := _tn + g.Go(func() error { + if err := runTask(ctx, prf, runTaskArgs{taskName: tn}); err != nil { + return errors.ErrTaskFailed.Wrap(err).KV(attr(tn)...) + } + return nil + }) + } + + // Wait for all tasks to finish + if err := g.Wait(); err != nil { + return err + } + + return nil + } + + for _, tn := range args.Tasks { + if err := runTask(ctx, prf, runTaskArgs{taskName: tn}); err != nil { + return errors.ErrTaskFailed.Wrap(err).KV(attr(tn)...) + } + } + + return nil +} diff --git a/types/parsed-types.go b/types/parsed-types.go new file mode 100644 index 0000000..955b2c8 --- /dev/null +++ b/types/parsed-types.go @@ -0,0 +1,9 @@ +package types + +type ParsedTask struct { + Shell []string `json:"shell"` + WorkingDir string `json:"workingDir"` + Env map[string]string `json:"environ"` + Interactive bool `json:"interactive,omitempty"` + Commands []ParsedCommandJson `json:"commands"` +} diff --git a/pkg/runfile/task.go b/types/types.go similarity index 69% rename from pkg/runfile/task.go rename to types/types.go index 70c7659..5103559 100644 --- a/pkg/runfile/task.go +++ b/types/types.go @@ -1,4 +1,26 @@ -package runfile +package types + +type Runfile struct { + Version string `json:"version,omitempty"` + Includes map[string]IncludeSpec `json:"includes"` + Env EnvVar `json:"env,omitempty"` + DotEnv []string `json:"dotEnv,omitempty"` + Tasks map[string]Task `json:"tasks"` +} + +type ParsedRunfile struct { + Env map[string]string `json:"env,omitempty"` + Tasks map[string]Task `json:"tasks"` + + Metadata struct { + RunfilePath string + } `json:"-"` +} + +type IncludeSpec struct { + Runfile string `json:"runfile"` + Dir string `json:"dir,omitempty"` +} // Only one of the fields must be set type Requires struct { @@ -17,7 +39,16 @@ Object values with `sh` key, such that the output of this command will be the va */ type EnvVar map[string]any +type TaskMetadata struct { + RunfilePath string `json:"-"` + Description string `json:"description"` +} + type Task struct { + Metadata struct { + RunfilePath *string + } + Name string `json:"-"` // Shell in which above commands will be executed // Default: ["sh", "-c"] @@ -69,3 +100,7 @@ type ParsedCommandJson struct { // If is a go template expression, which must evaluate to true, for task to run If *bool `json:"if"` } + +type ParsedIncludeSpec struct { + Runfile *Runfile +} From b4edd4f051e3327106b6493153bd363beca0e843 Mon Sep 17 00:00:00 2001 From: nxtcoder17 Date: Thu, 12 Dec 2024 10:47:47 +0530 Subject: [PATCH 2/3] feat: properly kills the running process on KeyboardInterrupt --- cmd/run/main.go | 1 - examples/Runfile.yml | 30 +++--- parser/parse-task.go | 2 +- parser/{parser.go => parser-runfile.go} | 0 runner/run-task.go | 122 ++++++++++++++---------- runner/runner.go | 4 +- 6 files changed, 92 insertions(+), 67 deletions(-) rename parser/{parser.go => parser-runfile.go} (100%) diff --git a/cmd/run/main.go b/cmd/run/main.go index 2516782..af450ee 100644 --- a/cmd/run/main.go +++ b/cmd/run/main.go @@ -220,7 +220,6 @@ func main() { go func() { <-ctx.Done() cf() - os.Exit(1) }() if err := cmd.Run(ctx, os.Args); err != nil { diff --git a/examples/Runfile.yml b/examples/Runfile.yml index 1b44a33..9242819 100644 --- a/examples/Runfile.yml +++ b/examples/Runfile.yml @@ -23,23 +23,23 @@ tasks: sh: echo -n "hello" k4: required: true - k5: - default: - # value: "this is default value" - # sh: echo this should be the default value - gotmpl: len "asdfadf" + # k5: + # default: + # # value: "this is default value" + # # sh: echo this should be the default value + # gotmpl: len "asdfadf" # dotenv: # - ../.secrets/env cmd: # - sleep 5 - # - echo "hi hello" - # - echo "value of k1 is '$k1'" - # - echo "value of k2 is '$k2'" - # - echo "value of k3 is '$k3'" + - echo "hi hello" + - echo "value of k1 is '$k1'" + - echo "value of k2 is '$k2'" + - echo "value of k3 is '$k3'" - echo "hello from cook" - echo "value of key_id (from .dotenv) is '$key_id', ${#key_id}" - echo "k4 is $k4" - - echo "k5 is $k5" + # - echo "k5 is $k5" clean: name: clean @@ -62,11 +62,11 @@ tasks: laundry: name: laundry shell: ["node", "-e"] - # env: - # k4: - # default: - # sh: |+ - # console.log('1234' == '23344') + env: + k4: + default: + sh: |+ + console.log('1234' == '23344') cmd: - run: cook - console.log(process.env.k4) diff --git a/parser/parse-task.go b/parser/parse-task.go index b69f921..ba1935a 100644 --- a/parser/parse-task.go +++ b/parser/parse-task.go @@ -37,7 +37,7 @@ func ParseTask(ctx context.Context, prf *types.ParsedRunfile, task types.Task) ( Env: prf.Env, }) if err != nil { - return nil, err + return nil, errors.WithErr(err) } for k, v := range tenv { diff --git a/parser/parser.go b/parser/parser-runfile.go similarity index 100% rename from parser/parser.go rename to parser/parser-runfile.go diff --git a/runner/run-task.go b/runner/run-task.go index 1932669..55c811d 100644 --- a/runner/run-task.go +++ b/runner/run-task.go @@ -2,13 +2,16 @@ package runner import ( "bytes" + "context" "fmt" "os" + "os/exec" "strings" "github.com/alecthomas/chroma/quick" "github.com/charmbracelet/lipgloss" "github.com/muesli/termenv" + "github.com/nxtcoder17/fwatcher/pkg/executor" "github.com/nxtcoder17/runfile/errors" fn "github.com/nxtcoder17/runfile/functions" "github.com/nxtcoder17/runfile/parser" @@ -25,6 +28,26 @@ func isDarkTheme() bool { return termenv.NewOutput(os.Stdout).HasDarkBackground() } +func padString(v string, padWith string) string { + sp := strings.Split(v, "\n") + for i := range sp { + if i == 0 { + sp[i] = fmt.Sprintf("%s | %s", padWith, sp[i]) + continue + } + sp[i] = fmt.Sprintf("%s | %s", strings.Repeat(" ", len(padWith)), sp[i]) + } + + return strings.Join(sp, "\n") +} + +// [snippet source](https://rderik.com/blog/identify-if-output-goes-to-the-terminal-or-is-being-redirected-in-golang/) +func isTTY() bool { + stdout, _ := os.Stdout.Stat() + stderr, _ := os.Stderr.Stat() + return ((stdout.Mode() & os.ModeCharDevice) == os.ModeCharDevice) && ((stderr.Mode() & os.ModeCharDevice) == os.ModeCharDevice) +} + func runTask(ctx Context, prf *types.ParsedRunfile, args runTaskArgs) error { runfilePath := prf.Metadata.RunfilePath task := prf.Tasks[args.taskName] @@ -47,7 +70,7 @@ func runTask(ctx Context, prf *types.ParsedRunfile, args runTaskArgs) error { pt, err := parser.ParseTask(ctx, prf, task) if err != nil { - return errors.ErrTaskParsingFailed.Wrap(err) + return errors.WithErr(err) } for _, command := range pt.Commands { @@ -64,7 +87,7 @@ func runTask(ctx Context, prf *types.ParsedRunfile, args runTaskArgs) error { taskName: command.Run, envOverrides: pt.Env, }); err != nil { - return errors.WithErr(err) + return errors.WithErr(err).KV("env-vars", prf.Env) } continue } @@ -114,60 +137,63 @@ func runTask(ctx Context, prf *types.ParsedRunfile, args runTaskArgs) error { // }() // } - borderColor := "#4388cc" - if !isDarkTheme() { - borderColor = "#3d5485" - } - s := lipgloss.NewStyle().BorderForeground(lipgloss.Color(borderColor)).PaddingLeft(1).PaddingRight(1).Border(lipgloss.RoundedBorder(), true, true, true, true) + if isTTY() { + borderColor := "#4388cc" + if !isDarkTheme() { + borderColor = "#3d5485" + } + s := lipgloss.NewStyle().BorderForeground(lipgloss.Color(borderColor)).PaddingLeft(1).PaddingRight(1).Border(lipgloss.RoundedBorder(), true, true, true, true) - hlCode := new(bytes.Buffer) - // choose colorschemes from `https://swapoff.org/chroma/playground/` - colorscheme := "catppuccin-macchiato" - if !isDarkTheme() { - colorscheme = "monokailight" + hlCode := new(bytes.Buffer) + // choose colorschemes from `https://swapoff.org/chroma/playground/` + colorscheme := "catppuccin-macchiato" + if !isDarkTheme() { + colorscheme = "monokailight" + } + quick.Highlight(hlCode, strings.TrimSpace(command.Command), "bash", "terminal16m", colorscheme) + + // fmt.Printf("%s\n", s.Render(args.taskName+" | "+hlCode.String())) + fmt.Printf("%s\n", s.Render(padString(hlCode.String(), args.taskName))) } - quick.Highlight(hlCode, strings.TrimSpace(command.Command), "bash", "terminal16m", colorscheme) - - fmt.Printf("%s\n", s.Render(hlCode.String())) - - cmd := CreateCommand(ctx, CmdArgs{ - Shell: pt.Shell, - Env: fn.ToEnviron(pt.Env), - Cmd: command.Command, - WorkingDir: pt.WorkingDir, - interactive: pt.Interactive, - Stdout: os.Stdout, - Stderr: os.Stderr, - }) - // ex := executor.NewExecutor(executor.ExecutorArgs{ - // Logger: logger, - // Command: func(c context.Context) *exec.Cmd { - // return CreateCommand(c, CmdArgs{ - // Shell: pt.Shell, - // Env: fn.ToEnviron(pt.Env), - // Cmd: command.Command, - // WorkingDir: pt.WorkingDir, - // interactive: pt.Interactive, - // Stdout: os.Stdout, - // Stderr: os.Stderr, - // // stdout: stdoutW, - // // stderr: stderrW, - // }) - // }, + // cmd := CreateCommand(ctx, CmdArgs{ + // Shell: pt.Shell, + // Env: fn.ToEnviron(pt.Env), + // Cmd: command.Command, + // WorkingDir: pt.WorkingDir, + // interactive: pt.Interactive, + // Stdout: os.Stdout, + // Stderr: os.Stderr, // }) - if err := cmd.Run(); err != nil { - return errors.ErrTaskFailed.Wrap(err).KV("cmd", cmd.String()) - } + // logger2 := logging.New(logging.Options{ + // Prefix: "executor", + // ShowDebugLogs: true, + // }) - // stdoutW.Close() - // stderrW.Close() + ex := executor.NewExecutor(executor.ExecutorArgs{ + Logger: logger, + Command: func(c context.Context) *exec.Cmd { + return CreateCommand(c, CmdArgs{ + Shell: pt.Shell, + Env: fn.ToEnviron(pt.Env), + Cmd: command.Command, + WorkingDir: pt.WorkingDir, + interactive: pt.Interactive, + Stdout: os.Stdout, + Stderr: os.Stderr, + }) + }, + }) - // wg.Wait() - // if err := ex.Exec(); err != nil { - // return errors.ErrTaskFailed.Wrap(err).KV("task", args.taskName) + // if err := cmd.Run(); err != nil { + // return errors.ErrTaskFailed.Wrap(err).KV("cmd", cmd.String()) // } + + // wg.Wait() + if err := ex.Exec(); err != nil { + return errors.ErrTaskFailed.Wrap(err).KV("task", args.taskName) + } } return nil diff --git a/runner/runner.go b/runner/runner.go index 2b1518a..a1b207d 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -100,7 +100,7 @@ func Run(ctx Context, prf *types.ParsedRunfile, args RunArgs) error { tn := _tn g.Go(func() error { if err := runTask(ctx, prf, runTaskArgs{taskName: tn}); err != nil { - return errors.ErrTaskFailed.Wrap(err).KV(attr(tn)...) + return errors.WithErr(err).KV(attr(tn)...) } return nil }) @@ -116,7 +116,7 @@ func Run(ctx Context, prf *types.ParsedRunfile, args RunArgs) error { for _, tn := range args.Tasks { if err := runTask(ctx, prf, runTaskArgs{taskName: tn}); err != nil { - return errors.ErrTaskFailed.Wrap(err).KV(attr(tn)...) + return errors.WithErr(err).KV(attr(tn)...) } } From 38bb2a197ec40849c16d5ddb69f6a09869314fb5 Mon Sep 17 00:00:00 2001 From: nxtcoder17 Date: Thu, 12 Dec 2024 13:20:04 +0530 Subject: [PATCH 3/3] feat: adds watch support, and TTY detection, with better tests --- go.mod | 22 +++---------- go.sum | 56 +++++++------------------------- runner/run-task.go | 74 +++++++++++++++++++++++++++++++------------ types/parsed-types.go | 1 + types/types.go | 13 ++++++-- 5 files changed, 81 insertions(+), 85 deletions(-) diff --git a/go.mod b/go.mod index f989270..78d1793 100644 --- a/go.mod +++ b/go.mod @@ -3,15 +3,11 @@ module github.com/nxtcoder17/runfile go 1.22.7 require ( - github.com/alecthomas/chroma v0.10.0 - github.com/alecthomas/chroma/v2 v2.14.0 - github.com/charmbracelet/bubbles v0.20.0 - github.com/charmbracelet/bubbletea v1.2.4 + github.com/alecthomas/chroma/v2 v2.14.1-0.20241203085416-67f0e3b31d46 github.com/charmbracelet/lipgloss v1.0.0 - github.com/go-task/slim-sprig/v3 v3.0.0 github.com/joho/godotenv v1.5.1 github.com/muesli/termenv v0.15.2 - github.com/nxtcoder17/fwatcher v1.0.3-0.20241210142126-8465e393708a + github.com/nxtcoder17/fwatcher v1.0.3-0.20241212071621-beb84ec5b061 github.com/phuslu/log v1.0.112 github.com/urfave/cli/v3 v3.0.0-beta1 golang.org/x/sync v0.10.0 @@ -19,22 +15,14 @@ require ( ) require ( - github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/x/ansi v0.5.2 // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/dlclark/regexp2 v1.11.0 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/charmbracelet/x/ansi v0.6.0 // indirect + github.com/dlclark/regexp2 v1.11.4 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // 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/rivo/uniseg v0.4.7 // indirect - github.com/stretchr/testify v1.9.0 // indirect - github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect golang.org/x/sys v0.28.0 // indirect - golang.org/x/text v0.3.8 // indirect ) diff --git a/go.sum b/go.sum index 8dc321e..168c93e 100644 --- a/go.sum +++ b/go.sum @@ -1,37 +1,21 @@ -github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= -github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= -github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= -github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= -github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= -github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= -github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.14.1-0.20241203085416-67f0e3b31d46 h1:s18LOL63NvaZHsxUMGBIxcEVJK4un5SzXuGvCGPz0lg= +github.com/alecthomas/chroma/v2 v2.14.1-0.20241203085416-67f0e3b31d46/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= -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/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= -github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= -github.com/charmbracelet/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE= -github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM= github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= -github.com/charmbracelet/x/ansi v0.5.2 h1:dEa1x2qdOZXD/6439s+wF7xjV+kZLu/iN00GuXXrU9E= -github.com/charmbracelet/x/ansi v0.5.2/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/charmbracelet/x/ansi v0.6.0 h1:qOznutrb93gx9oMiGf7caF7bqqubh6YIM0SWKyA08pA= +github.com/charmbracelet/x/ansi v0.6.0/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -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/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= -github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= +github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -43,18 +27,12 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 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/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.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= -github.com/nxtcoder17/fwatcher v1.0.3-0.20241210142126-8465e393708a h1:lAVn2+j+f1Nfe5ndya0ByTAZ30h5Y7bb4kSMQQXGVAw= -github.com/nxtcoder17/fwatcher v1.0.3-0.20241210142126-8465e393708a/go.mod h1:MNmSwXYOrqp7U1pUxh0GWB5skpjFTWTQXhAA0+sPJcU= +github.com/nxtcoder17/fwatcher v1.0.3-0.20241212071621-beb84ec5b061 h1:bNRVjvghGLiCJ9EOTS/qkrSAyKvz4e2S6CMzL8GnxgI= +github.com/nxtcoder17/fwatcher v1.0.3-0.20241212071621-beb84ec5b061/go.mod h1:MNmSwXYOrqp7U1pUxh0GWB5skpjFTWTQXhAA0+sPJcU= github.com/phuslu/log v1.0.112 h1:vQ0ZFd5O+in/0IQAcjuEl6wRkHiQPw7T0sqwmOjpL0U= github.com/phuslu/log v1.0.112/go.mod h1:F8osGJADo5qLK/0F88djWwdyoZZ9xDJQL1HYRHFEkS0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -62,27 +40,17 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN 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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/urfave/cli/v3 v3.0.0-alpha9 h1:P0RMy5fQm1AslQS+XCmy9UknDXctOmG/q/FZkUFnJSo= -github.com/urfave/cli/v3 v3.0.0-alpha9/go.mod h1:0kK/RUFHyh+yIKSfWxwheGndfnrvYSmYFVeKCh03ZUc= github.com/urfave/cli/v3 v3.0.0-beta1 h1:6DTaaUarcM0wX7qj5Hcvs+5Dm3dyUTBbEwIWAjcw9Zg= github.com/urfave/cli/v3 v3.0.0-beta1/go.mod h1:FnIeEMYu+ko8zP1F9Ypr3xkZMIDqW3DR92yUtY39q1Y= -github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= -github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= diff --git a/runner/run-task.go b/runner/run-task.go index 55c811d..44041b3 100644 --- a/runner/run-task.go +++ b/runner/run-task.go @@ -6,12 +6,15 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "strings" + "time" - "github.com/alecthomas/chroma/quick" + "github.com/alecthomas/chroma/v2/quick" "github.com/charmbracelet/lipgloss" "github.com/muesli/termenv" "github.com/nxtcoder17/fwatcher/pkg/executor" + "github.com/nxtcoder17/fwatcher/pkg/watcher" "github.com/nxtcoder17/runfile/errors" fn "github.com/nxtcoder17/runfile/functions" "github.com/nxtcoder17/runfile/parser" @@ -143,6 +146,7 @@ func runTask(ctx Context, prf *types.ParsedRunfile, args runTaskArgs) error { borderColor = "#3d5485" } s := lipgloss.NewStyle().BorderForeground(lipgloss.Color(borderColor)).PaddingLeft(1).PaddingRight(1).Border(lipgloss.RoundedBorder(), true, true, true, true) + // labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(borderColor)).Blink(true) hlCode := new(bytes.Buffer) // choose colorschemes from `https://swapoff.org/chroma/playground/` @@ -150,27 +154,19 @@ func runTask(ctx Context, prf *types.ParsedRunfile, args runTaskArgs) error { if !isDarkTheme() { colorscheme = "monokailight" } - quick.Highlight(hlCode, strings.TrimSpace(command.Command), "bash", "terminal16m", colorscheme) + _ = colorscheme + // quick.Highlight(hlCode, strings.TrimSpace(command.Command), "bash", "terminal16m", colorscheme) + + cmdStr := strings.TrimSpace(command.Command) + + quick.Highlight(hlCode, cmdStr, "bash", "terminal16m", colorscheme) + // cst := styles.Get("gruvbox") + // fmt.Println("cst: ", cst.Name, styles.Fallback.Name, styles.Names()) // fmt.Printf("%s\n", s.Render(args.taskName+" | "+hlCode.String())) fmt.Printf("%s\n", s.Render(padString(hlCode.String(), args.taskName))) } - // cmd := CreateCommand(ctx, CmdArgs{ - // Shell: pt.Shell, - // Env: fn.ToEnviron(pt.Env), - // Cmd: command.Command, - // WorkingDir: pt.WorkingDir, - // interactive: pt.Interactive, - // Stdout: os.Stdout, - // Stderr: os.Stderr, - // }) - - // logger2 := logging.New(logging.Options{ - // Prefix: "executor", - // ShowDebugLogs: true, - // }) - ex := executor.NewExecutor(executor.ExecutorArgs{ Logger: logger, Command: func(c context.Context) *exec.Cmd { @@ -186,11 +182,47 @@ func runTask(ctx Context, prf *types.ParsedRunfile, args runTaskArgs) error { }, }) - // if err := cmd.Run(); err != nil { - // return errors.ErrTaskFailed.Wrap(err).KV("cmd", cmd.String()) - // } + if task.Watch.Enable { + watch, err := watcher.NewWatcher(watcher.WatcherArgs{ + Logger: logger, + WatchDirs: append(task.Watch.Dirs, pt.WorkingDir), + OnlySuffixes: pt.Watch.OnlySuffixes, + IgnoreSuffixes: pt.Watch.IgnoreSuffixes, + ExcludeDirs: pt.Watch.ExcludeDirs, + UseDefaultIgnoreList: true, + }) + if err != nil { + return errors.WithErr(err) + } + + go ex.Exec() + + go func() { + <-ctx.Done() + logger.Info("fwatcher is closing ...") + <-time.After(200 * time.Millisecond) + os.Exit(0) + }() + + // if err := ex.Exec(); err != nil { + // return errors.ErrTaskFailed.Wrap(err).KV("task", args.taskName) + // } + + watch.WatchEvents(func(event watcher.Event, fp string) error { + relPath, err := filepath.Rel(fn.Must(os.Getwd()), fp) + if err != nil { + return err + } + logger.Info(fmt.Sprintf("[RELOADING] due changes in %s", relPath)) + ex.Kill() + <-time.After(100 * time.Millisecond) + go ex.Exec() + return nil + }) + + return nil + } - // wg.Wait() if err := ex.Exec(); err != nil { return errors.ErrTaskFailed.Wrap(err).KV("task", args.taskName) } diff --git a/types/parsed-types.go b/types/parsed-types.go index 955b2c8..2108289 100644 --- a/types/parsed-types.go +++ b/types/parsed-types.go @@ -3,6 +3,7 @@ package types type ParsedTask struct { Shell []string `json:"shell"` WorkingDir string `json:"workingDir"` + Watch TaskWatch `json:"watch"` Env map[string]string `json:"environ"` Interactive bool `json:"interactive,omitempty"` Commands []ParsedCommandJson `json:"commands"` diff --git a/types/types.go b/types/types.go index 5103559..07be5de 100644 --- a/types/types.go +++ b/types/types.go @@ -29,7 +29,7 @@ type Requires struct { } /* -EnvVar Values could take multiple forms: +// EnvVar Values could take multiple forms: - my_key: "value" or - my_key: @@ -44,6 +44,14 @@ type TaskMetadata struct { Description string `json:"description"` } +type TaskWatch struct { + Enable bool `json:"enable"` + Dirs []string `json:"dirs"` + OnlySuffixes []string `json:"onlySuffixes"` + IgnoreSuffixes []string `json:"ignoreSuffixes"` + ExcludeDirs []string `json:"excludeDirs"` +} + type Task struct { Metadata struct { RunfilePath *string @@ -67,8 +75,7 @@ type Task struct { Env EnvVar `json:"env,omitempty"` - // this field is for testing purposes only - ignoreSystemEnv bool `json:"-"` + Watch TaskWatch `json:"watch"` Requires []*Requires `json:"requires,omitempty"`