diff --git a/.envrc b/.envrc deleted file mode 100644 index a5dbbcb..0000000 --- a/.envrc +++ /dev/null @@ -1 +0,0 @@ -use flake . diff --git a/Runfile.yml b/Runfile.yml index 79635fd..5d9b325 100644 --- a/Runfile.yml +++ b/Runfile.yml @@ -1,5 +1,7 @@ tasks: build: + env: + CGO_ENABLED: 0 cmd: - |+ echo "building ..." @@ -16,12 +18,7 @@ tasks: example: dir: ./examples cmd: - - |+ - run cook clean - - test:old: - cmd: - - go test -json ./pkg/runfile | gotestfmt + - echo "hello world" test: env: @@ -30,7 +27,6 @@ tasks: only_failing: default: false watch: - enable: true dir: - ./parser onlySuffixes: @@ -43,8 +39,11 @@ tasks: testfmt_args="" [ "$only_failing" = "true" ] && testfmt_args="--hide successful-tests" - go test -json ./pkg/runfile/... $pattern_args | gotestfmt $testfmt_args + go test -json ./pkg/runfile/resolver/... $pattern_args | gotestfmt $testfmt_args + go test -json ./pkg/executor/... $pattern_args | gotestfmt $testfmt_args test:only-failing: cmd: - - go test -json ./pkg/runfile | gotestfmt --hide successful-tests + - go test -json ./pkg/runfile/resolver/... | gotestfmt --hide successful-tests + - go test -json ./pkg/executor/... | gotestfmt --hide successful-tests + diff --git a/cmd/run/completions.go b/cmd/run/completions.go index c3ea085..3abc0b4 100644 --- a/cmd/run/completions.go +++ b/cmd/run/completions.go @@ -1,25 +1,26 @@ package main -import ( - "context" - "fmt" - "io" - "log/slog" +// +// import ( +// "context" +// "fmt" +// "io" +// "log/slog" +// +// "github.com/nxtcoder17/fastlog" +// "github.com/nxtcoder17/runfile/pkg/runfile" +// ) - "github.com/nxtcoder17/fastlog" - "github.com/nxtcoder17/runfile/pkg/runfile" -) - -func generateShellCompletion(ctx context.Context, writer io.Writer, rfpath string) error { - runfile, err := runfile.ParseFromFile(runfile.NewContext(ctx, fastlog.New()), rfpath) - if err != nil { - slog.Error("parsing, got", "err", err) - panic(err) - } - - for k := range runfile.Tasks { - fmt.Fprintf(writer, "%s\n", k) - } - - return nil -} +// func generateShellCompletion(ctx context.Context, writer io.Writer, rfpath string) error { +// runfile, err := runfile.ParseFromFile(runfile.NewContext(ctx, fastlog.New()), rfpath) +// if err != nil { +// slog.Error("parsing, got", "err", err) +// panic(err) +// } +// +// for k := range runfile.Tasks { +// fmt.Fprintf(writer, "%s\n", k) +// } +// +// return nil +// } diff --git a/cmd/run/completions/run.fish b/cmd/run/completions/run.fish index 4d366ac..901493a 100644 --- a/cmd/run/completions/run.fish +++ b/cmd/run/completions/run.fish @@ -1,15 +1,32 @@ -# args: (1) -# run fish shell completion -set PROGNAME run - -# list_targets fetches all targets, with all flags provided on the cli -function list_targets - # eval $PROGNAME --list - eval (commandline -b) --list +# # args: (1) +# # run fish shell completion +# set PROGNAME run +# +# # list_targets fetches all targets, with all flags provided on the cli +# function list_targets +# # eval $PROGNAME --list +# eval (commandline -b) --list +# echo "shell:completion" +# end +# +# complete -c $PROGNAME -d "runs named task" -xa '(list_targets)' +# complete -c $PROGNAME -d "runs named task" -xa '(list_targets)' +# complete -c $PROGNAME -l help -s h -d 'show help' +# complete -c $PROGNAME -l list -s l -d 'list all tasks' +# complete -c $PROGNAME -rF -l file -s f -d 'runs targets from this runfile' + +# completion fish shell completion + +function __fish_completion_no_subcommand --description 'Test if there has been any subcommand yet' + for i in (commandline -opc) + if contains -- $i + return 1 + end + end + return 0 end -complete -c $PROGNAME -d "runs named task" -xa '(list_targets)' -complete -c $PROGNAME -l help -s h -d 'show help' -complete -c $PROGNAME -l list -s l -d 'list all tasks' -complete -c $PROGNAME -rF -l file -s f -d 'runs targets from this runfile' +complete -c run -n '__fish_completion_no_subcommand' -f -l help -s h -d 'show help' +complete -c run -n '__fish_completion_no_subcommand' -f -l list -s l -d 'list all tasks' +complete -c run -n '__fish_completion_no_subcommand' -rF -l file -s f -d 'runs targets from this runfile' diff --git a/cmd/run/main.go b/cmd/run/main.go index 53c595f..cedbd1d 100644 --- a/cmd/run/main.go +++ b/cmd/run/main.go @@ -13,7 +13,7 @@ import ( "time" "github.com/nxtcoder17/fastlog" - "github.com/nxtcoder17/runfile/pkg/errors" + "github.com/nxtcoder17/go.errors" "github.com/nxtcoder17/runfile/pkg/runfile" "github.com/urfave/cli/v3" ) @@ -95,24 +95,26 @@ func main() { return } - runfilePath, err := locateRunfile(c) - if err != nil { - slog.Error("locating runfile", "err", err) - panic(err) - } + // runfilePath, err := locateRunfile(c) + // if err != nil { + // slog.Error("locating runfile", "err", err) + // panic(err) + // } - generateShellCompletion(ctx, c.Root().Writer, runfilePath) + // generateShellCompletion(ctx, c.Root().Writer, runfilePath) }, Commands: []*cli.Command{ { - Name: "shell:completion", - Usage: "", - Suggest: true, + Name: "shell:completion", + Usage: "[shell]", + EnableShellCompletion: false, Action: func(ctx context.Context, c *cli.Command) error { - fmt.Printf("args: (%d)\n", c.NArg()) - if c.NArg() != 1 { - return fmt.Errorf("needs argument one of [bash,zsh,fish,ps]") + if c.NArg() == 0 { + for _, shell := range []string{"fish", "bash", "zsh", "powershell"} { + fmt.Fprintf(c.Writer, "%s\n", shell) + } + return nil } switch c.Args().First() { @@ -129,6 +131,26 @@ func main() { return nil }, }, + { + Name: "init", + EnableShellCompletion: false, + Action: func(ctx context.Context, c *cli.Command) error { + dir, err := os.Getwd() + if err != nil { + return err + } + + _, err = getRunfilePath(dir) + if err == nil { + slog.Info("Runfile already exists in current directory") + return nil + } + + // TODO: implement init command to create a sample Runfile + slog.Info("init command not yet implemented") + return nil + }, + }, }, Suggest: true, @@ -139,12 +161,12 @@ func main() { showList := c.Bool("list") if showList { - runfilePath, err := locateRunfile(c) - if err != nil { - slog.Error("locating runfile, got", "err", err) - return err - } - return generateShellCompletion(ctx, c.Root().Writer, runfilePath) + // runfilePath, err := locateRunfile(c) + // if err != nil { + // slog.Error("locating runfile, got", "err", err) + // return err + // } + // return generateShellCompletion(ctx, c.Root().Writer, runfilePath) } if c.NArg() == 0 { @@ -157,16 +179,6 @@ func main() { // INFO: for supporting flags that have been suffixed post arguments args := make([]string, 0, len(c.Args().Slice())) for _, arg := range c.Args().Slice() { - if arg == "-p" || arg == "--parallel" { - parallel = true - continue - } - - if arg == "-w" || arg == "--watch" { - watch = true - continue - } - if arg == "--debug" { debug = true continue @@ -185,13 +197,8 @@ func main() { return fmt.Errorf("parallel and watch can't be set together") } - logger := fastlog.New(fastlog.Options{ - Format: fastlog.ConsoleFormat, - EnableColors: true, - ShowCaller: debug, - ShowTimestamp: false, - ShowDebugLogs: debug, - }) + logger := fastlog.New(fastlog.Console(), fastlog.ShowDebugLogs(debug), fastlog.WithoutTimestamp()) + slog.SetDefault(logger.Slog()) runfilePath, err := locateRunfile(c) if err != nil { @@ -199,23 +206,8 @@ func main() { return err } - rctx := runfile.NewContext(ctx, logger) - - rf, err := runfile.ParseFromFile(rctx, runfilePath) - if err != nil { - slog.Error("parsing runfile, got", "err", err) - panic(err) - } - - if err := rf.Run(rctx, args, runfile.RunOption{ - ExecuteInParallel: parallel, - Watch: watch, - Debug: debug, - KVs: kv, - }); err != nil { - if err2, ok := err.(*errors.Error); ok { - logger.Error(err2.Error(), err2.SlogAttrs()...) - } + if err := runfile.RunTask(ctx, runfilePath, args[0], kv); err != nil { + return err } return nil @@ -231,8 +223,40 @@ func main() { }() if err := cmd.Run(ctx, os.Args); err != nil { - slog.Error("while running cmd, got", "err", err) + if err2, ok := err.(*errors.Error); ok { + slog.Error("failed to run task", err2.AsKeyValues()...) + return + } + slog.Error("failed to run task", "err", err) + } +} + +var ErrRunfileNotFound = fmt.Errorf("failed to locate your nearest Runfile") + +func getRunfilePath(dir string) (string, error) { + runfileNames := []string{ + "Runfile", + "Runfile.yml", + "Runfile.yaml", + } + + for _, f := range runfileNames { + stat, err := os.Stat(filepath.Join(dir, f)) + if err != nil { + if !os.IsNotExist(err) { + return "", err + } + continue + } + + if stat.IsDir() { + return "", fmt.Errorf("%s is a directory", filepath.Join(dir, f)) + } + + return filepath.Join(dir, f), nil } + + return "", ErrRunfileNotFound } func locateRunfile(c *cli.Command) (string, error) { @@ -247,28 +271,22 @@ func locateRunfile(c *cli.Command) (string, error) { oldDir := "" - runfileNames := []string{ - "Runfile", - "Runfile.yml", - "Runfile.yaml", - } - for oldDir != dir { - for _, fn := range runfileNames { - if _, err := os.Stat(filepath.Join(dir, fn)); err != nil { - if !os.IsNotExist(err) { - return "", err - } + fp, err := getRunfilePath(dir) + if err != nil { + if errors.Is(err, ErrRunfileNotFound) { + // Not found in this dir, try parent + oldDir = dir + dir = filepath.Dir(dir) continue } - - return filepath.Join(dir, fn), nil + // Some other error + return "", err } - oldDir = dir - dir = filepath.Dir(dir) + return fp, nil } - return "", fmt.Errorf("failed to locate your nearest Runfile") + return "", ErrRunfileNotFound } } diff --git a/examples/Runfile.yml b/examples/Runfile.yml index 712ea0a..eb2f331 100644 --- a/examples/Runfile.yml +++ b/examples/Runfile.yml @@ -42,35 +42,33 @@ tasks: # - echo "k5 is $k5" clean: - name: clean # shell: ["python", "-c"] shell: python # dotenv: # - ../.secrets/env cmd: - run: laundry - # vars: - # k1: v1 - - |+ - import secrets - import os - import time - # print("key_id from env: ", os.environ['key_id']) - # time.sleep(2) - print("hello from clean") - print(secrets.token_hex(32)) + env: + k4: + sh: echo "the value came from shell execution" + # - |+ + # import secrets + # import os + # import time + # # print("key_id from env: ", os.environ['key_id']) + # # time.sleep(2) + # print("hello from clean") + # print(secrets.token_hex(32)) laundry: name: laundry shell: ["node", "-e"] + silent: true env: k4: - default: - sh: |+ - echo "1234" - # console.log('1234' == '23344') + sh: echo "1234" cmd: - - run: cook + # - run: cook - console.log(process.env.k4) - console.log("hello from laundry") @@ -126,3 +124,8 @@ tasks: - run: first-and-second - echo "Hello World" + node: + interactive: true + cmd: + - node + diff --git a/flake.lock b/flake.lock deleted file mode 100644 index c1cef94..0000000 --- a/flake.lock +++ /dev/null @@ -1,60 +0,0 @@ -{ - "nodes": { - "flake-utils": { - "inputs": { - "systems": "systems" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "id": "flake-utils", - "type": "indirect" - } - }, - "nixpkgs": { - "locked": { - "lastModified": 1747744144, - "narHash": "sha256-W7lqHp0qZiENCDwUZ5EX/lNhxjMdNapFnbErcbnP11Q=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "2795c506fe8fb7b03c36ccb51f75b6df0ab2553f", - "type": "github" - }, - "original": { - "owner": "nixos", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "root": { - "inputs": { - "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs" - } - }, - "systems": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - } - }, - "root": "root", - "version": 7 -} diff --git a/flake.nix b/flake.nix deleted file mode 100644 index e772768..0000000 --- a/flake.nix +++ /dev/null @@ -1,40 +0,0 @@ -{ - description = "RunFile dev workspace"; - inputs = { - nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; - }; - - outputs = { - self, - nixpkgs, - flake-utils, - }: - flake-utils.lib.eachDefaultSystem ( - system: let - pkgs = import nixpkgs {inherit system;}; - in { - devShells.default = pkgs.mkShell { - # hardeningDisable = [ "all" ]; - - buildInputs = with pkgs; [ - # cli tools - curl - jq - yq - - pre-commit - - # programming tools - go_1_24 - - upx - - gotestfmt - ]; - - shellHook = '' - ''; - }; - } - ); -} diff --git a/go.mod b/go.mod index c5c6c08..6d48c33 100644 --- a/go.mod +++ b/go.mod @@ -9,20 +9,20 @@ require ( github.com/charmbracelet/lipgloss v1.0.0 github.com/joho/godotenv v1.5.1 github.com/muesli/termenv v0.15.2 - github.com/nxtcoder17/fastlog v0.0.0-20250702035423-1739653a5c24 + github.com/nxtcoder17/fastlog v0.0.0-20251112144402-5324a708e570 github.com/nxtcoder17/fwatcher v1.2.2-0.20250804201159-543ad31be162 + github.com/nxtcoder17/go.errors v0.0.0-20251116060059-d31bd582d4c8 github.com/urfave/cli/v3 v3.0.0-beta1 - golang.org/x/sync v0.10.0 - golang.org/x/term v0.32.0 - sigs.k8s.io/yaml v1.4.0 + golang.org/x/term v0.39.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/x/ansi v0.4.2 // indirect + github.com/creack/pty v1.1.24 // 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-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -33,6 +33,7 @@ require ( github.com/samber/lo v1.47.0 // indirect github.com/samber/slog-common v0.18.1 // indirect github.com/samber/slog-zerolog/v2 v2.7.3 // indirect - golang.org/x/sys v0.33.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.16.0 // indirect ) diff --git a/go.sum b/go.sum index 0dfd3a0..b1c48df 100644 --- a/go.sum +++ b/go.sum @@ -11,6 +11,8 @@ github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOY github.com/charmbracelet/x/ansi v0.4.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk= github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 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.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= @@ -18,9 +20,6 @@ github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cn 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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -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= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= @@ -37,10 +36,12 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 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/fastlog v0.0.0-20250702035423-1739653a5c24 h1:oLLpFv1p7jRRrrsZzyf77pECLAMKrTxzHRSoHNRoELw= -github.com/nxtcoder17/fastlog v0.0.0-20250702035423-1739653a5c24/go.mod h1:x6o+8WEHRGaWu9XEhSdTrjmDjKhVnKNXd/XZ56bNN/o= +github.com/nxtcoder17/fastlog v0.0.0-20251112144402-5324a708e570 h1:uiafpAq+4R/W7QcDJ8GiM7feWHBuojrekKNxiX+POis= +github.com/nxtcoder17/fastlog v0.0.0-20251112144402-5324a708e570/go.mod h1:x6o+8WEHRGaWu9XEhSdTrjmDjKhVnKNXd/XZ56bNN/o= github.com/nxtcoder17/fwatcher v1.2.2-0.20250804201159-543ad31be162 h1:7EHTiBm6MVUMzT8pdeavpXcxwzzIbDC0QJwre6OvGAk= github.com/nxtcoder17/fwatcher v1.2.2-0.20250804201159-543ad31be162/go.mod h1:SMwIdCpyi5fBygrkCX8hIIUeILzgoxJFaDSlhFBOWWQ= +github.com/nxtcoder17/go.errors v0.0.0-20251116060059-d31bd582d4c8 h1:C1vUEvYbbpofqK4xnbEU1htxZl66myq6ZJfHpcdA/GQ= +github.com/nxtcoder17/go.errors v0.0.0-20251116060059-d31bd582d4c8/go.mod h1:9gp0I4JikKZGKflgPqqXCPZlIznVzlPWmnv+CWIrdxE= github.com/nxtcoder17/go.pkgs v0.0.0-20250216034729-39e2d2cd48da h1:Y6GILHFlrihVfDqDPQ98y2kdUeI0SQc8tnoXh2NbEIA= github.com/nxtcoder17/go.pkgs v0.0.0-20250216034729-39e2d2cd48da/go.mod h1:raSGHj5CMHNHZf4fCV9CWpFk0hsb2CSKFZSPd4zW8JM= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -62,20 +63,22 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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= -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/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 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.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= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/nixy.yml b/nixy.yml new file mode 100644 index 0000000..0d9ac45 --- /dev/null +++ b/nixy.yml @@ -0,0 +1,11 @@ +nixpkgs: + default: 554be6495561ff07b6c724047bdd7e0716aa7b46 + +packages: + - go + - pre-commit + - gotestfmt + +onShellEnter: |+ + export PATH="/workspace/bin:$PATH" + source $HOME/.profile diff --git a/pkg/errors/constants.go b/pkg/errors/constants.go index 366c9a7..6a37f81 100644 --- a/pkg/errors/constants.go +++ b/pkg/errors/constants.go @@ -2,68 +2,74 @@ package errors import ( "fmt" + + "github.com/nxtcoder17/go.errors" ) -func ErrReadRunfile(err error) *Error { - return WrapErr(err).Msg("failed to read runfile") +func ErrReadRunfile(err error) *errors.Error { + return errors.New("failed to read runfile").Wrap(err) +} + +func ErrParseRunfile(err error) *errors.Error { + return errors.New("failed to parse runfile").Wrap(err) } -func ErrParseRunfile(err error) *Error { - return WrapErr(err).Msg("failed to parse runfile") +func ErrParseIncludes(err error) *errors.Error { + return errors.New("failed to parse includes").Wrap(err) } -func ErrParseIncludes(err error) *Error { - return WrapErr(err).Msg("failed to parse includes") +func ErrParseDotEnv(err error) *errors.Error { + return errors.New("failed to parse dotenv file").Wrap(err) } -func ErrParseDotEnv(err error) *Error { - return WrapErr(err).Msg("failed to parse dotenv file") +func ErrInvalidDotEnv(err error) *errors.Error { + return errors.New("invalid dotenv file").Wrap(err) } -func ErrInvalidDotEnv(err error) *Error { - return WrapErr(err).Msg("invalid dotenv file") +func ErrInvalidEnvVar(key string, err error) *errors.Error { + return errors.New("invalid env var (" + key + ")").Wrap(err) } -func ErrInvalidEnvVar(key string, err error) *Error { - return WrapErr(err).Msg("invalid env var (" + key + ")") +func ErrRequiredEnvVar(key string) *errors.Error { + return errors.New("required env var (" + key + ")") } -func ErrRequiredEnvVar(key string) *Error { - return WrapStr("required env var (" + key + ")") +func ErrInvalidDefaultValue(err error, key string, value any) *errors.Error { + return errors.New("invalid default value for env var (" + key + "), default: " + fmt.Sprint(value)).Wrap(err) } -func ErrInvalidDefaultValue(err error, key string, value any) *Error { - return WrapErr(err).Msg("invalid default value for env var (" + key + "), default: " + fmt.Sprint(value)) +func ErrEvalEnvVarSh(err error) *errors.Error { + return errors.New("failed while executing env-var sh script").Wrap(err) } -func ErrEvalEnvVarSh(err error) *Error { - return WrapErr(err).Msg("failed while executing env-var sh script") +func ErrTaskNotFound(taskName string) *errors.Error { + return errors.New("task not found").KV("task", taskName) } -func ErrTaskNotFound(taskName string) *Error { - return WrapStr("task not found").KV("task", taskName) +func ErrTaskFailed(err error) *errors.Error { + return errors.New("task failed").Wrap(err) } -func ErrTaskFailed(err error) *Error { - return WrapErr(err).Msg("task failed") +func ErrTaskParsingFailed(err error) *errors.Error { + return errors.New("task parsing failed").Wrap(err) } -func ErrTaskParsingFailed(err error) *Error { - return WrapErr(err).Msg("task parsing failed") +func ErrTaskRequirementNotMet(requirement string, err error) *errors.Error { + return errors.New("task requirements not met").Wrap(err).KV("requirement", requirement) } -func ErrTaskRequirementNotMet(requirement string, err error) *Error { - return WrapErr(err).Msg("task requirements not met").KV("requirement", requirement) +func ErrTaskInvalidWorkingDir(workingDir string, err error) *errors.Error { + return errors.New("task invalid working directory").Wrap(err).KV("working-dir", workingDir) } -func ErrTaskInvalidWorkingDir(workingDir string, err error) *Error { - return WrapErr(err).Msg("task invalid working directory").KV("working-dir", workingDir) +func ErrTaskInvalidCommand(command any, err error) *errors.Error { + return errors.New("task invalid command").Wrap(err).KV("command", command) } -func ErrTaskInvalidCommand(command any, err error) *Error { - return WrapErr(err).Msg("task invalid command").KV("command", command) +func ErrInvalidShellAlias(alias string) *errors.Error { + return errors.New("invalid shell alias").KV("alias", alias) } -func ErrInvalidShellAlias(alias string) *Error { - return WrapStr("invalid shell alias").KV("alias", alias) +func ErrCircularDependency(taskName string) *errors.Error { + return errors.New("circular dependency detected").KV("task", taskName) } diff --git a/pkg/errors/errors.go b/pkg/errors/errors.go deleted file mode 100644 index 920fdc3..0000000 --- a/pkg/errors/errors.go +++ /dev/null @@ -1,98 +0,0 @@ -package errors - -import ( - "errors" - "fmt" - "slices" -) - -type Error struct { - err error - - msg *string - kv map[string]any -} - -var _ error = (*Error)(nil) - -func (e *Error) Msg(msg string) *Error { - if e.err != nil { - e.msg = &msg - } - return e -} - -func (e *Error) KV(kv ...any) *Error { - if e.kv == nil { - e.kv = make(map[string]any) - } - if e != nil { - for i := 1; i < len(kv); i++ { - k, v := kv[i-1], kv[i] - if ks, ok := k.(string); ok { - e.kv[ks] = v - } - } - } - return e -} - -// Error implements error. -func (e *Error) Error() string { - if e.msg != nil { - return fmt.Sprintf("%s [%s]", e.err.Error(), *e.msg) - } - - return e.err.Error() -} - -// Unwrap returns the underlying error for error chain compatibility -func (e *Error) Unwrap() error { - return e.err -} - -func (e *Error) GetMsg() string { - if e.msg != nil { - return *e.msg - } - return "" -} - -func (e *Error) SlogAttrs() []any { - keys := make([]string, 0, len(e.kv)) - for k := range e.kv { - keys = append(keys, k) - } - - slices.Sort(keys) - - result := make([]any, 0, len(e.kv)*2) - - for _, k := range keys { - result = append(result, k, e.kv[k]) - } - - return result -} - -func WrapErr(err error) *Error { - return &Error{ - err: err, - msg: nil, - kv: nil, - } -} - -func WrapStr(v string) *Error { - return &Error{ - err: errors.New(v), - msg: nil, - kv: nil, - } -} - -// New creates a new error with the given message -// This is an alias for WrapStr for consistency -func New(msg string) *Error { - return WrapStr(msg) -} diff --git a/pkg/executor/command.go b/pkg/executor/command.go new file mode 100644 index 0000000..71dadea --- /dev/null +++ b/pkg/executor/command.go @@ -0,0 +1,48 @@ +package executor + +import ( + "context" +) + +// Command is the unit of work in a pipeline +type Command interface { + Run(ctx context.Context) error +} + +type command struct { + run func(ctx context.Context) error + preHooks []func(ctx context.Context) error + postHooks []func(ctx context.Context) error +} + +func (c *command) Run(ctx context.Context) error { + for _, h := range c.preHooks { + if err := h(ctx); err != nil { + return err + } + } + if err := c.run(ctx); err != nil { + return err + } + for _, h := range c.postHooks { + if err := h(ctx); err != nil { + return err + } + } + return nil +} + +func (c *command) AddPreHook(h func(ctx context.Context) error) *command { + c.preHooks = append(c.preHooks, h) + return c +} + +func (c *command) AddPostHook(h func(ctx context.Context) error) *command { + c.postHooks = append(c.postHooks, h) + return c +} + +// CommandFunc is now a factory +func CommandFunc(fn func(context.Context) error) *command { + return &command{run: fn} +} diff --git a/pkg/executor/pipeline.go b/pkg/executor/pipeline.go new file mode 100644 index 0000000..2d9cbb9 --- /dev/null +++ b/pkg/executor/pipeline.go @@ -0,0 +1,104 @@ +package executor + +import ( + "context" + "log/slog" + "sync" + + "golang.org/x/sync/errgroup" +) + +// Step is a pipeline Step. +// It could either have substeps or commands. Not both at the same time. +type Step struct { + SubSteps []Step + Commands []Command + + // Parallel means all the Commands/SubSteps will be executed in parallel + Parallel bool +} + +// Pipeline executes a sequence of Steps +type Pipeline struct { + logger *slog.Logger + mu sync.Mutex + cancel func() + steps []Step +} + +func NewPipeline(logger *slog.Logger, steps []Step) *Pipeline { + if logger == nil { + logger = slog.Default() + } + return &Pipeline{logger: logger, steps: steps} +} + +func (p *Pipeline) Start(parent context.Context) error { + p.mu.Lock() + ctx, cf := context.WithCancel(parent) + p.cancel = cf + defer p.mu.Unlock() + + for i := range p.steps { + step := p.steps[i] + if err := p.execStep(ctx, &step); err != nil { + return err + } + } + + return nil +} + +func (p *Pipeline) Stop() error { + if p.cancel != nil { + p.cancel() + } + return nil +} + +func (p *Pipeline) execStep(ctx context.Context, step *Step) error { + if err := p.execSubSteps(ctx, step); err != nil { + return err + } + return p.execCommands(ctx, step) +} + +func (p *Pipeline) execCommands(ctx context.Context, step *Step) error { + if step.Parallel { + g, gctx := errgroup.WithContext(ctx) + for i := range step.Commands { + cmd := step.Commands[i] + g.Go(func() error { + return cmd.Run(gctx) + }) + } + return g.Wait() + } + + for i := range step.Commands { + if err := step.Commands[i].Run(ctx); err != nil { + return err + } + } + return nil +} + +func (p *Pipeline) execSubSteps(ctx context.Context, step *Step) error { + if step.Parallel { + g, gctx := errgroup.WithContext(ctx) + for i := range step.SubSteps { + substep := &step.SubSteps[i] + g.Go(func() error { + return p.execStep(gctx, substep) + }) + } + return g.Wait() + } + + for i := range step.SubSteps { + if err := p.execStep(ctx, &step.SubSteps[i]); err != nil { + return err + } + } + return nil +} diff --git a/pkg/executor/shell-command.go b/pkg/executor/shell-command.go new file mode 100644 index 0000000..fceeb7d --- /dev/null +++ b/pkg/executor/shell-command.go @@ -0,0 +1,90 @@ +package executor + +import ( + "context" + "io" + "os" + "os/exec" + "os/signal" + "syscall" + "time" + + "github.com/creack/pty" + "golang.org/x/term" +) + +// NewInteractiveShellCommand creates a Command that runs an exec.Cmd with PTY for full terminal support +func NewInteractiveShellCommand(handler func(context.Context) *exec.Cmd) *command { + return CommandFunc(func(ctx context.Context) error { + cmd := handler(ctx) + + // Clear these - pty.Start sets them to TTY but won't override existing values + cmd.Stdout = nil + cmd.Stderr = nil + cmd.Stdin = nil + + ptmx, err := pty.Start(cmd) + if err != nil { + return err + } + defer ptmx.Close() + + // Handle terminal resize + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGWINCH) + go func() { + for range sigCh { + pty.InheritSize(os.Stdin, ptmx) + } + }() + sigCh <- syscall.SIGWINCH // Initial resize + defer signal.Stop(sigCh) + + // Set stdin to raw mode + oldState, err := term.MakeRaw(int(os.Stdin.Fd())) + if err != nil { + return err + } + defer term.Restore(int(os.Stdin.Fd()), oldState) + + // Copy I/O + go io.Copy(ptmx, os.Stdin) + io.Copy(os.Stdout, ptmx) + + return cmd.Wait() + }) +} + +// NewShellCommand creates a Command that runs an exec.Cmd with lifecycle management +func NewShellCommand(handler func(context.Context) *exec.Cmd) *command { + return CommandFunc(func(ctx context.Context) error { + cmd := handler(ctx) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + if err := cmd.Start(); err != nil { + return err + } + + pid := cmd.Process.Pid + done := make(chan error, 1) + + go func() { + done <- cmd.Wait() + }() + + select { + case err := <-done: + return err + case <-ctx.Done(): + syscall.Kill(-pid, syscall.SIGTERM) + + select { + case <-done: + case <-time.After(2 * time.Second): + syscall.Kill(-pid, syscall.SIGKILL) + <-done + } + return ctx.Err() + } + }) +} diff --git a/pkg/runfile/parse-command.go b/pkg/runfile/parse-command.go deleted file mode 100644 index 4d24919..0000000 --- a/pkg/runfile/parse-command.go +++ /dev/null @@ -1,69 +0,0 @@ -package runfile - -import ( - "encoding/json" - - "github.com/nxtcoder17/runfile/pkg/errors" - fn "github.com/nxtcoder17/runfile/pkg/functions" -) - -func parseCommand(_ *Context, command any, env map[string]string) (*ParsedCommandJson, error) { - ferr := func(err error) error { - return errors.ErrTaskInvalidCommand(command, err) - } - - switch c := command.(type) { - case string: - { - if c == "" { - return nil, ferr(errors.WrapStr("empty command")) - } - - return &ParsedCommandJson{Command: &c, Env: env}, nil - } - case map[string]any: - { - var cj 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) - } - - pcj := ParsedCommandJson{ - Env: fn.MapMerge(env, cj.Env), - } - - switch { - case cj.Run != nil: - { - if *cj.Run == "" { - return nil, ferr(errors.WrapStr("empty run target")) - } - - pcj.Run = cj.Run - } - case cj.Command != nil: - { - if *cj.Command == "" { - return nil, ferr(errors.WrapStr("empty command")) - } - pcj.Command = cj.Command - } - default: - { - return nil, errors.WrapStr("either 'run' or 'cmd' key, must be specified when setting command in json format") - } - } - - return &pcj, nil - } - default: - { - return nil, ferr(errors.WrapStr("invalid command type, must be either a string or an object")) - } - } -} diff --git a/pkg/runfile/parse-command_test.go b/pkg/runfile/parse-command_test.go deleted file mode 100644 index e697f5b..0000000 --- a/pkg/runfile/parse-command_test.go +++ /dev/null @@ -1,225 +0,0 @@ -package runfile - -import ( - "fmt" - "reflect" - "testing" - - fn "github.com/nxtcoder17/runfile/pkg/functions" -) - -func testParseCommandJsonEqual(t *testing.T, got, want *ParsedCommandJson) { - if got == nil && want != nil || got != nil && want == nil { - t.Errorf("parseCommand(),\n[.command] \n\tgot = %v\n\twant = %v", got, want) - return - } - - // t.Log("first", first, "err", err, "secondErr", secondErr, "condition", secondErr != (err != nil)) - - if !reflect.DeepEqual(got.Command, want.Command) { - t.Errorf("parseCommand(),\n[.command] \n\tgot = %v\n\twant = %v", fn.DefaultIfNil(got.Command, ""), fn.DefaultIfNil(want.Command, "")) - return - } - - if fmt.Sprint(got.Env) != fmt.Sprint(want.Env) { - t.Errorf("parseCommand(),\n[.env] \n\tgot = %+v\n\twant = %+v", got.Env, want.Env) - return - } -} - -func Test_parseCommand(t *testing.T) { - type args struct { - command any - env map[string]string - } - tests := []struct { - name string - args args - want *ParsedCommandJson - wantErr bool - }{ - { - name: "1. When command is simple string without env var, It should parse as command", - args: args{ - command: "echo hello", - }, - want: &ParsedCommandJson{ - Command: stringPtr("echo hello"), - }, - wantErr: false, - }, - { - name: "2. When command is simple string with env var, It should include env", - args: args{ - command: "echo hello", - env: map[string]string{"FOO": "bar"}, - }, - want: &ParsedCommandJson{ - Command: fn.Ptr("echo hello"), - Env: map[string]string{"FOO": "bar"}, - }, - wantErr: false, - }, - { - name: "3. When command uses json format with cmd key, It should merge environments", - args: args{ - command: map[string]any{ - "cmd": "ls -la", - "env": map[string]any{ - "KEY1": "value1", - }, - }, - env: map[string]string{"FOO": "bar"}, - }, - want: &ParsedCommandJson{ - Command: stringPtr("ls -la"), - Env: map[string]string{ - "FOO": "bar", - "KEY1": "value1", - }, - }, - wantErr: false, - }, - { - name: "4. When command uses json format with run key, It should parse as task reference", - args: args{ - command: map[string]any{ - "run": "build:prod", - "env": map[string]any{ - "NODE_ENV": "production", - }, - }, - env: map[string]string{"BASE": "value"}, - }, - want: &ParsedCommandJson{ - Run: stringPtr("build:prod"), - Env: map[string]string{ - "BASE": "value", - "NODE_ENV": "production", - }, - }, - wantErr: false, - }, - { - name: "5. When json format has no cmd or run key, It should fail", - args: args{ - command: map[string]any{ - "invalid": "test", - }, - env: map[string]string{}, - }, - want: nil, - wantErr: true, - }, - { - name: "6. When command type is invalid (number), It should fail", - args: args{ - command: 123, - env: map[string]string{}, - }, - want: nil, - wantErr: true, - }, - { - name: "7. When both cmd and run keys exist, It should use run key", - args: args{ - command: map[string]any{ - "cmd": "echo test", - "run": "other:task", - }, - env: map[string]string{}, - }, - want: &ParsedCommandJson{ - Run: stringPtr("other:task"), - Env: map[string]string{}, - }, - wantErr: false, - }, - { - name: "8. When command is empty string, It should fail", - args: args{ - command: "", - env: map[string]string{"TEST": "value"}, - }, - want: &ParsedCommandJson{ - Command: stringPtr(""), - Env: map[string]string{"TEST": "value"}, - }, - wantErr: true, - }, - { - name: "9. When json format has empty cmd string, It should fail", - args: args{ - command: map[string]any{ - "cmd": "", - }, - env: map[string]string{"TEST": "value"}, - }, - want: nil, - wantErr: true, - }, - { - name: "10. When json format has empty run target, It should fail", - args: args{ - command: map[string]any{ - "run": "", - }, - env: map[string]string{"TEST": "value"}, - }, - want: nil, - wantErr: true, - }, - { - name: "11. When environment is nil, It should handle gracefully", - args: args{ - command: "test", - env: nil, - }, - want: &ParsedCommandJson{ - Command: stringPtr("test"), - Env: nil, - }, - wantErr: false, - }, - { - name: "12. When both cmd and run keys are empty strings, It should fail", - args: args{ - command: map[string]any{ - "cmd": "", - "run": "", - }, - env: map[string]string{}, - }, - want: nil, - wantErr: true, - }, - } - - ctx := NewTestContext() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := parseCommand(ctx, tt.args.command, tt.args.env) - if tt.wantErr && err == nil { - t.Errorf("parseCommand() error = %v, wantErr %v", err, tt.wantErr) - } - if err != nil { - if !tt.wantErr { - t.Errorf("parseCommand() error = %v, wantErr %v", err, tt.wantErr) - } - return - } - - testParseCommandJsonEqual(t, got, tt.want) - - // if !reflect.DeepEqual(got, tt.want) { - // t.Errorf("parseCommand() = %v, want %v", got, tt.want) - // } - }) - } -} - -func stringPtr(s string) *string { - return &s -} - diff --git a/pkg/runfile/parse-dotenv.go b/pkg/runfile/parse-dotenv.go deleted file mode 100644 index 07352a3..0000000 --- a/pkg/runfile/parse-dotenv.go +++ /dev/null @@ -1,48 +0,0 @@ -package runfile - -import ( - "fmt" - "io" - "os" - "path/filepath" - - "github.com/joho/godotenv" - "github.com/nxtcoder17/runfile/pkg/errors" -) - -func parseDotEnv(reader io.Reader) (map[string]string, error) { - m, err := godotenv.Parse(reader) - if err != nil { - return nil, errors.ErrParseDotEnv(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(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(err).KV("dotenv", files[i]) - } - - m, err2 := parseDotEnv(f) - if err2 != nil { - return nil, errors.ErrInvalidDotEnv(err).KV("dotenv", files[i]) - } - f.Close() - - for k, v := range m { - results[k] = v - } - } - - return results, nil -} diff --git a/pkg/runfile/parse-dotenv_test.go b/pkg/runfile/parse-dotenv_test.go deleted file mode 100644 index bc35b50..0000000 --- a/pkg/runfile/parse-dotenv_test.go +++ /dev/null @@ -1,218 +0,0 @@ -package runfile - -import ( - "bytes" - "io" - "os" - "path/filepath" - "reflect" - "testing" -) - -func Test_ParseDotEnvFile(t *testing.T) { - type args struct { - reader io.Reader - } - - tests := []struct { - name string - args args - want map[string]string - wantErr bool - }{ - { - name: "1. When key=, It should parse as empty string", - args: args{ - reader: bytes.NewBuffer([]byte(`key=`)), - }, - want: map[string]string{ - "key": "", - }, - wantErr: false, - }, - { - name: "2. When key=1, It should parse as string", - args: args{ - reader: bytes.NewBuffer([]byte(`key=1`)), - }, - want: map[string]string{ - "key": "1", - }, - wantErr: false, - }, - { - name: "3. When key=one, It should parse correctly", - args: args{ - reader: bytes.NewBuffer([]byte(`key=one`)), - }, - want: map[string]string{ - "key": "one", - }, - wantErr: false, - }, - { - name: "4. When key='one', It should strip quotes", - args: args{ - reader: bytes.NewBuffer([]byte(`key='one'`)), - }, - want: map[string]string{ - "key": "one", - }, - wantErr: false, - }, - { - name: `5. When key='o"ne', It should preserve inner quotes`, - args: args{ - reader: bytes.NewBuffer([]byte(`key='o"ne'`)), - }, - want: map[string]string{ - "key": `o"ne`, - }, - wantErr: false, - }, - { - name: `6. When key="one", It should strip quotes`, - args: args{ - reader: bytes.NewBuffer([]byte(`key="one"`)), - }, - want: map[string]string{ - "key": `one`, - }, - wantErr: false, - }, - { - name: `7. When key=sample==, It should preserve all equals signs`, - args: args{ - reader: bytes.NewBuffer([]byte(`key=sample==`)), - }, - want: map[string]string{ - "key": `sample==`, - }, - wantErr: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := parseDotEnv(tt.args.reader) - if (err != nil) != tt.wantErr { - t.Errorf("parseDotEnvFile() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("parseDotEnvFile()\n\t got: %#v,\n\twant: %#v", got, tt.want) - } - }) - } -} - -func TestParseDotEnvFiles(t *testing.T) { - // Create temporary directory for test files - tmpDir := t.TempDir() - - // Create test .env files - envFile1 := filepath.Join(tmpDir, "env1.env") - if err := os.WriteFile(envFile1, []byte("KEY1=value1\nKEY2=value2"), 0o644); err != nil { - t.Fatal(err) - } - - envFile2 := filepath.Join(tmpDir, "env2.env") - if err := os.WriteFile(envFile2, []byte("KEY2=overridden\nKEY3=value3"), 0o644); err != nil { - t.Fatal(err) - } - - emptyFile := filepath.Join(tmpDir, "empty.env") - if err := os.WriteFile(emptyFile, []byte(""), 0o644); err != nil { - t.Fatal(err) - } - - invalidFile := filepath.Join(tmpDir, "invalid.env") - if err := os.WriteFile(invalidFile, []byte("INVALID LINE WITHOUT EQUALS"), 0o644); err != nil { - t.Fatal(err) - } - - tests := []struct { - name string - files []string - want map[string]string - wantErr bool - }{ - { - name: "1. When single file is provided, It should parse all key-value pairs", - files: []string{envFile1}, - want: map[string]string{ - "KEY1": "value1", - "KEY2": "value2", - }, - wantErr: false, - }, - { - name: "2. When multiple files are provided, It should merge with later files overriding earlier", - files: []string{envFile1, envFile2}, - want: map[string]string{ - "KEY1": "value1", - "KEY2": "overridden", - "KEY3": "value3", - }, - wantErr: false, - }, - { - name: "3. When empty file list is provided, It should return empty map", - files: []string{}, - want: map[string]string{}, - wantErr: false, - }, - { - name: "4. When file is empty, It should return empty map", - files: []string{emptyFile}, - want: map[string]string{}, - wantErr: false, - }, - { - name: "5. When non-existent file is provided, It should return error", - files: []string{filepath.Join(tmpDir, "nonexistent.env")}, - want: nil, - wantErr: true, - }, - { - name: "6. When relative path is provided, It should fail", - files: []string{"relative.env"}, - want: nil, - wantErr: true, - }, - { - name: "7. When mix of valid and relative paths is provided, It should fail", - files: []string{envFile1, "relative.env"}, - want: nil, - wantErr: true, - }, - { - name: "8. When files are provided in different order, It should respect the order", - files: []string{envFile2, envFile1}, - want: map[string]string{ - "KEY1": "value1", - "KEY2": "value2", // envFile1 overrides envFile2 - "KEY3": "value3", - }, - wantErr: false, - }, - { - name: "9. When mix of valid and non-existent absolute paths is provided, It should fail", - files: []string{envFile1, filepath.Join(tmpDir, "does-not-exist.env")}, - want: nil, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := ParseDotEnvFiles(tt.files...) - if (err != nil) != tt.wantErr { - t.Errorf("ParseDotEnvFiles() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("ParseDotEnvFiles()\n\tgot: %#v\n\twant: %#v", got, tt.want) - } - }) - } -} diff --git a/pkg/runfile/parse-env.go b/pkg/runfile/parse-env.go deleted file mode 100644 index df31a36..0000000 --- a/pkg/runfile/parse-env.go +++ /dev/null @@ -1,128 +0,0 @@ -package runfile - -import ( - "bytes" - "encoding/json" - "fmt" - "os" - "os/exec" - "strings" - - "github.com/nxtcoder17/runfile/pkg/errors" - fn "github.com/nxtcoder17/runfile/pkg/functions" -) - -/* -parseEnvVars processes environment variables from EnvExpr format. - -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, ev EnvExpr, parentEnv map[string]string) (map[string]string, error) { - env := make(map[string]string, len(ev)) - for k, v := range ev { - attr := []any{"env.key", k, "env.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 := parentEnv[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.WrapStr("required field must be a boolean").KV(attr...) - } - - if required { - return nil, errors.ErrRequiredEnvVar(k).KV(attr...) - } - } - - if defaultVal, ok := v["default"]; ok { - pDefaults, err := ParseEnvVars(ctx, EnvExpr{k: defaultVal}, parentEnv) - if err != nil { - defaultValJson, _ := json.MarshalIndent(defaultVal, "", " ") - return nil, errors.ErrInvalidDefaultValue(err, k, string(defaultValJson)) - } - - if dv, ok := pDefaults[k]; ok { - env[k] = dv - continue - } - } - - b, err := json.Marshal(v) - if err != nil { - return nil, errors.ErrInvalidEnvVar(k, err).KV(attr...) - } - - var specials struct { - Sh *string `json:"sh"` - } - - if err := json.Unmarshal(b, &specials); err != nil { - return nil, errors.ErrInvalidEnvVar(k, err).KV(attr...) - } - - switch { - case specials.Sh != nil: - { - *specials.Sh = strings.TrimSpace(*specials.Sh) - cmd := exec.CommandContext(ctx, "sh", "-c", *specials.Sh) - cmd.Env = fn.ToEnviron(parentEnv) - - stdoutB := new(bytes.Buffer) - cmd.Stdout = stdoutB - - stderrB := new(bytes.Buffer) - cmd.Stderr = stderrB - if err := cmd.Run(); err != nil { - return nil, errors.ErrEvalEnvVarSh(fmt.Errorf("stderr: %s", stderrB.String())).KV(attr...) - } - - env[k] = strings.TrimSpace(stdoutB.String()) - } - default: - { - return nil, errors.ErrInvalidEnvVar(k, fmt.Errorf("invalid env format")).KV(attr...) - } - } - - default: - env[k] = fmt.Sprintf("%v", v) - } - } - - return env, nil -} diff --git a/pkg/runfile/parse-env_test.go b/pkg/runfile/parse-env_test.go deleted file mode 100644 index f734e03..0000000 --- a/pkg/runfile/parse-env_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package runfile - -import ( - "reflect" - "testing" -) - -func Test_ParseEnvExprs(t *testing.T) { - type args struct { - envVars EnvExpr - testingEnv map[string]string - } - - type test struct { - name string - args args - want map[string]string - wantErr bool - } - - tests := []test{ - { - name: "1. When required env is not provided, It should fail", - args: args{ - envVars: EnvExpr{ - "hello": map[string]any{ - "required": true, - }, - }, - testingEnv: nil, - }, - want: nil, - wantErr: true, - }, - { - name: "2. When required env is provided, It should pass", - args: args{ - envVars: EnvExpr{ - "hello": map[string]any{ - "required": true, - }, - }, - testingEnv: map[string]string{ - "hello": "world", - }, - }, - want: map[string]string{ - "hello": "world", - }, - wantErr: false, - }, - { - name: "3. When required env has no default and is not provided, It should fail", - args: args{ - envVars: EnvExpr{ - "hello": map[string]any{ - "required": true, - }, - }, - }, - wantErr: true, - }, - { - name: "4. When default value is provided, It should use the default", - args: args{ - envVars: EnvExpr{ - "hello": map[string]any{ - "default": "world", - }, - }, - testingEnv: nil, - }, - want: map[string]string{ - "hello": "world", - }, - wantErr: false, - }, - { - name: "5. When default sh command exits with non-zero, It should fail", - args: args{ - envVars: EnvExpr{ - "hello": map[string]any{ - "default": map[string]any{ - "sh": "exit 1", - }, - }, - }, - }, - wantErr: true, - }, - { - name: "6. When default sh command exits with zero, It should return the command output", - args: args{ - envVars: EnvExpr{ - "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(NewTestContext(), tt.args.envVars, tt.args.testingEnv) - if (err != nil) != tt.wantErr { - t.Errorf("ParseEnvExprs():> got = %v, error = %v, wantErr %v", got, err, tt.wantErr) - return - } - - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("parseEnvExprs():> \n\tgot:\t%v,\n\twant:\t%v", got, tt.want) - } - }) - } -} diff --git a/pkg/runfile/parse-shell.go b/pkg/runfile/parse-shell.go deleted file mode 100644 index e979df9..0000000 --- a/pkg/runfile/parse-shell.go +++ /dev/null @@ -1,48 +0,0 @@ -package runfile - -import ( - "github.com/nxtcoder17/runfile/pkg/errors" - fn "github.com/nxtcoder17/runfile/pkg/functions" -) - -var shellAliasMap = map[string][]string{ - "sh": {"sh", "-c"}, - "bash": {"bash", "-c"}, - "python": {"python", "-c"}, - "node": {"node", "-e"}, - "ruby": {"ruby", "-e"}, - "perl": {"perl", "-e"}, - "php": {"php", "-r"}, - "rust": {"cargo", "script", "-e"}, - "clojure": {"clojure", "-e"}, - "lua": {"lua", "-e"}, - "elixir": {"elixir", "-e"}, - "powershell": {"powershell", "-Command"}, - "haskell": {"runghc", "-e"}, -} - -var shellAliasKeys = fn.MapKeys(shellAliasMap) - -func (r *ParsedRunfile) ParseTaskShell(taskName string) (Shell, error) { - task, ok := r.Tasks[taskName] - if !ok { - return nil, errors.ErrTaskNotFound(taskName) - } - - if task.Shell == nil { - return shellAliasMap["sh"], nil - } - - switch val := task.Shell.(type) { - case string: - shell, ok := shellAliasMap[val] - if !ok { - return nil, errors.ErrInvalidShellAlias(val).KV("available", shellAliasKeys) - } - return shell, nil - case []string: - return val, nil - default: - return nil, errors.WrapStr("shell must be a string or []string") - } -} diff --git a/pkg/runfile/parse-shell_test.go b/pkg/runfile/parse-shell_test.go deleted file mode 100644 index a4d2aad..0000000 --- a/pkg/runfile/parse-shell_test.go +++ /dev/null @@ -1,182 +0,0 @@ -package runfile - -import ( - "reflect" - "testing" -) - -func TestParsedRunfile_ParseTaskShell(t *testing.T) { - tests := []struct { - name string - runfile *ParsedRunfile - taskName string - want Shell - wantErr bool - }{ - { - name: "1. When task has shell='sh', It should use sh -c", - runfile: &ParsedRunfile{ - Tasks: map[string]Task{ - "test": { - Shell: "sh", - }, - }, - }, - taskName: "test", - want: []string{"sh", "-c"}, - wantErr: false, - }, - { - name: "2. When task has shell='bash', It should use bash -c", - runfile: &ParsedRunfile{ - Tasks: map[string]Task{ - "build": { - Shell: "bash", - }, - }, - }, - taskName: "build", - want: []string{"bash", "-c"}, - wantErr: false, - }, - { - name: "3. When task has shell='python', It should use python -c", - runfile: &ParsedRunfile{ - Tasks: map[string]Task{ - "script": { - Shell: "python", - }, - }, - }, - taskName: "script", - want: []string{"python", "-c"}, - wantErr: false, - }, - { - name: "4. When task has custom shell array, It should use it as-is", - runfile: &ParsedRunfile{ - Tasks: map[string]Task{ - "custom": { - Shell: []string{"zsh", "-c"}, - }, - }, - }, - taskName: "custom", - want: []string{"zsh", "-c"}, - wantErr: false, - }, - { - name: "5. When task has nil shell, It should default to sh -c", - runfile: &ParsedRunfile{ - Tasks: map[string]Task{ - "default": { - Shell: nil, - }, - }, - }, - taskName: "default", - want: []string{"sh", "-c"}, - wantErr: false, - }, - { - name: "6. When task has invalid shell alias, It should fail", - runfile: &ParsedRunfile{ - Tasks: map[string]Task{ - "invalid": { - Shell: "invalidshell", - }, - }, - }, - taskName: "invalid", - want: nil, - wantErr: true, - }, - { - name: "7. When task has invalid shell type (number), It should fail", - runfile: &ParsedRunfile{ - Tasks: map[string]Task{ - "badtype": { - Shell: 123, - }, - }, - }, - taskName: "badtype", - want: nil, - wantErr: true, - }, - { - name: "8. When task is not found, It should fail", - runfile: &ParsedRunfile{ - Tasks: map[string]Task{ - "existing": { - Shell: "sh", - }, - }, - }, - taskName: "nonexistent", - want: nil, - wantErr: true, - }, - { - name: "9. When task has shell='node', It should use node -e", - runfile: &ParsedRunfile{ - Tasks: map[string]Task{ - "nodejs": { - Shell: "node", - }, - }, - }, - taskName: "nodejs", - want: []string{"node", "-e"}, - wantErr: false, - }, - { - name: "10. When task has shell='powershell', It should use powershell -Command", - runfile: &ParsedRunfile{ - Tasks: map[string]Task{ - "ps": { - Shell: "powershell", - }, - }, - }, - taskName: "ps", - want: []string{"powershell", "-Command"}, - wantErr: false, - }, - { - name: "11. When runfile has empty tasks map, It should fail", - runfile: &ParsedRunfile{ - Tasks: map[string]Task{}, - }, - taskName: "any", - want: nil, - wantErr: true, - }, - { - name: "12. When task has complex custom shell array, It should preserve all elements", - runfile: &ParsedRunfile{ - Tasks: map[string]Task{ - "docker": { - Shell: []string{"docker", "run", "--rm", "alpine", "sh", "-c"}, - }, - }, - }, - taskName: "docker", - want: []string{"docker", "run", "--rm", "alpine", "sh", "-c"}, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := tt.runfile.ParseTaskShell(tt.taskName) - if (err != nil) != tt.wantErr { - t.Errorf("ParsedRunfile.ParseTaskShell() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("ParsedRunfile.ParseTaskShell() = %v, want %v", got, tt.want) - } - }) - } -} \ No newline at end of file diff --git a/pkg/runfile/parse-task.go b/pkg/runfile/parse-task.go deleted file mode 100644 index f78adc7..0000000 --- a/pkg/runfile/parse-task.go +++ /dev/null @@ -1,76 +0,0 @@ -package runfile - -import ( - "bytes" - "fmt" - "maps" - "os" - "path/filepath" - - "github.com/nxtcoder17/runfile/pkg/errors" - fn "github.com/nxtcoder17/runfile/pkg/functions" -) - -func (r *ParsedRunfile) ParseTaskEnv(ctx *Context, taskName string, parentEnv map[string]string) (map[string]string, error) { - task, ok := r.Tasks[taskName] - if !ok { - return nil, errors.ErrTaskNotFound(taskName) - } - - env := make(map[string]string) - maps.Copy(env, parentEnv) - - maps.Copy(env, r.Env) - - dotEnvs := make([]string, 0, len(task.DotEnv)) - for i := range task.DotEnv { - de := task.DotEnv[i] - if !filepath.IsAbs(de) { - if task.Metadata.RunfilePath == nil { - return nil, errors.WrapStr("task metadata RunfilePath is nil") - } - result := filepath.Join(filepath.Dir(*task.Metadata.RunfilePath), de) - de = result - } - - dotEnvs = append(dotEnvs, de) - } - - tdotenv, err := ParseDotEnvFiles(dotEnvs...) - if err != nil { - return nil, err - } - - maps.Copy(env, tdotenv) - - tenv, err := ParseEnvVars(ctx, task.Env, env) - if err != nil { - return nil, err - } - - maps.Copy(env, tenv) - - for _, requirement := range task.Requires { - if requirement != nil && requirement.Sh != nil { - var stderrBuf bytes.Buffer - cmd := CreateCommand(ctx, CmdArgs{ - Shell: shellAliasMap["sh"], - Env: fn.ToEnviron(env), - WorkingDir: task.Dir, - Cmd: *requirement.Sh, - interactive: task.Interactive, - Stdout: fn.Must(os.OpenFile(os.DevNull, os.O_WRONLY, 0o755)), - Stderr: &stderrBuf, - }) - - if err := cmd.Run(); err != nil { - return nil, errors.ErrTaskRequirementNotMet( - *requirement.Sh, - fmt.Errorf("%w: %s", err, stderrBuf.String()), - ) - } - } - } - - return env, nil -} diff --git a/pkg/runfile/parse-task_test.go b/pkg/runfile/parse-task_test.go deleted file mode 100644 index 7caee62..0000000 --- a/pkg/runfile/parse-task_test.go +++ /dev/null @@ -1,341 +0,0 @@ -package runfile - -import ( - "os" - "path/filepath" - "reflect" - "testing" -) - -func TestParsedRunfile_ParseTaskEnv(t *testing.T) { - // Create a temporary directory for test dotenv files - tmpDir := t.TempDir() - - // Create test .env files - testEnvFile := filepath.Join(tmpDir, "test.env") - if err := os.WriteFile(testEnvFile, []byte("TEST_VAR=test_value\nANOTHER_VAR=another_value"), 0o644); err != nil { - t.Fatal(err) - } - - prodEnvFile := filepath.Join(tmpDir, "prod.env") - if err := os.WriteFile(prodEnvFile, []byte("PROD_VAR=production\nTEST_VAR=overridden"), 0o644); err != nil { - t.Fatal(err) - } - - tests := []struct { - name string - runfile *ParsedRunfile - taskName string - parentEnv map[string]string - want map[string]string - wantErr bool - }{ - { - name: "1. When task has no env, It should inherit parent and global env", - runfile: &ParsedRunfile{ - Env: map[string]string{"GLOBAL": "global_value"}, - Tasks: map[string]Task{ - "simple": { - Metadata: struct { - RunfilePath *string - Namespace string - }{ - RunfilePath: stringPtr(filepath.Join(tmpDir, "Runfile")), - }, - }, - }, - }, - taskName: "simple", - parentEnv: map[string]string{"PARENT": "parent_value"}, - want: map[string]string{ - "PARENT": "parent_value", - "GLOBAL": "global_value", - }, - wantErr: false, - }, - { - name: "2. When task has env vars, It should merge with parent and global", - runfile: &ParsedRunfile{ - Env: map[string]string{"GLOBAL": "global_value"}, - Tasks: map[string]Task{ - "with-env": { - Metadata: struct { - RunfilePath *string - Namespace string - }{ - RunfilePath: stringPtr(filepath.Join(tmpDir, "Runfile")), - }, - Env: EnvExpr{ - "TASK_VAR": "task_value", - "COMPUTED": "prefix_${GLOBAL}_suffix", - }, - }, - }, - }, - taskName: "with-env", - parentEnv: map[string]string{"PARENT": "parent_value"}, - want: map[string]string{ - "PARENT": "parent_value", - "GLOBAL": "global_value", - "TASK_VAR": "task_value", - "COMPUTED": "prefix_${GLOBAL}_suffix", - }, - wantErr: false, - }, - { - name: "3. When task has dotenv file, It should load env from file", - runfile: &ParsedRunfile{ - Env: map[string]string{}, - Tasks: map[string]Task{ - "with-dotenv": { - Metadata: struct { - RunfilePath *string - Namespace string - }{ - RunfilePath: stringPtr(filepath.Join(tmpDir, "Runfile")), - }, - DotEnv: []string{"test.env"}, - }, - }, - }, - taskName: "with-dotenv", - parentEnv: map[string]string{}, - want: map[string]string{ - "TEST_VAR": "test_value", - "ANOTHER_VAR": "another_value", - }, - wantErr: false, - }, - { - name: "4. When task has multiple dotenv files, It should merge with later overriding earlier", - runfile: &ParsedRunfile{ - Env: map[string]string{}, - Tasks: map[string]Task{ - "multi-dotenv": { - Metadata: struct { - RunfilePath *string - Namespace string - }{ - RunfilePath: stringPtr(filepath.Join(tmpDir, "Runfile")), - }, - DotEnv: []string{"test.env", "prod.env"}, - }, - }, - }, - taskName: "multi-dotenv", - parentEnv: map[string]string{}, - want: map[string]string{ - "TEST_VAR": "overridden", - "ANOTHER_VAR": "another_value", - "PROD_VAR": "production", - }, - wantErr: false, - }, - { - name: "5. When task has absolute path dotenv, It should load the file", - runfile: &ParsedRunfile{ - Env: map[string]string{}, - Tasks: map[string]Task{ - "abs-dotenv": { - Metadata: struct { - RunfilePath *string - Namespace string - }{ - RunfilePath: stringPtr(filepath.Join(tmpDir, "Runfile")), - }, - DotEnv: []string{testEnvFile}, - }, - }, - }, - taskName: "abs-dotenv", - parentEnv: map[string]string{}, - want: map[string]string{ - "TEST_VAR": "test_value", - "ANOTHER_VAR": "another_value", - }, - wantErr: false, - }, - { - name: "6. When env var exists at multiple levels, It should follow precedence: parent < global < dotenv < task", - runfile: &ParsedRunfile{ - Env: map[string]string{ - "VAR": "global", - "GLOBAL_ONLY": "global_only", - }, - Tasks: map[string]Task{ - "precedence": { - Metadata: struct { - RunfilePath *string - Namespace string - }{ - RunfilePath: stringPtr(filepath.Join(tmpDir, "Runfile")), - }, - DotEnv: []string{"test.env"}, - Env: EnvExpr{ - "VAR": "task", - "TASK_ONLY": "task_only", - }, - }, - }, - }, - taskName: "precedence", - parentEnv: map[string]string{ - "VAR": "parent", - "PARENT_ONLY": "parent_only", - }, - want: map[string]string{ - "VAR": "task", - "PARENT_ONLY": "parent_only", - "GLOBAL_ONLY": "global_only", - "TASK_ONLY": "task_only", - "TEST_VAR": "test_value", - "ANOTHER_VAR": "another_value", - }, - wantErr: false, - }, - { - name: "7. When task is not found, It should return error", - runfile: &ParsedRunfile{ - Tasks: map[string]Task{}, - }, - taskName: "nonexistent", - parentEnv: map[string]string{}, - want: nil, - wantErr: true, - }, - { - name: "8. When dotenv file does not exist, It should return error", - runfile: &ParsedRunfile{ - Env: map[string]string{}, - Tasks: map[string]Task{ - "bad-dotenv": { - Metadata: struct { - RunfilePath *string - Namespace string - }{ - RunfilePath: stringPtr(filepath.Join(tmpDir, "Runfile")), - }, - DotEnv: []string{"nonexistent.env"}, - }, - }, - }, - taskName: "bad-dotenv", - parentEnv: map[string]string{}, - want: nil, - wantErr: true, - }, - } - - ctx := NewTestContext() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := tt.runfile.ParseTaskEnv(ctx, tt.taskName, tt.parentEnv) - if (err != nil) != tt.wantErr { - t.Errorf("ParsedRunfile.ParseTaskEnv() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("ParsedRunfile.ParseTaskEnv() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestParsedRunfile_ParseTaskEnv_WithRequires(t *testing.T) { - tmpDir := t.TempDir() - - tests := []struct { - name string - runfile *ParsedRunfile - taskName string - parentEnv map[string]string - wantErr bool - }{ - { - name: "1. When requirement command succeeds, It should pass", - runfile: &ParsedRunfile{ - Env: map[string]string{}, - Tasks: map[string]Task{ - "with-requires": { - Metadata: struct { - RunfilePath *string - Namespace string - }{ - RunfilePath: stringPtr(filepath.Join(tmpDir, "Runfile")), - }, - Requires: []*Requires{ - { - Sh: stringPtr("true"), - }, - }, - }, - }, - }, - taskName: "with-requires", - parentEnv: map[string]string{}, - wantErr: false, - }, - { - name: "2. When requirement command fails, It should fail", - runfile: &ParsedRunfile{ - Env: map[string]string{}, - Tasks: map[string]Task{ - "failing-requires": { - Metadata: struct { - RunfilePath *string - Namespace string - }{ - RunfilePath: stringPtr(filepath.Join(tmpDir, "Runfile")), - }, - Requires: []*Requires{ - { - Sh: stringPtr("false"), - }, - }, - }, - }, - }, - taskName: "failing-requires", - parentEnv: map[string]string{}, - wantErr: true, - }, - { - name: "3. When requirements array contains nil, It should skip nil and continue", - runfile: &ParsedRunfile{ - Env: map[string]string{}, - Tasks: map[string]Task{ - "nil-requires": { - Metadata: struct { - RunfilePath *string - Namespace string - }{ - RunfilePath: stringPtr(filepath.Join(tmpDir, "Runfile")), - }, - Requires: []*Requires{ - nil, - { - Sh: stringPtr("true"), - }, - }, - }, - }, - }, - taskName: "nil-requires", - parentEnv: map[string]string{}, - wantErr: false, - }, - } - - ctx := NewTestContext() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := tt.runfile.ParseTaskEnv(ctx, tt.taskName, tt.parentEnv) - if (err != nil) != tt.wantErr { - t.Errorf("ParsedRunfile.ParseTaskEnv() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - diff --git a/pkg/runfile/resolver/env.go b/pkg/runfile/resolver/env.go new file mode 100644 index 0000000..2f883ed --- /dev/null +++ b/pkg/runfile/resolver/env.go @@ -0,0 +1,120 @@ +package resolver + +import ( + "bytes" + "context" + "fmt" + "maps" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/joho/godotenv" + "github.com/nxtcoder17/go.errors" + fn "github.com/nxtcoder17/runfile/pkg/functions" +) + +func parseDotEnvFilesInto(store map[string]string, files []string) error { + for _, file := range files { + if !filepath.IsAbs(file) { + return errors.New("dotenv file must have absolute paths").KV("dotenv.file", file) + } + + f, err := os.Open(file) + if err != nil { + return errors.New("failed to open dotenv file").Wrap(err).KV("dotenv.file", file) + } + + m, err := godotenv.Parse(f) + if err != nil { + return errors.New("failed to parse dotenv file").Wrap(err).KV("dotenv.file", file) + } + + if err := f.Close(); err != nil { + return errors.New("failed to close dotenv file").Wrap(err).KV("dotenv.file", file) + } + + maps.Copy(store, m) + } + + return nil +} + +func parseEnvInto(ctx context.Context, envStore map[string]string, envMap map[string]any) error { + lookupEnv := func(key string) (string, bool) { + if v, ok := os.LookupEnv(key); ok { + return v, true + } + if v, ok := envStore[key]; ok { + return v, true + } + + return "", false + } + + lazyEvalMap := make(map[string]*exec.Cmd) + + for k, v := range envMap { + // INFO: Skip if key already exists (in OS env or envStore) + if _, ok := lookupEnv(k); ok { + continue + } + + switch value := v.(type) { + case string: + envStore[k] = value + case map[string]any: + { + + if requiredVal, ok := value["required"]; ok { + isRequired, ok := requiredVal.(bool) + if !ok { + return errors.New("ENV-EXPRESSION: value field `required` must be a boolean").KV("env.key", k, "env.value", value) + } + if isRequired { + if _, exists := lookupEnv(k); !exists { + return errors.New(fmt.Sprintf("ENV-EXPRESSION: env var '%s' is required, it must be provided", k)).KV("env.key", k, "env.value", value) + } + } + } + + for optKey, optVal := range value { + if shell, ok := shellAliasMap[optKey]; ok { + envEvalScript, ok := optVal.(string) + if !ok { + return errors.New(fmt.Sprintf("ENV-EXPRESSION: value field `%s`, must have a string value", optKey)).KV("env.key", k, "env.value", value) + } + + // #nosec G204 - This is intentional: env vars with sh/bash keys are meant to + // execute shell commands defined in the Runfile. The Runfile is trusted + // user-provided configuration, similar to Makefiles or shell scripts. + lazyEvalMap[k] = exec.CommandContext(ctx, shell[0], append(shell[1:], envEvalScript)...) + break + } + } + } + default: + envStore[k] = fmt.Sprint(v) + } + } + + cmdEnv := fn.ToEnviron(envStore) + + for k, cmd := range lazyEvalMap { + cmd.Env = cmdEnv + stdout := new(bytes.Buffer) + cmd.Stdout = stdout + + stderr := new(bytes.Buffer) + cmd.Stderr = stderr + + if err := cmd.Run(); err != nil { + return errors.New("ENV-EXPRESSION: evaluation failed").Wrap(err).KV("env.key", k, "eval.cmd", cmd.String(), "eval.stderr", stderr.String()) + } + + envStore[k] = strings.TrimSpace(stdout.String()) + } + + return nil +} diff --git a/pkg/runfile/resolver/env_test.go b/pkg/runfile/resolver/env_test.go new file mode 100644 index 0000000..a3186f5 --- /dev/null +++ b/pkg/runfile/resolver/env_test.go @@ -0,0 +1,391 @@ +package resolver + +import ( + "context" + "fmt" + "maps" + "os" + "path/filepath" + "testing" +) + +func TestParseDotEnvFilesInto(t *testing.T) { + tests := []struct { + name string + fileContents []string // each entry becomes .env0, .env1, etc. + initialStore map[string]string + expected map[string]string + wantErr bool + useRelPath bool // if true, use relative path to trigger error + useNonExistent bool // if true, use non-existent path + }{ + { + name: "when files list is empty, it must pass", + fileContents: []string{}, + initialStore: map[string]string{}, + expected: map[string]string{}, + wantErr: false, + }, + { + name: "when file path is relative, it must fail", + useRelPath: true, + wantErr: true, + }, + { + name: "when file does not exist, it must fail", + useNonExistent: true, + wantErr: true, + }, + { + name: "when dotenv file has invalid syntax, it must return a parse error", + fileContents: []string{"INVALID LINE WITHOUT EQUALS\nANOTHER BAD LINE"}, + initialStore: map[string]string{}, + expected: map[string]string{}, + wantErr: true, + }, + { + name: "when file is valid, it must parse all key-value pairs", + fileContents: []string{ + `KEY1=value1 +KEY2=value2 +KEY3="quoted value"`, + }, + initialStore: map[string]string{}, + expected: map[string]string{ + "KEY1": "value1", + "KEY2": "value2", + "KEY3": "quoted value", + }, + wantErr: false, + }, + { + name: "when multiple files have same key, it must use value from last file", + fileContents: []string{ + "KEY1=value1\nKEY2=original", + "KEY2=overridden\nKEY3=value3", + }, + initialStore: map[string]string{}, + expected: map[string]string{ + "KEY1": "value1", + "KEY2": "overridden", + "KEY3": "value3", + }, + wantErr: false, + }, + { + name: "when store has existing keys, it must preserve non-overlapping values", + fileContents: []string{"NEW_KEY=new_value"}, + initialStore: map[string]string{"EXISTING": "existing_value"}, + expected: map[string]string{ + "EXISTING": "existing_value", + "NEW_KEY": "new_value", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var files []string + + if tt.useRelPath { + files = []string{"relative/path/.env"} + } else if tt.useNonExistent { + files = []string{"/non/existent/path/.env"} + } else { + tmpDir := t.TempDir() + for i, content := range tt.fileContents { + filename := fmt.Sprintf(".env%d", i) + path := filepath.Join(tmpDir, filename) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + files = append(files, path) + } + } + + store := make(map[string]string) + for k, v := range tt.initialStore { + store[k] = v + } + + err := parseDotEnvFilesInto(store, files) + + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + for k, v := range tt.expected { + if store[k] != v { + t.Errorf("expected %s=%q, got %s=%q", k, v, k, store[k]) + } + } + }) + } +} + +func TestParseEnvInto(t *testing.T) { + tests := []struct { + name string + initialStore map[string]string + envMap map[string]any + osEnvSetup map[string]string // env vars to set before test + expected map[string]string + wantErr bool + }{ + { + name: "when env map is empty, it must pass", + initialStore: map[string]string{}, + envMap: map[string]any{}, + expected: map[string]string{}, + wantErr: false, + }, + { + name: "when value is string, it must store directly", + initialStore: map[string]string{}, + envMap: map[string]any{ + "KEY1": "value1", + "KEY2": "value2", + }, + expected: map[string]string{ + "KEY1": "value1", + "KEY2": "value2", + }, + wantErr: false, + }, + { + name: "when value is numeric or boolean, it must convert to string", + initialStore: map[string]string{}, + envMap: map[string]any{ + "INT_KEY": 42, + "FLOAT_KEY": 3.14, + "BOOL_KEY": true, + }, + expected: map[string]string{ + "INT_KEY": "42", + "FLOAT_KEY": "3.14", + "BOOL_KEY": "true", + }, + wantErr: false, + }, + { + name: "when required is true and env var is missing, it must fail", + initialStore: map[string]string{}, + envMap: map[string]any{ + "REQUIRED_KEY": map[string]any{ + "required": true, + }, + }, + wantErr: true, + }, + { + name: "when required is false, it must pass", + initialStore: map[string]string{}, + envMap: map[string]any{ + "OPTIONAL_KEY": map[string]any{ + "required": false, + }, + }, + expected: map[string]string{}, + wantErr: false, + }, + { + name: "when required is not a boolean, it must fail", + initialStore: map[string]string{}, + envMap: map[string]any{ + "BAD_KEY": map[string]any{ + "required": "yes", + }, + }, + wantErr: true, + }, + { + name: "when sh key is present, it must evaluate and store trimmed output", + initialStore: map[string]string{}, + envMap: map[string]any{ + "SHELL_KEY": map[string]any{ + "sh": "echo hello", + }, + }, + expected: map[string]string{ + "SHELL_KEY": "hello", + }, + wantErr: false, + }, + { + name: "when bash key is present, it must evaluate and store trimmed output", + initialStore: map[string]string{}, + envMap: map[string]any{ + "BASH_KEY": map[string]any{ + "bash": "echo -n world", + }, + }, + expected: map[string]string{ + "BASH_KEY": "world", + }, + wantErr: false, + }, + { + name: "when shell script runs, it must have access to existing env vars", + initialStore: map[string]string{"EXISTING": "from_store"}, + envMap: map[string]any{ + "DERIVED": map[string]any{ + "sh": "echo $EXISTING", + }, + }, + expected: map[string]string{ + "EXISTING": "from_store", + "DERIVED": "from_store", + }, + wantErr: false, + }, + { + name: "when shell script value is not a string, it must fail", + initialStore: map[string]string{}, + envMap: map[string]any{ + "BAD_SHELL": map[string]any{ + "sh": 123, + }, + }, + wantErr: true, + }, + { + name: "when shell script exits with non-zero code, it must fail", + initialStore: map[string]string{}, + envMap: map[string]any{ + "FAIL_KEY": map[string]any{ + "sh": "exit 1", + }, + }, + wantErr: true, + }, + { + name: "when env var exists in store, it must skip evaluation", + initialStore: map[string]string{"EXISTING": "original"}, + envMap: map[string]any{ + "EXISTING": map[string]any{ + "sh": "echo overwritten", + }, + }, + expected: map[string]string{ + "EXISTING": "original", + }, + wantErr: false, + }, + { + name: "when env var exists in store, it must skip even for string values", + initialStore: map[string]string{"EXISTING": "original"}, + envMap: map[string]any{ + "EXISTING": "overwritten", + }, + expected: map[string]string{ + "EXISTING": "original", + }, + wantErr: false, + }, + { + name: "when env var exists in OS environment, it must skip evaluation", + initialStore: map[string]string{}, + osEnvSetup: map[string]string{"TEST_OS_ENV": "from_os"}, + envMap: map[string]any{ + "TEST_OS_ENV": map[string]any{ + "sh": "echo overwritten", + }, + }, + expected: map[string]string{}, + wantErr: false, + }, + { + name: "when required env var exists in OS environment, it must pass", + initialStore: map[string]string{}, + osEnvSetup: map[string]string{"REQUIRED_FROM_OS": "provided"}, + envMap: map[string]any{ + "REQUIRED_FROM_OS": map[string]any{ + "required": true, + }, + }, + expected: map[string]string{}, + wantErr: false, + }, + { + name: "when required env var exists in store, it must pass", + initialStore: map[string]string{"REQUIRED_FROM_STORE": "provided"}, + envMap: map[string]any{ + "REQUIRED_FROM_STORE": map[string]any{ + "required": true, + }, + }, + expected: map[string]string{ + "REQUIRED_FROM_STORE": "provided", + }, + wantErr: false, + }, + { + name: "when shell output has surrounding whitespace, it must trim it", + initialStore: map[string]string{}, + envMap: map[string]any{ + "TRIMMED": map[string]any{ + "sh": "echo ' spaces '", + }, + }, + expected: map[string]string{ + "TRIMMED": "spaces", + }, + wantErr: false, + }, + { + name: "when shell output has internal newlines, it must preserve them", + initialStore: map[string]string{}, + envMap: map[string]any{ + "MULTILINE": map[string]any{ + "sh": "echo -e 'line1\\nline2'", + }, + }, + expected: map[string]string{ + "MULTILINE": "line1\nline2", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup OS env vars + for k, v := range tt.osEnvSetup { + os.Setenv(k, v) + defer os.Unsetenv(k) + } + + store := make(map[string]string) + maps.Copy(store, tt.initialStore) + + err := parseEnvInto(context.Background(), store, tt.envMap) + + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + for k, v := range tt.expected { + if store[k] != v { + t.Errorf("expected %s=%q, got %s=%q", k, v, k, store[k]) + } + } + }) + } +} diff --git a/pkg/runfile/resolver/resolver.go b/pkg/runfile/resolver/resolver.go new file mode 100644 index 0000000..7190a16 --- /dev/null +++ b/pkg/runfile/resolver/resolver.go @@ -0,0 +1,135 @@ +package resolver + +import ( + "context" + "io" + "maps" + "os" + "path/filepath" + + "github.com/nxtcoder17/go.errors" + "github.com/nxtcoder17/runfile/pkg/runfile/spec" + "gopkg.in/yaml.v3" +) + +type extendedTaskSpec struct { + spec.TaskSpec + + IsImported bool + // ImportPrefix needs to be set only if IsImported is true + ImportPrefix string +} + +type Resolver struct { + Env map[string]string + Tasks map[string]extendedTaskSpec +} + +func loadRunfile(file string) (*spec.RunfileSpec, error) { + f, err := os.Open(file) + if err != nil { + return nil, errors.New("failed to open file").Wrap(err).KV("filepath", file) + } + + b, err := io.ReadAll(f) + if err != nil { + return nil, errors.New("failed to read file").Wrap(err).KV("filepath", file) + } + + var rf spec.RunfileSpec + if err := yaml.Unmarshal(b, &rf); err != nil { + return nil, errors.New("failed to unmarshal file content into Runfile Spec").Wrap(err).KV("filepath", file) + } + + return &rf, nil +} + +func Load(ctx context.Context, file string) (*Resolver, error) { + rf, err := loadRunfile(file) + if err != nil { + return nil, err + } + + runfileDir := filepath.Dir(file) + + dotenv := make([]string, 0, len(rf.DotEnv)) + for _, f := range rf.DotEnv { + if !filepath.IsAbs(f) { + f = filepath.Join(runfileDir, f) + } + dotenv = append(dotenv, f) + } + + env := rf.Env + + tasks := make(map[string]extendedTaskSpec, len(rf.Tasks)) + + for k, task := range rf.Tasks { + if task.Dir == "" { + task.Dir = "." + } + + task.Dir = filepath.Join(runfileDir, task.Dir) + for i := range task.DotEnv { + if !filepath.IsAbs(task.DotEnv[i]) { + task.DotEnv[i] = filepath.Join(runfileDir, task.DotEnv[i]) + } + } + tasks[k] = extendedTaskSpec{TaskSpec: task, IsImported: false} + } + + for includeKey, includeSpec := range rf.Includes { + includedFile := includeSpec.Runfile + if !filepath.IsAbs(includedFile) { + includedFile = filepath.Join(runfileDir, includedFile) + } + + includedDir := filepath.Dir(includedFile) + + included, err := loadRunfile(includedFile) + if err != nil { + return nil, err + } + + for _, f := range included.DotEnv { + if !filepath.IsAbs(f) { + f = filepath.Join(includedDir, f) + } + dotenv = append(dotenv, f) + } + + maps.Copy(env, included.Env) + + for tn, task := range included.Tasks { + if task.Dir == "" { + task.Dir = "." + } + + for i := range task.DotEnv { + if !filepath.IsAbs(task.DotEnv[i]) { + task.DotEnv[i] = filepath.Join(includedDir, task.DotEnv[i]) + } + } + + task.Dir = filepath.Join(includedDir, task.Dir) + tasks[includeKey+":"+tn] = extendedTaskSpec{ + TaskSpec: task, + IsImported: true, + ImportPrefix: includeKey, + } + } + } + + envStore := make(map[string]string) + if err := parseDotEnvFilesInto(envStore, dotenv); err != nil { + return nil, err + } + if err := parseEnvInto(ctx, envStore, env); err != nil { + return nil, err + } + + return &Resolver{ + Env: envStore, + Tasks: tasks, + }, nil +} diff --git a/pkg/runfile/resolver/shell.go b/pkg/runfile/resolver/shell.go new file mode 100644 index 0000000..17075c2 --- /dev/null +++ b/pkg/runfile/resolver/shell.go @@ -0,0 +1,62 @@ +package resolver + +import ( + "fmt" + + "github.com/nxtcoder17/go.errors" +) + +var shellAliasMap = map[string][]string{ + "sh": {"sh", "-c"}, + "bash": {"bash", "-c"}, + "zsh": {"zsh", "-c"}, + "python": {"python", "-c"}, + "node": {"node", "-e"}, + "ruby": {"ruby", "-e"}, + "perl": {"perl", "-e"}, + "php": {"php", "-r"}, + "rust": {"cargo", "script", "-e"}, + "clojure": {"clojure", "-e"}, + "lua": {"lua", "-e"}, + "elixir": {"elixir", "-e"}, + "powershell": {"powershell", "-Command"}, + "haskell": {"runghc", "-e"}, +} + +func ParseShell(shell any) ([]string, error) { + if shell == nil { + return shellAliasMap["sh"], nil + } + + switch v := shell.(type) { + case string: + { + if v == "" { + return shellAliasMap["sh"], nil + } + + sh, ok := shellAliasMap[v] + if !ok { + return nil, errors.New(fmt.Sprintf("unsupported shell alias (%s), must specify shell in list format", v)) + } + return sh, nil + } + case []any: + if len(v) == 0 { + return shellAliasMap["sh"], nil + } + + result := make([]string, 0, len(v)) + for i := range v { + str, ok := v[i].(string) + if !ok { + return nil, errors.New("invalid shell specs must consist of only strings") + } + result = append(result, str) + } + + return result, nil + default: + return nil, errors.New("unknown shell format, must in a string or a list").KV("got", fmt.Sprintf("%v (type: %T)", v, v)) + } +} diff --git a/pkg/runfile/resolver/task.go b/pkg/runfile/resolver/task.go new file mode 100644 index 0000000..3a54ce9 --- /dev/null +++ b/pkg/runfile/resolver/task.go @@ -0,0 +1,461 @@ +package resolver + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "strings" + "sync" + + "github.com/alecthomas/chroma/v2/quick" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + "github.com/nxtcoder17/fwatcher/pkg/watcher" + "github.com/nxtcoder17/go.errors" + "github.com/nxtcoder17/runfile/pkg/executor" + fn "github.com/nxtcoder17/runfile/pkg/functions" + "github.com/nxtcoder17/runfile/pkg/runfile/spec" + "github.com/nxtcoder17/runfile/pkg/writer" + "golang.org/x/term" +) + +type TaskContext struct { + context.Context + PWD string + Env map[string]string +} + +type Command struct { + Text string + IsRunTarget bool + Env map[string]any +} + +type ResolvedTask struct { + Name string + + Dir string + Shell []string + Env map[string]any + + Parallel bool + Interactive bool + Silent bool + + Watch *spec.TaskWatchSpec + + Commands []*Command +} + +func (r *Resolver) GetTask(name string) (*ResolvedTask, error) { + task, ok := r.Tasks[name] + if !ok { + return nil, errors.New("Task Not Found").KV("task", name, "all-tasks", fn.MapKeys(r.Tasks)) + } + + shell, err := ParseShell(task.Shell) + if err != nil { + return nil, err + } + + commands, err := parseCommands(task.Commands) + if err != nil { + return nil, err + } + + if task.IsImported { + for i := range commands { + if commands[i].IsRunTarget { + commands[i].Text = task.ImportPrefix + ":" + commands[i].Text + } + } + } + + return &ResolvedTask{ + Name: name, + Dir: task.Dir, + Shell: shell, + Env: task.Env, + Commands: commands, + Parallel: task.Parallel, + Interactive: task.Interactive, + Silent: task.Silent, + Watch: task.Watch, + }, nil +} + +func (r *Resolver) RunTask(ctx context.Context, name string) error { + rt, err := r.GetTask(name) + if err != nil { + return err + } + + lw := &writer.LogWriter{Writer: os.Stderr} + + steps, err := r.createSteps(rt, createCommandGroupArgs{ + Stdout: lw, + Stderr: lw, + }) + if err != nil { + return err + } + + pipeline := executor.NewPipeline(slog.Default(), steps) + + notWatching := rt.Watch == nil || rt.Watch.Enabled == false + + if notWatching { + return pipeline.Start(ctx) + } + + var wg sync.WaitGroup + watch, err := watcher.NewWatcher(ctx, watcher.WatcherArgs{ + WatchDirs: rt.Watch.Dirs, + IgnoreDirs: rt.Watch.IgnoreDirs, + WatchExtensions: rt.Watch.Extensions, + IgnoreExtensions: rt.Watch.IgnoreExtensions, + IgnoreList: watcher.DefaultIgnoreList, + Interactive: rt.Interactive, + ShouldLogWatchEvents: false, + }) + if err != nil { + return err + } + + wg.Add(1) + go func() { + defer wg.Done() + <-ctx.Done() + // ctx.Logger().Info("fwatcher is closing ...") + watch.Close() + }() + + // executors := []executor.Executor{pipeline} + // + // if rt.Watch.SSE != nil && rt.Watch.SSE.Addr != "" { + // executors = append(executors, executor.NewSSEExecutor(executor.SSEExecutorArgs{Addr: rt.Watch.SSE.Addr})) + // } + + wg.Add(1) + go func() { + defer wg.Done() + if err := pipeline.Start(ctx); err != nil { + slog.Error("starting command", "err", err) + } + slog.Debug("final executor start finished") + }() + + wg.Add(1) + go func() { + defer wg.Done() + <-ctx.Done() + pipeline.Stop() + slog.Debug("2. context cancelled") + }() + + wg.Add(1) + go func() { + defer wg.Done() + watch.Watch(ctx) + slog.Debug("3. watcher closed") + }() + + counter := 0 + for ev := range watch.GetEvents() { + slog.Debug("received", "event", ev) + counter += 1 + slog.Info(fmt.Sprintf("[RELOADING (%d)] due changes in %s", counter, ev.Name)) + } + + // if err := watch.WatchAndExecute(ctx, executors); err != nil { + // return err + // } + // + wg.Wait() + return nil +} + +func parseCommands(commands []any) ([]*Command, error) { + result := make([]*Command, 0, len(commands)) + + for _, command := range commands { + switch c := command.(type) { + case string: + { + if c == "" { + continue + } + + result = append(result, &Command{Text: c, IsRunTarget: false, Env: nil}) + } + case map[string]any: + { + var jsonCmd spec.CommandObject + + b, err := json.Marshal(c) + if err != nil { + return nil, errors.New("failed to marshal command").Wrap(err).KV("cmd", c) + } + + if err := json.Unmarshal(b, &jsonCmd); err != nil { + return nil, errors.New("failed to unmarshal command into json command").Wrap(err).KV("b", b) + } + + cmd := &Command{Env: jsonCmd.Env} + + switch { + case jsonCmd.Run != nil: + { + if *jsonCmd.Run == "" { + return nil, errors.New("empty run target") + } + + cmd.Text = *jsonCmd.Run + cmd.IsRunTarget = true + } + case jsonCmd.Command != nil: + { + if *jsonCmd.Command == "" { + return nil, errors.New("empty command") + } + cmd.Text = *jsonCmd.Command + } + default: + { + return nil, errors.New("either 'run' or 'cmd' key, must be specified when setting command in json format") + } + } + result = append(result, cmd) + } + default: + { + return nil, errors.New("invalid command type, must be either a string or an object") + } + } + } + + return result, nil +} + +type CmdArgs struct { + Shell []string + Env []string // [key=value, key=value, ...] + WorkingDir string + + Cmd string + + isInteractive bool + Stdout io.Writer + Stderr io.Writer +} + +func CreateCommand(ctx context.Context, args CmdArgs) *exec.Cmd { + shell := args.Shell[0] + + cargs := append(args.Shell[1:], args.Cmd) + // #nosec G204 - shell is from a predefined map, command is passed via shell's -c flag + c := exec.CommandContext(ctx, shell, cargs...) + c.Dir = args.WorkingDir + c.Env = args.Env + c.Stdout = args.Stdout + c.Stderr = args.Stderr + + if args.isInteractive { + c.Stdin = os.Stdin + } + + return c +} + +var ( + darkThemeOnce sync.Once + darkThemeResult bool +) + +func isDarkTheme() bool { + darkThemeOnce.Do(func() { + darkThemeResult = termenv.NewOutput(os.Stdout).HasDarkBackground() + }) + return darkThemeResult +} + +func printCommand(w *writer.LogWriter, prefix, lang, cmd string) { + borderColor := "#4388cc" + if !isDarkTheme() { + borderColor = "#3d5485" + } + + myBorder := lipgloss.Border{ + Top: "-+", + Bottom: "-+", + Left: "|", + Right: "|", + TopLeft: "+", + TopRight: "+", + BottomLeft: "+", + BottomRight: "+", + } + + s := lipgloss.NewStyle().Border(myBorder).BorderForeground(lipgloss.Color(borderColor)).PaddingLeft(1).PaddingRight(1) + defer s.UnsetBorderStyle() + defer s.UnsetPadding() + + width := 0 + + if term.IsTerminal(0) { + width, _, _ = term.GetSize(0) + } + + hlCode := new(bytes.Buffer) + // choose colorschemes from `https://swapoff.org/chroma/playground/` + colorscheme := "catppuccin-macchiato" + if !isDarkTheme() { + colorscheme = "xcode" + } + + // INFO: 2 for spaces around prefix + longestLen := longestLineLen(cmd) + len(prefix) + 2 + + cmdStr := strings.TrimSpace(cmd) + + quick.Highlight(hlCode, cmdStr, lang, "terminal16m", colorscheme) + + if width > 0 && longestLen >= width-2 { + s = s.Width(width - 2) + } + + // w.Mu.Lock() + // defer w.Mu.Unlock() + fmt.Fprintf(w, "\r\033[K%s%s\n", padString(s.Render(hlCode.String()), prefix), s.UnsetBorderStyle()) +} + +func longestLineLen(str string) int { + sp := strings.Split(str, "\n") + l := len(sp[0]) + for i := 1; i < len(sp); i++ { + if len(sp[i]) > l { + l = len(sp[i]) + } + } + + return l +} + +func padString(str string, withPrefix string) string { + sp := strings.Split(str, "\n") + for i := range sp { + if i == 0 { + sp[i] = fmt.Sprintf("%s %s", writer.GetStyledPrefix(withPrefix), sp[i]) + continue + } + sp[i] = fmt.Sprintf("%s %s", strings.Repeat(" ", len(withPrefix)+2), sp[i]) + } + + return strings.Join(sp, "\n") +} + +type createCommandGroupArgs struct { + Stdout *writer.LogWriter + Stderr *writer.LogWriter + Env map[string]string + Silent bool + TaskTrail []string +} + +func (r *Resolver) createSteps(task *ResolvedTask, args createCommandGroupArgs) ([]executor.Step, error) { + var steps []executor.Step + + taskTrail := make([]string, 0, len(args.TaskTrail)+1) + for i := range args.TaskTrail { + taskTrail = append(taskTrail, args.TaskTrail[i]) + } + taskTrail = append(taskTrail, task.Name) + + slog.Debug("creating command groups", "task.name", task.Name, "task.trail", taskTrail) + + for _, cmd := range task.Commands { + // INFO: env var overrides with args.Env takes priority + envStore := fn.MapMerge(r.Env, args.Env) + + if task.Env != nil { + if err := parseEnvInto(context.TODO(), envStore, task.Env); err != nil { + return nil, err + } + } + + if cmd.Env != nil { + if err := parseEnvInto(context.TODO(), envStore, cmd.Env); err != nil { + return nil, err + } + } + + if cmd.IsRunTarget { + rt, err := r.GetTask(cmd.Text) + if err != nil { + return nil, err + } + + substeps, err := r.createSteps(rt, createCommandGroupArgs{ + Stdout: args.Stdout, + Stderr: args.Stderr, + Env: envStore, + Silent: task.Silent || rt.Silent, + TaskTrail: taskTrail, + }) + if err != nil { + return nil, err + } + + steps = append(steps, executor.Step{ + SubSteps: substeps, + Parallel: rt.Parallel, + }) + continue + } + + logPrefix := strings.Join(taskTrail, " ≫ ") + + step := executor.Step{Parallel: task.Parallel} + + cmdHandler := func(c context.Context) *exec.Cmd { + return CreateCommand(c, CmdArgs{ + Shell: task.Shell, + Env: fn.ToEnviron(envStore), + Cmd: cmd.Text, + WorkingDir: task.Dir, + Stdout: args.Stdout.WithPrefix(logPrefix), + Stderr: args.Stderr.WithPrefix(logPrefix), + }) + } + + preHook := func(c context.Context) error { + if task.Silent || task.Interactive { + return nil + } + str := strings.TrimSpace(cmd.Text) + + lang := "bash" + if len(task.Shell) > 0 { + lang = task.Shell[0] + } + printCommand(args.Stderr, logPrefix, lang, str) + return nil + } + + if task.Interactive { + step.Commands = append(step.Commands, executor.NewInteractiveShellCommand(cmdHandler).AddPreHook(preHook)) + } else { + step.Commands = append(step.Commands, executor.NewShellCommand(cmdHandler).AddPreHook(preHook)) + } + + steps = append(steps, step) + } + + slog.Debug("created command groups", "len", len(steps)) + return steps, nil +} diff --git a/pkg/runfile/resolver/task_test.go b/pkg/runfile/resolver/task_test.go new file mode 100644 index 0000000..d94c3f9 --- /dev/null +++ b/pkg/runfile/resolver/task_test.go @@ -0,0 +1,483 @@ +package resolver + +import ( + "testing" + + "github.com/nxtcoder17/runfile/pkg/runfile/spec" + "github.com/nxtcoder17/runfile/pkg/writer" +) + +func TestParseCommands(t *testing.T) { + tests := []struct { + name string + commands []any + expected []*Command + wantErr bool + }{ + { + name: "when commands list is empty, it must return empty slice", + commands: []any{}, + expected: []*Command{}, + wantErr: false, + }, + { + name: "when command is a string, it must parse correctly", + commands: []any{"echo hello"}, + expected: []*Command{ + {Text: "echo hello", IsRunTarget: false, Env: nil}, + }, + wantErr: false, + }, + { + name: "when command is an empty string, it must be skipped", + commands: []any{"", "echo hello", ""}, + expected: []*Command{ + {Text: "echo hello", IsRunTarget: false, Env: nil}, + }, + wantErr: false, + }, + { + name: "when multiple string commands, it must parse all", + commands: []any{"echo first", "echo second", "echo third"}, + expected: []*Command{ + {Text: "echo first", IsRunTarget: false, Env: nil}, + {Text: "echo second", IsRunTarget: false, Env: nil}, + {Text: "echo third", IsRunTarget: false, Env: nil}, + }, + wantErr: false, + }, + { + name: "when command has run key, it must be marked as run target", + commands: []any{ + map[string]any{"run": "other-task"}, + }, + expected: []*Command{ + {Text: "other-task", IsRunTarget: true, Env: nil}, + }, + wantErr: false, + }, + { + name: "when command has run key with env, it must include env", + commands: []any{ + map[string]any{ + "run": "other-task", + "env": map[string]any{"KEY": "value"}, + }, + }, + expected: []*Command{ + {Text: "other-task", IsRunTarget: true, Env: map[string]any{"KEY": "value"}}, + }, + wantErr: false, + }, + { + name: "when command has cmd key, it must parse as regular command", + commands: []any{ + map[string]any{"cmd": "echo from cmd"}, + }, + expected: []*Command{ + {Text: "echo from cmd", IsRunTarget: false, Env: nil}, + }, + wantErr: false, + }, + { + name: "when run key is empty string, it must fail", + commands: []any{ + map[string]any{"run": ""}, + }, + wantErr: true, + }, + { + name: "when cmd key is empty string, it must fail", + commands: []any{ + map[string]any{"cmd": ""}, + }, + wantErr: true, + }, + { + name: "when map has neither run nor cmd, it must fail", + commands: []any{ + map[string]any{"invalid": "value"}, + }, + wantErr: true, + }, + { + name: "when command is invalid type (int), it must fail", + commands: []any{123}, + wantErr: true, + }, + { + name: "when command is invalid type (bool), it must fail", + commands: []any{true}, + wantErr: true, + }, + { + name: "when mixed string and map commands, it must parse all correctly", + commands: []any{ + "echo first", + map[string]any{"run": "setup"}, + "echo middle", + map[string]any{"cmd": "echo from cmd"}, + "echo last", + }, + expected: []*Command{ + {Text: "echo first", IsRunTarget: false, Env: nil}, + {Text: "setup", IsRunTarget: true, Env: nil}, + {Text: "echo middle", IsRunTarget: false, Env: nil}, + {Text: "echo from cmd", IsRunTarget: false, Env: nil}, + {Text: "echo last", IsRunTarget: false, Env: nil}, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseCommands(tt.commands) + + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if len(result) != len(tt.expected) { + t.Errorf("expected %d commands, got %d", len(tt.expected), len(result)) + return + } + + for i, cmd := range result { + exp := tt.expected[i] + if cmd.Text != exp.Text { + t.Errorf("command[%d]: expected Text=%q, got %q", i, exp.Text, cmd.Text) + } + if cmd.IsRunTarget != exp.IsRunTarget { + t.Errorf("command[%d]: expected IsRunTarget=%v, got %v", i, exp.IsRunTarget, cmd.IsRunTarget) + } + // Check env if expected + if exp.Env != nil { + if cmd.Env == nil { + t.Errorf("command[%d]: expected Env to be set, got nil", i) + } + } + } + }) + } +} + +func TestCreateSteps(t *testing.T) { + tests := []struct { + name string + resolver *Resolver + task *ResolvedTask + args createCommandGroupArgs + expectedSteps int + expectedCmds []int // number of commands per step + checkSubSteps bool + subStepIndices []int // which steps should have substeps + wantErr bool + }{ + { + name: "when task has no commands, it must return empty steps", + resolver: &Resolver{Env: map[string]string{}}, + task: &ResolvedTask{ + Name: "empty", + Shell: []string{"bash", "-c"}, + Commands: []*Command{}, + }, + args: createCommandGroupArgs{ + Stdout: &writer.LogWriter{}, + Stderr: &writer.LogWriter{}, + }, + expectedSteps: 0, + expectedCmds: []int{}, + wantErr: false, + }, + { + name: "when task has single command, it must create one step", + resolver: &Resolver{Env: map[string]string{}}, + task: &ResolvedTask{ + Name: "single", + Shell: []string{"bash", "-c"}, + Commands: []*Command{{Text: "echo hello", IsRunTarget: false}}, + }, + args: createCommandGroupArgs{ + Stdout: &writer.LogWriter{}, + Stderr: &writer.LogWriter{}, + }, + expectedSteps: 1, + expectedCmds: []int{1}, + wantErr: false, + }, + { + name: "when task has multiple commands, it must create multiple steps", + resolver: &Resolver{Env: map[string]string{}}, + task: &ResolvedTask{ + Name: "multi", + Shell: []string{"bash", "-c"}, + Commands: []*Command{ + {Text: "echo first", IsRunTarget: false}, + {Text: "echo second", IsRunTarget: false}, + {Text: "echo third", IsRunTarget: false}, + }, + }, + args: createCommandGroupArgs{ + Stdout: &writer.LogWriter{}, + Stderr: &writer.LogWriter{}, + }, + expectedSteps: 3, + expectedCmds: []int{1, 1, 1}, + wantErr: false, + }, + { + name: "when task has run target, it must create substeps", + resolver: &Resolver{ + Env: map[string]string{}, + Tasks: map[string]extendedTaskSpec{ + "subtask": { + TaskSpec: spec.TaskSpec{ + Shell: "bash", + Commands: []any{"echo from subtask"}, + }, + }, + }, + }, + task: &ResolvedTask{ + Name: "parent", + Shell: []string{"bash", "-c"}, + Commands: []*Command{ + {Text: "subtask", IsRunTarget: true}, + }, + }, + args: createCommandGroupArgs{ + Stdout: &writer.LogWriter{}, + Stderr: &writer.LogWriter{}, + }, + expectedSteps: 1, + checkSubSteps: true, + subStepIndices: []int{0}, + wantErr: false, + }, + { + name: "when run target does not exist, it must fail", + resolver: &Resolver{ + Env: map[string]string{}, + Tasks: map[string]extendedTaskSpec{}, + }, + task: &ResolvedTask{ + Name: "parent", + Shell: []string{"bash", "-c"}, + Commands: []*Command{ + {Text: "nonexistent", IsRunTarget: true}, + }, + }, + args: createCommandGroupArgs{ + Stdout: &writer.LogWriter{}, + Stderr: &writer.LogWriter{}, + }, + wantErr: true, + }, + { + name: "when task is parallel, steps must have Parallel flag set", + resolver: &Resolver{Env: map[string]string{}}, + task: &ResolvedTask{ + Name: "parallel-task", + Shell: []string{"bash", "-c"}, + Parallel: true, + Commands: []*Command{ + {Text: "echo first", IsRunTarget: false}, + {Text: "echo second", IsRunTarget: false}, + }, + }, + args: createCommandGroupArgs{ + Stdout: &writer.LogWriter{}, + Stderr: &writer.LogWriter{}, + }, + expectedSteps: 2, + expectedCmds: []int{1, 1}, + wantErr: false, + }, + { + name: "when task is interactive, it must use interactive shell command", + resolver: &Resolver{Env: map[string]string{}}, + task: &ResolvedTask{ + Name: "interactive-task", + Shell: []string{"bash", "-c"}, + Interactive: true, + Commands: []*Command{{Text: "node", IsRunTarget: false}}, + }, + args: createCommandGroupArgs{ + Stdout: &writer.LogWriter{}, + Stderr: &writer.LogWriter{}, + }, + expectedSteps: 1, + expectedCmds: []int{1}, + wantErr: false, + }, + { + name: "when mixed commands and run targets, it must handle both", + resolver: &Resolver{ + Env: map[string]string{}, + Tasks: map[string]extendedTaskSpec{ + "setup": { + TaskSpec: spec.TaskSpec{ + Shell: "bash", + Commands: []any{"echo setup"}, + }, + }, + }, + }, + task: &ResolvedTask{ + Name: "mixed", + Shell: []string{"bash", "-c"}, + Commands: []*Command{ + {Text: "echo before", IsRunTarget: false}, + {Text: "setup", IsRunTarget: true}, + {Text: "echo after", IsRunTarget: false}, + }, + }, + args: createCommandGroupArgs{ + Stdout: &writer.LogWriter{}, + Stderr: &writer.LogWriter{}, + }, + expectedSteps: 3, + checkSubSteps: true, + subStepIndices: []int{1}, // only step at index 1 should have substeps + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + steps, err := tt.resolver.createSteps(tt.task, tt.args) + + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if len(steps) != tt.expectedSteps { + t.Errorf("expected %d steps, got %d", tt.expectedSteps, len(steps)) + return + } + + // Check command counts per step + for i, expectedCmdCount := range tt.expectedCmds { + if i >= len(steps) { + break + } + if len(steps[i].Commands) != expectedCmdCount { + t.Errorf("step[%d]: expected %d commands, got %d", i, expectedCmdCount, len(steps[i].Commands)) + } + } + + // Check substeps + if tt.checkSubSteps { + subStepSet := make(map[int]bool) + for _, idx := range tt.subStepIndices { + subStepSet[idx] = true + } + + for i, step := range steps { + if subStepSet[i] { + if len(step.SubSteps) == 0 { + t.Errorf("step[%d]: expected substeps, got none", i) + } + } + } + } + + // Check parallel flag + if tt.task.Parallel { + for i, step := range steps { + if !step.Parallel { + t.Errorf("step[%d]: expected Parallel=true, got false", i) + } + } + } + }) + } +} + +func TestCreateStepsWithEnv(t *testing.T) { + tests := []struct { + name string + resolverEnv map[string]string + argsEnv map[string]string + taskEnv map[string]any + cmdEnv map[string]any + wantErr bool + }{ + { + name: "when resolver has env, it must be available", + resolverEnv: map[string]string{"GLOBAL": "from_resolver"}, + wantErr: false, + }, + { + name: "when args has env, it must override resolver env", + resolverEnv: map[string]string{"KEY": "from_resolver"}, + argsEnv: map[string]string{"KEY": "from_args"}, + wantErr: false, + }, + { + name: "when task has env, it must be parsed", + taskEnv: map[string]any{"TASK_KEY": "from_task"}, + wantErr: false, + }, + { + name: "when command has env, it must be parsed", + cmdEnv: map[string]any{"CMD_KEY": "from_cmd"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resolver := &Resolver{ + Env: tt.resolverEnv, + Tasks: map[string]extendedTaskSpec{}, + } + if resolver.Env == nil { + resolver.Env = map[string]string{} + } + + task := &ResolvedTask{ + Name: "test", + Shell: []string{"bash", "-c"}, + Env: tt.taskEnv, + Commands: []*Command{{Text: "echo test", IsRunTarget: false, Env: tt.cmdEnv}}, + } + + args := createCommandGroupArgs{ + Stdout: &writer.LogWriter{}, + Stderr: &writer.LogWriter{}, + Env: tt.argsEnv, + } + + _, err := resolver.createSteps(task, args) + + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} diff --git a/pkg/runfile/run-task.go b/pkg/runfile/run-task.go deleted file mode 100644 index 476833a..0000000 --- a/pkg/runfile/run-task.go +++ /dev/null @@ -1,339 +0,0 @@ -package runfile - -import ( - "bytes" - "context" - "fmt" - "io" - "os" - "os/exec" - "strings" - "sync" - - "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/pkg/errors" - fn "github.com/nxtcoder17/runfile/pkg/functions" - "github.com/nxtcoder17/runfile/pkg/writer" - "golang.org/x/term" -) - -type CreateCommandGroupArgs struct { - Trail []string - - Stdout *writer.LogWriter - Stderr *writer.LogWriter - - Env map[string]string -} - -func isDarkTheme() bool { - return termenv.NewOutput(os.Stdout).HasDarkBackground() -} - -func longestLineLen(str string) int { - sp := strings.Split(str, "\n") - l := len(sp[0]) - for i := 1; i < len(sp); i++ { - if len(sp[i]) > l { - l = len(sp[i]) - } - } - - return l -} - -func padString(str string, padWith string) string { - sp := strings.Split(str, "\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") -} - -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) - // #nosec G204 - shell is from a predefined map, command is passed via shell's -c flag - c := exec.CommandContext(ctx, shell, cargs...) - c.Dir = func() string { - if args.WorkingDir != nil { - return *args.WorkingDir - } - return "" - }() - c.Env = args.Env - c.Stdout = args.Stdout - c.Stderr = args.Stderr - - if args.interactive { - c.Stdin = os.Stdin - } - - return c -} - -func printCommand(w io.Writer, prefix, lang, cmd string) { - if writer.IsANSITerminal() { - borderColor := "#4388cc" - if !isDarkTheme() { - borderColor = "#3d5485" - } - - s := lipgloss.NewStyle().BorderForeground(lipgloss.Color(borderColor)).PaddingLeft(1).PaddingRight(1).Border(lipgloss.RoundedBorder(), true, true, true, true) - - width := 0 - - if term.IsTerminal(0) { - width, _, _ = term.GetSize(0) - } - - hlCode := new(bytes.Buffer) - // choose colorschemes from `https://swapoff.org/chroma/playground/` - colorscheme := "catppuccin-macchiato" - if !isDarkTheme() { - colorscheme = "xcode" - } - _ = colorscheme - - longestLen := longestLineLen(cmd) + len(prefix) + 2 // 2 for spaces around prefix - - cmdStr := strings.TrimSpace(cmd) - - quick.Highlight(hlCode, cmdStr, lang, "terminal16m", colorscheme) - - if width > 0 && longestLen >= width-2 { - s = s.Width(width - 2) - } - fmt.Fprintf(w, "\r%s%s\n", s.Render(padString(hlCode.String(), prefix)), s.UnsetBorderStyle()) - } -} - -func (r *ParsedRunfile) createCommandGroups(ctx *Context, taskName string, args CreateCommandGroupArgs) ([]executor.CommandGroup, error) { - task, ok := r.Tasks[taskName] - if !ok { - return nil, errors.ErrTaskNotFound(taskName) - } - - env, err := r.ParseTaskEnv(ctx, taskName, args.Env) - if err != nil { - return nil, err - } - - var groups []executor.CommandGroup - - for i := range task.Commands { - cmd, err := parseCommand(ctx, task.Commands[i], env) - if err != nil { - return nil, err - } - - ctx.Debug("debugging", "env", env, "task", taskName, "trail", args.Trail) - - switch { - case cmd.Run != nil: - { - - if task.Metadata.Namespace != "" { - *cmd.Run = task.Metadata.Namespace + ":" + *cmd.Run - } - - rt, ok := r.Tasks[*cmd.Run] - if !ok { - return nil, errors.ErrTaskNotFound(*cmd.Run).KV("all-tasks", fn.MapKeys(r.Tasks)) - } - - rtCommands, err := r.createCommandGroups(ctx, rt.Name, CreateCommandGroupArgs{ - Trail: append(args.Trail, rt.Name), - Stdout: args.Stdout, - Stderr: args.Stderr, - Env: cmd.Env, - }) - if err != nil { - return nil, err - } - - cg := executor.CommandGroup{ - Groups: rtCommands, - Parallel: rt.Parallel, - PreExecCommand: func(c *exec.Cmd) { - str := c.String() - sp := strings.SplitN(str, " ", 3) - args.Stderr.WithDimmedPrefix(*cmd.Run).Write([]byte(sp[2])) - }, - } - - groups = append(groups, cg) - } - - case cmd.Command != nil: - { - shell, err := r.ParseTaskShell(taskName) - if err != nil { - return nil, err - } - - task, ok := r.Tasks[taskName] - if !ok { - return nil, errors.ErrTaskNotFound(*cmd.Run).KV("all-tasks", fn.MapKeys(r.Tasks)) - } - - cg := executor.CommandGroup{ - Parallel: task.Parallel, - PreExecCommand: func(cmd *exec.Cmd) { - str := strings.TrimSpace(cmd.String()) - sp := strings.SplitN(str, " ", len(shell)+1) - - lang := "bash" - if len(shell) > 0 { - lang = shell[0] - } - printCommand(args.Stderr, task.Name, lang, sp[2]) - }, - - Commands: []func(c context.Context) *exec.Cmd{ - func(c context.Context) *exec.Cmd { - return CreateCommand(ctx, CmdArgs{ - Shell: shell, - Env: fn.ToEnviron(cmd.Env), - Cmd: *cmd.Command, - WorkingDir: task.Dir, - interactive: task.Interactive, - Stdout: func() io.Writer { - if task.Interactive { - return os.Stdout - } - return args.Stdout.WithPrefix(task.Name) - }(), - Stderr: func() io.Writer { - if task.Interactive { - return os.Stderr - } - return args.Stderr.WithPrefix(task.Name) - }(), - }) - }, - }, - } - - groups = append(groups, cg) - } - } - } - - return groups, nil -} - -func (r *ParsedRunfile) RunTask(ctx *Context, name string) error { - ctx.taskTrail = append(ctx.taskTrail, name) - ctx.Debug("running", "task", name) - - logStdout := &writer.LogWriter{Writer: os.Stdout} - - commandGroups, err := r.createCommandGroups(ctx, name, CreateCommandGroupArgs{ - Trail: []string{}, - Stdout: logStdout, - Stderr: logStdout, - }) - if err != nil { - return err - } - - task, ok := r.Tasks[name] - if !ok { - return errors.ErrTaskNotFound(name) - } - - ex := executor.NewCmdExecutor(ctx, executor.CmdExecutorArgs{ - Logger: ctx.Logger().Slog(), - Interactive: task.Interactive, - Commands: commandGroups, - Parallel: task.Parallel, - }) - - switch task.Watch == nil { - case true: - { - if err := ex.Start(); err != nil { - ctx.Logger().Error("while running command, got", "err", err) - return err - } - ctx.Debug("completed") - } - case false: - { - var wg sync.WaitGroup - if task.Watch != nil && (task.Watch.Enable == nil || *task.Watch.Enable) { - watch, err := watcher.NewWatcher(ctx, watcher.WatcherArgs{ - Logger: ctx.Logger().Slog(), - // WatchDirs: append(t.Watch.Dirs, t.Dir), - WatchDirs: task.Watch.Dirs, - IgnoreDirs: task.Watch.IgnoreDirs, - WatchExtensions: task.Watch.Extensions, - IgnoreExtensions: task.Watch.IgnoreExtensions, - IgnoreList: watcher.DefaultIgnoreList, - Interactive: task.Interactive, - ShouldLogWatchEvents: false, - }) - if err != nil { - return err - } - - wg.Add(1) - go func() { - defer wg.Done() - <-ctx.Done() - ctx.Logger().Info("fwatcher is closing ...") - watch.Close() - }() - - executors := []executor.Executor{ex} - - if task.Watch.SSE != nil && task.Watch.SSE.Addr != "" { - executors = append(executors, executor.NewSSEExecutor(executor.SSEExecutorArgs{Addr: task.Watch.SSE.Addr})) - } - - if err := watch.WatchAndExecute(ctx, executors); err != nil { - return err - } - } - - wg.Wait() - } - } - - return nil -} diff --git a/pkg/runfile/run-task_test.go b/pkg/runfile/run-task_test.go deleted file mode 100644 index 7b78291..0000000 --- a/pkg/runfile/run-task_test.go +++ /dev/null @@ -1,443 +0,0 @@ -package runfile - -import ( - "bytes" - "context" - "io" - "os" - "strings" - "testing" - - fn "github.com/nxtcoder17/runfile/pkg/functions" - "github.com/nxtcoder17/runfile/pkg/writer" -) - -func Test_longestLineLen(t *testing.T) { - tests := []struct { - name string - str string - want int - }{ - { - name: "1. When string is single line, It should return line length", - str: "hello world", - want: 11, - }, - { - name: "2. When first line is longest, It should return first line length", - str: "hello world\nhi\ntest", - want: 11, - }, - { - name: "3. When middle line is longest, It should return middle line length", - str: "hi\nhello world test\nbye", - want: 16, - }, - { - name: "4. When last line is longest, It should return last line length", - str: "hi\nbye\nthis is the longest line", - want: 24, - }, - { - name: "5. When string is empty, It should return 0", - str: "", - want: 0, - }, - { - name: "6. When string has empty lines, It should return longest non-empty line", - str: "test\n\n\nhi", - want: 4, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := longestLineLen(tt.str); got != tt.want { - t.Errorf("longestLineLen() = %v, want %v", got, tt.want) - } - }) - } -} - -func Test_padString(t *testing.T) { - tests := []struct { - name string - str string - padWith string - want string - }{ - { - name: "1. When padding single line, It should add prefix with separator", - str: "hello", - padWith: "PREFIX", - want: "PREFIX | hello", - }, - { - name: "2. When padding multiple lines, It should align all lines", - str: "line1\nline2\nline3", - padWith: "PREFIX", - want: "PREFIX | line1\n | line2\n | line3", - }, - { - name: "3. When pad is empty, It should use space", - str: "test", - padWith: "", - want: " | test", - }, - { - name: "4. When pad is short, It should align properly", - str: "first\nsecond", - padWith: ">>", - want: ">> | first\n | second", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := padString(tt.str, tt.padWith); got != tt.want { - t.Errorf("padString() = %q, want %q", got, tt.want) - } - }) - } -} - -func TestCreateCommand(t *testing.T) { - ctx := context.Background() - - tests := []struct { - name string - args CmdArgs - want struct { - shell string - args []string - hasStdin bool - workingDir string - } - }{ - { - name: "1. When no shell specified, It should use default sh", - args: CmdArgs{ - Cmd: "echo hello", - }, - want: struct { - shell string - args []string - hasStdin bool - workingDir string - }{ - shell: "sh", - args: []string{"-c", "echo hello"}, - hasStdin: false, - }, - }, - { - name: "2. When custom shell specified, It should use custom shell", - args: CmdArgs{ - Shell: []string{"bash", "-c"}, - Cmd: "echo hello", - }, - want: struct { - shell string - args []string - hasStdin bool - workingDir string - }{ - shell: "bash", - args: []string{"-c", "echo hello"}, - hasStdin: false, - }, - }, - { - name: "3. When working directory specified, It should set Dir", - args: CmdArgs{ - Cmd: "pwd", - WorkingDir: stringPtr("/tmp"), - }, - want: struct { - shell string - args []string - hasStdin bool - workingDir string - }{ - shell: "sh", - args: []string{"-c", "pwd"}, - workingDir: "/tmp", - }, - }, - { - name: "4. When interactive mode, It should set stdin", - args: CmdArgs{ - Cmd: "read input", - interactive: true, - }, - want: struct { - shell string - args []string - hasStdin bool - workingDir string - }{ - shell: "sh", - args: []string{"-c", "read input"}, - hasStdin: true, - }, - }, - { - name: "5. When environment vars specified, It should set env", - args: CmdArgs{ - Cmd: "echo $VAR", - Env: []string{"VAR=value"}, - }, - want: struct { - shell string - args []string - hasStdin bool - workingDir string - }{ - shell: "sh", - args: []string{"-c", "echo $VAR"}, - }, - }, - { - name: "6. When custom stdout/stderr provided, It should use them", - args: CmdArgs{ - Cmd: "echo test", - Stdout: &bytes.Buffer{}, - Stderr: &bytes.Buffer{}, - }, - want: struct { - shell string - args []string - hasStdin bool - workingDir string - }{ - shell: "sh", - args: []string{"-c", "echo test"}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cmd := CreateCommand(ctx, tt.args) - - // Check shell (Path will be the executable name) - if !strings.HasSuffix(cmd.Path, tt.want.shell) { - t.Errorf("CreateCommand() shell = %v, want %v", cmd.Path, tt.want.shell) - } - - // Check args - if len(cmd.Args) != len(tt.want.args)+1 { // +1 for the executable name - t.Errorf("CreateCommand() args length = %v, want %v", len(cmd.Args)-1, len(tt.want.args)) - } - - // Check stdin - if tt.want.hasStdin && cmd.Stdin == nil { - t.Error("CreateCommand() expected stdin to be set") - } else if !tt.want.hasStdin && cmd.Stdin != nil { - t.Error("CreateCommand() expected stdin to be nil") - } - - // Check working directory - if cmd.Dir != tt.want.workingDir { - t.Errorf("CreateCommand() Dir = %v, want %v", cmd.Dir, tt.want.workingDir) - } - - // Check env - if tt.args.Env != nil && len(cmd.Env) != len(tt.args.Env) { - t.Errorf("CreateCommand() Env length = %v, want %v", len(cmd.Env), len(tt.args.Env)) - } - }) - } -} - -func TestParsedRunfile_RunTask(t *testing.T) { - // Create a minimal test - tests := []struct { - name string - runfile *ParsedRunfile - task string - wantErr bool - }{ - { - name: "1. When task not found, It should return error", - runfile: &ParsedRunfile{ - Tasks: map[string]Task{}, - }, - task: "nonexistent", - wantErr: true, - }, - { - name: "2. When simple echo task exists, It should execute successfully", - runfile: &ParsedRunfile{ - Tasks: map[string]Task{ - "echo": { - Name: "echo", - Commands: []any{ - "echo test", - }, - }, - }, - }, - task: "echo", - wantErr: false, - }, - { - name: "3. When both Parallel and Watch are true, It should handle gracefully", - runfile: &ParsedRunfile{ - Tasks: map[string]Task{ - "parallel-watch": { - Name: "parallel-watch", - Parallel: true, - Watch: &TaskWatch{ - Enable: fn.Ptr(true), - }, - Commands: []any{ - "echo test", - }, - }, - }, - }, - task: "parallel-watch", - wantErr: false, // Based on the code, it doesn't seem to validate this combination - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctx := NewTestContext() - - // For successful cases, we need to capture output - if !tt.wantErr { - // Override stdout to capture output - old := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - err := tt.runfile.RunTask(ctx, tt.task) - - // Restore stdout - w.Close() - os.Stdout = old - - // Read captured output - var buf bytes.Buffer - io.Copy(&buf, r) - - if (err != nil) != tt.wantErr { - t.Errorf("ParsedRunfile.RunTask() error = %v, wantErr %v", err, tt.wantErr) - } - } else { - // For error cases, just check the error - err := tt.runfile.RunTask(ctx, tt.task) - if (err != nil) != tt.wantErr { - t.Errorf("ParsedRunfile.RunTask() error = %v, wantErr %v", err, tt.wantErr) - } - } - }) - } -} - -func TestParsedRunfile_createCommandGroups(t *testing.T) { - runfile := &ParsedRunfile{ - Tasks: map[string]Task{ - "simple": { - Name: "simple", - Commands: []any{ - "echo hello", - "echo world", - }, - }, - "with-run": { - Name: "with-run", - Commands: []any{ - map[string]any{ - "run": "simple", - }, - }, - }, - "with-cmd": { - Name: "with-cmd", - Commands: []any{ - map[string]any{ - "cmd": "ls -la", - "env": map[string]any{ - "VAR": "value", - }, - }, - }, - }, - "parallel": { - Name: "parallel", - Parallel: true, - Commands: []any{ - "sleep 1", - "sleep 1", - }, - }, - }, - } - - ctx := NewTestContext() - - // Parse env for each task - for name, task := range runfile.Tasks { - env, _ := runfile.ParseTaskEnv(ctx, name, map[string]string{}) - task.ParentEnv = env - runfile.Tasks[name] = task - } - - tests := []struct { - name string - taskName string - wantErr bool - wantLen int - }{ - { - name: "1. When task has simple commands, It should create command groups", - taskName: "simple", - wantErr: false, - wantLen: 2, - }, - { - name: "2. When task has run command, It should create single group", - taskName: "with-run", - wantErr: false, - wantLen: 1, - }, - { - name: "3. When task has cmd with env, It should create group with env", - taskName: "with-cmd", - wantErr: false, - wantLen: 1, - }, - { - name: "4. When task has parallel commands, It should create multiple groups", - taskName: "parallel", - wantErr: false, - wantLen: 2, - }, - { - name: "5. When task doesn't exist, It should return error", - taskName: "nonexistent", - wantErr: true, - wantLen: 0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := runfile.createCommandGroups(ctx, tt.taskName, CreateCommandGroupArgs{ - Trail: []string{}, - Stdout: &writer.LogWriter{Writer: &bytes.Buffer{}}, - Stderr: &writer.LogWriter{Writer: &bytes.Buffer{}}, - }) - - if (err != nil) != tt.wantErr { - t.Errorf("createCommandGroups() error = %v, wantErr %v", err, tt.wantErr) - return - } - - if !tt.wantErr && len(got) != tt.wantLen { - t.Errorf("createCommandGroups() returned %d groups, want %d", len(got), tt.wantLen) - } - }) - } -} diff --git a/pkg/runfile/run.go b/pkg/runfile/run.go index 07f18e7..082a08d 100644 --- a/pkg/runfile/run.go +++ b/pkg/runfile/run.go @@ -1,58 +1,26 @@ package runfile import ( - "github.com/nxtcoder17/runfile/pkg/errors" - "golang.org/x/sync/errgroup" + "context" + "log/slog" + "maps" + + "github.com/nxtcoder17/runfile/pkg/runfile/resolver" ) -type RunOption struct { - ExecuteInParallel bool - Watch bool - Debug bool - KVs map[string]string +type Context struct { + context.Context + RunfilePath string } -func (r *ParsedRunfile) Run(ctx *Context, tasks []string, opt RunOption) error { - for k, v := range opt.KVs { - if r.Env == nil { - r.Env = make(map[string]string) - } - r.Env[k] = v - } - - for _, taskName := range tasks { - if _, ok := r.Tasks[taskName]; !ok { - return errors.ErrTaskNotFound(taskName) - } - } - - if opt.ExecuteInParallel { - ctx.Debug("running in parallel mode", "tasks", tasks) - errg := new(errgroup.Group) - - for _, _tn := range tasks { - name := _tn - errg.Go(func() error { - if err := r.RunTask(NewContext(ctx.Context, ctx.Logger()), name); err != nil { - return err - } - return nil - }) - } - - // Wait for all tasks to finish - if err := errg.Wait(); err != nil { - return err - } - - return nil - } - - for _, tn := range tasks { - if err := r.RunTask(NewContext(ctx.Context, ctx.Logger()), tn); err != nil { - return err - } +func RunTask(ctx context.Context, runfile string, task string, envOverrides map[string]string) error { + slog.Debug("[run-task] START", "task", task) + defer slog.Debug("[run-task] FINISH", "task", task) + r, err := resolver.Load(ctx, runfile) + if err != nil { + return err } - return nil + maps.Copy(r.Env, envOverrides) + return r.RunTask(ctx, task) } diff --git a/pkg/runfile/run_test.go b/pkg/runfile/run_test.go deleted file mode 100644 index 0399e29..0000000 --- a/pkg/runfile/run_test.go +++ /dev/null @@ -1,194 +0,0 @@ -package runfile - -import ( - "testing" - -) - -func TestParsedRunfile_Run(t *testing.T) { - // Create a test runfile with various tasks - runfile := &ParsedRunfile{ - Env: map[string]string{ - "INITIAL": "value", - }, - Tasks: map[string]Task{ - "task1": { - Name: "task1", - Commands: []any{ - "echo task1", - }, - }, - "task2": { - Name: "task2", - Commands: []any{ - "echo task2", - }, - }, - "task3": { - Name: "task3", - Commands: []any{ - "echo task3", - }, - }, - "failing": { - Name: "failing", - Commands: []any{ - "exit 1", - }, - }, - }, - } - - ctx := NewTestContext() - - tests := []struct { - name string - tasks []string - opt RunOption - wantErr bool - check func(t *testing.T, r *ParsedRunfile) - }{ - { - name: "1. When running single task, It should execute successfully", - tasks: []string{"task1"}, - opt: RunOption{}, - wantErr: false, - }, - { - name: "2. When running multiple tasks sequentially, It should execute all tasks", - tasks: []string{"task1", "task2", "task3"}, - opt: RunOption{}, - wantErr: false, - }, - { - name: "3. When running multiple tasks in parallel, It should execute all tasks concurrently", - tasks: []string{"task1", "task2", "task3"}, - opt: RunOption{ - ExecuteInParallel: true, - }, - wantErr: false, - }, - { - name: "4. When task doesn't exist, It should return error", - tasks: []string{"nonexistent"}, - opt: RunOption{}, - wantErr: true, - }, - { - name: "5. When mixing existent and non-existent tasks, It should return error", - tasks: []string{"task1", "nonexistent"}, - opt: RunOption{}, - wantErr: true, - }, - { - name: "6. When KVs are provided, It should override and add environment variables", - tasks: []string{"task1"}, - opt: RunOption{ - KVs: map[string]string{ - "INITIAL": "overridden", - "NEW_VAR": "new_value", - }, - }, - wantErr: false, - check: func(t *testing.T, r *ParsedRunfile) { - if r.Env["INITIAL"] != "overridden" { - t.Errorf("Expected INITIAL to be overridden, got %s", r.Env["INITIAL"]) - } - if r.Env["NEW_VAR"] != "new_value" { - t.Errorf("Expected NEW_VAR to be new_value, got %s", r.Env["NEW_VAR"]) - } - }, - }, - { - name: "7. When env map is nil and KVs are provided, It should create env map", - tasks: []string{"task1"}, - opt: RunOption{ - KVs: map[string]string{ - "TEST": "value", - }, - }, - wantErr: false, - check: func(t *testing.T, r *ParsedRunfile) { - // The function should have created the map and added the value - if r.Env == nil { - t.Error("Expected Env map to be created") - } - if r.Env["TEST"] != "value" { - t.Errorf("Expected TEST to be value, got %s", r.Env["TEST"]) - } - }, - }, - { - name: "8. When task list is empty, It should succeed without running anything", - tasks: []string{}, - opt: RunOption{}, - wantErr: false, - }, - { - name: "9. When parallel mode with empty task list, It should succeed", - tasks: []string{}, - opt: RunOption{ - ExecuteInParallel: true, - }, - wantErr: false, - }, - { - name: "10. When task fails in sequential mode, It should stop execution", - tasks: []string{"task1", "failing", "task2"}, - opt: RunOption{}, - wantErr: true, - }, - { - name: "11. When task fails in parallel mode, It should report error", - tasks: []string{"task1", "failing", "task2"}, - opt: RunOption{ - ExecuteInParallel: true, - }, - wantErr: true, - }, - { - name: "12. When debug option is enabled, It should run with debug output", - tasks: []string{"task1"}, - opt: RunOption{ - Debug: true, - }, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create a copy of the runfile for each test - testRunfile := &ParsedRunfile{ - Env: make(map[string]string), - Tasks: make(map[string]Task), - } - - // Copy env - for k, v := range runfile.Env { - testRunfile.Env[k] = v - } - - // Copy tasks - for k, v := range runfile.Tasks { - testRunfile.Tasks[k] = v - } - - // Special handling for nil env test - if tt.name == "KVs with nil env map" { - testRunfile.Env = nil - } - - err := testRunfile.Run(ctx, tt.tasks, tt.opt) - - if (err != nil) != tt.wantErr { - t.Errorf("ParsedRunfile.Run() error = %v, wantErr %v", err, tt.wantErr) - } - - if tt.check != nil { - tt.check(t, testRunfile) - } - }) - } -} - diff --git a/pkg/runfile/runfile-types.go b/pkg/runfile/runfile-types.go deleted file mode 100644 index 4ef4bed..0000000 --- a/pkg/runfile/runfile-types.go +++ /dev/null @@ -1,3 +0,0 @@ -package runfile - - diff --git a/pkg/runfile/runfile.go b/pkg/runfile/runfile.go deleted file mode 100644 index a2dc255..0000000 --- a/pkg/runfile/runfile.go +++ /dev/null @@ -1,98 +0,0 @@ -package runfile - -import ( - "os" - "path/filepath" - - "github.com/nxtcoder17/runfile/pkg/errors" - fn "github.com/nxtcoder17/runfile/pkg/functions" - "sigs.k8s.io/yaml" -) - -func ParseFromFile(ctx *Context, file string) (*ParsedRunfile, error) { - var rf Runfile - f, err := os.ReadFile(file) - if err != nil { - return nil, errors.ErrReadRunfile(err).KV("file", file) - } - - if err := yaml.Unmarshal(f, &rf); err != nil { - return nil, errors.ErrParseRunfile(err).Msg("failed to unmarshal YAML into Runfile") - } - - rf.Filepath = fn.Must(filepath.Abs(file)) - - env, err := rf.resolveEnv(ctx) - if err != nil { - return nil, err - } - - includedTasks, err := rf.resolveIncludedTasks(ctx) - if err != nil { - return nil, err - } - - tasks := make(map[string]Task, len(rf.Tasks)) - for k, v := range rf.Tasks { - v.Name = k - v.ParentEnv = env - v.Metadata.Namespace = "" - tasks[k] = v - } - - return &ParsedRunfile{ - Env: env, - Tasks: fn.MapMerge(tasks, includedTasks), - }, nil -} - -func (rf *Runfile) resolveEnv(ctx *Context) (map[string]string, error) { - dotEnvFiles := make([]string, 0, len(rf.DotEnv)) - for i := range rf.DotEnv { - de := rf.DotEnv[i] - if !filepath.IsAbs(de) { - de = filepath.Join(filepath.Dir(rf.Filepath), de) - } - dotEnvFiles = append(dotEnvFiles, de) - } - - dotenvVars, err := ParseDotEnvFiles(dotEnvFiles...) - if err != nil { - return nil, err - } - - envVars, err := ParseEnvVars(ctx, rf.Env, dotenvVars) - if err != nil { - return nil, err - } - - return fn.MapMerge(dotenvVars, envVars), nil -} - -func (rf *Runfile) resolveIncludedTasks(ctx *Context) (map[string]Task, error) { - tasks := make(map[string]Task) - - for k, v := range rf.Includes { - r, err := ParseFromFile(ctx, v.Runfile) - if err != nil { - return nil, errors.ErrParseIncludes(err).KV("include", v.Runfile) - } - - for name, target := range r.Tasks { - if v.Dir != "" { - if target.Dir != nil { - target.Dir = fn.Ptr(filepath.Join(v.Dir, *target.Dir)) - } - } - - target.ParentEnv = r.Env - target.Metadata.Namespace = k - - taskName := k + ":" + name - target.Name = taskName - tasks[taskName] = target - } - } - - return tasks, nil -} diff --git a/pkg/runfile/runfile_test.go b/pkg/runfile/runfile_test.go deleted file mode 100644 index 9b5348c..0000000 --- a/pkg/runfile/runfile_test.go +++ /dev/null @@ -1,470 +0,0 @@ -package runfile - -import ( - "os" - "path/filepath" - "reflect" - "testing" -) - -func TestParseFromFile(t *testing.T) { - tmpDir := t.TempDir() - - // Define lib runfile path first - libRunfilePath := filepath.Join(tmpDir, "lib.Runfile") - - // Create test runfile - runfileContent := ` -version: 1.0.0 -env: - GLOBAL_VAR: global_value - COMPUTED: "prefix_${USER}_suffix" - -dotEnv: - - test.env - -tasks: - build: - shell: bash - env: - BUILD_MODE: production - cmd: - - echo "Building..." - - echo "Done" - - test: - shell: sh - cmd: - - go test ./... - -includes: - lib: - runfile: ` + libRunfilePath + ` -` - - runfilePath := filepath.Join(tmpDir, "Runfile") - if err := os.WriteFile(runfilePath, []byte(runfileContent), 0644); err != nil { - t.Fatal(err) - } - - // Create test.env file - testEnvContent := "ENV_VAR=env_value\nANOTHER=test" - testEnvPath := filepath.Join(tmpDir, "test.env") - if err := os.WriteFile(testEnvPath, []byte(testEnvContent), 0644); err != nil { - t.Fatal(err) - } - - // Create lib.Runfile for includes test - libRunfileContent := ` -tasks: - compile: - cmd: - - echo "Compiling library..." - clean: - dir: build - cmd: - - rm -rf * -` - if err := os.WriteFile(libRunfilePath, []byte(libRunfileContent), 0644); err != nil { - t.Fatal(err) - } - - ctx := NewTestContext() - - tests := []struct { - name string - file string - wantErr bool - check func(t *testing.T, pr *ParsedRunfile) - }{ - { - name: "1. When runfile is valid, It should parse successfully", - file: runfilePath, - wantErr: false, - check: func(t *testing.T, pr *ParsedRunfile) { - // Check tasks exist - if _, ok := pr.Tasks["build"]; !ok { - t.Error("Expected 'build' task to exist") - } - if _, ok := pr.Tasks["test"]; !ok { - t.Error("Expected 'test' task to exist") - } - if _, ok := pr.Tasks["lib:compile"]; !ok { - t.Error("Expected 'lib:compile' task to exist from includes") - } - if _, ok := pr.Tasks["lib:clean"]; !ok { - t.Error("Expected 'lib:clean' task to exist from includes") - } - - // Check env vars - if pr.Env["GLOBAL_VAR"] != "global_value" { - t.Errorf("Expected GLOBAL_VAR=global_value, got %s", pr.Env["GLOBAL_VAR"]) - } - if pr.Env["ENV_VAR"] != "env_value" { - t.Errorf("Expected ENV_VAR=env_value from dotenv, got %s", pr.Env["ENV_VAR"]) - } - - // Check task properties - buildTask := pr.Tasks["build"] - if buildTask.Name != "build" { - t.Errorf("Expected task name 'build', got %s", buildTask.Name) - } - if len(buildTask.Commands) != 2 { - t.Errorf("Expected 2 commands in build task, got %d", len(buildTask.Commands)) - } - - // Check included task properties - libCleanTask := pr.Tasks["lib:clean"] - if libCleanTask.Name != "lib:clean" { - t.Errorf("Expected task name 'lib:clean', got %s", libCleanTask.Name) - } - if libCleanTask.Metadata.Namespace != "lib" { - t.Errorf("Expected namespace 'lib', got %s", libCleanTask.Metadata.Namespace) - } - if libCleanTask.Dir == nil || *libCleanTask.Dir != "build" { - t.Error("Expected dir to be 'build'") - } - }, - }, - { - name: "2. When file doesn't exist, It should return error", - file: filepath.Join(tmpDir, "nonexistent.yaml"), - wantErr: true, - }, - { - name: "3. When yaml is invalid, It should return error", - file: func() string { - invalidPath := filepath.Join(tmpDir, "invalid.yaml") - os.WriteFile(invalidPath, []byte("invalid: yaml: content:"), 0644) - return invalidPath - }(), - wantErr: true, - }, - { - name: "4. When include file doesn't exist, It should return error", - file: func() string { - invalidIncludePath := filepath.Join(tmpDir, "invalid-include.yaml") - content := ` -tasks: - test: - cmd: echo test -includes: - bad: - runfile: nonexistent.yaml -` - os.WriteFile(invalidIncludePath, []byte(content), 0644) - return invalidIncludePath - }(), - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := ParseFromFile(ctx, tt.file) - if (err != nil) != tt.wantErr { - t.Errorf("ParseFromFile() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !tt.wantErr && tt.check != nil { - tt.check(t, got) - } - }) - } -} - -func TestRunfile_resolveEnv(t *testing.T) { - tmpDir := t.TempDir() - - // Create test .env files - env1Content := "VAR1=value1\nVAR2=value2" - env1Path := filepath.Join(tmpDir, "env1.env") - if err := os.WriteFile(env1Path, []byte(env1Content), 0644); err != nil { - t.Fatal(err) - } - - env2Content := "VAR2=overridden\nVAR3=value3" - env2Path := filepath.Join(tmpDir, "env2.env") - if err := os.WriteFile(env2Path, []byte(env2Content), 0644); err != nil { - t.Fatal(err) - } - - ctx := NewTestContext() - - tests := []struct { - name string - runfile *Runfile - want map[string]string - wantErr bool - }{ - { - name: "1. When no env or dotenv specified, It should return empty map", - runfile: &Runfile{ - Filepath: filepath.Join(tmpDir, "Runfile"), - }, - want: map[string]string{}, - wantErr: false, - }, - { - name: "2. When only env vars specified, It should parse env vars", - runfile: &Runfile{ - Filepath: filepath.Join(tmpDir, "Runfile"), - Env: EnvExpr{ - "KEY1": "value1", - "KEY2": "value2", - }, - }, - want: map[string]string{ - "KEY1": "value1", - "KEY2": "value2", - }, - wantErr: false, - }, - { - name: "3. When only dotenv files specified, It should load env from files", - runfile: &Runfile{ - Filepath: filepath.Join(tmpDir, "Runfile"), - DotEnv: []string{env1Path}, - }, - want: map[string]string{ - "VAR1": "value1", - "VAR2": "value2", - }, - wantErr: false, - }, - { - name: "4. When dotenv has relative paths, It should resolve from runfile dir", - runfile: &Runfile{ - Filepath: filepath.Join(tmpDir, "Runfile"), - DotEnv: []string{"env1.env", "env2.env"}, - }, - want: map[string]string{ - "VAR1": "value1", - "VAR2": "overridden", - "VAR3": "value3", - }, - wantErr: false, - }, - { - name: "5. When both env and dotenv specified, It should have env override dotenv", - runfile: &Runfile{ - Filepath: filepath.Join(tmpDir, "Runfile"), - DotEnv: []string{env1Path}, - Env: EnvExpr{ - "VAR1": "env_override", - "NEW": "new_value", - }, - }, - want: map[string]string{ - "VAR1": "env_override", - "VAR2": "value2", - "NEW": "new_value", - }, - wantErr: false, - }, - { - name: "6. When dotenv file doesn't exist, It should return error", - runfile: &Runfile{ - Filepath: filepath.Join(tmpDir, "Runfile"), - DotEnv: []string{"nonexistent.env"}, - }, - want: nil, - wantErr: true, - }, - { - name: "7. When dotenv file has malformed content, It should return error", - runfile: &Runfile{ - Filepath: filepath.Join(tmpDir, "Runfile"), - DotEnv: []string{func() string { - malformedPath := filepath.Join(tmpDir, "malformed.env") - os.WriteFile(malformedPath, []byte("INVALID LINE WITHOUT EQUALS\nKEY=VALUE\nANOTHER INVALID LINE"), 0644) - return malformedPath - }()}, - }, - want: nil, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := tt.runfile.resolveEnv(ctx) - if (err != nil) != tt.wantErr { - t.Errorf("Runfile.resolveEnv() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("Runfile.resolveEnv() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestRunfile_resolveIncludedTasks(t *testing.T) { - tmpDir := t.TempDir() - - // Create included runfiles - lib1Content := ` -env: - LIB1_VAR: lib1_value - -tasks: - build: - dir: src - cmd: - - echo "Building lib1" - test: - cmd: - - echo "Testing lib1" -` - lib1Path := filepath.Join(tmpDir, "lib1.yaml") - if err := os.WriteFile(lib1Path, []byte(lib1Content), 0644); err != nil { - t.Fatal(err) - } - - lib2Content := ` -tasks: - deploy: - cmd: - - echo "Deploying lib2" -` - lib2Path := filepath.Join(tmpDir, "lib2.yaml") - if err := os.WriteFile(lib2Path, []byte(lib2Content), 0644); err != nil { - t.Fatal(err) - } - - ctx := NewTestContext() - - tests := []struct { - name string - runfile *Runfile - wantErr bool - check func(t *testing.T, tasks map[string]Task) - }{ - { - name: "1. When no includes specified, It should return empty tasks", - runfile: &Runfile{ - Filepath: filepath.Join(tmpDir, "Runfile"), - }, - wantErr: false, - check: func(t *testing.T, tasks map[string]Task) { - if len(tasks) != 0 { - t.Errorf("Expected no tasks, got %d", len(tasks)) - } - }, - }, - { - name: "2. When single include specified, It should namespace tasks", - runfile: &Runfile{ - Filepath: filepath.Join(tmpDir, "Runfile"), - Includes: map[string]IncludeSpec{ - "lib1": { - Runfile: lib1Path, - }, - }, - }, - wantErr: false, - check: func(t *testing.T, tasks map[string]Task) { - if len(tasks) != 2 { - t.Errorf("Expected 2 tasks, got %d", len(tasks)) - } - - buildTask, ok := tasks["lib1:build"] - if !ok { - t.Error("Expected lib1:build task") - } else { - if buildTask.Name != "lib1:build" { - t.Errorf("Expected task name lib1:build, got %s", buildTask.Name) - } - if buildTask.Metadata.Namespace != "lib1" { - t.Errorf("Expected namespace lib1, got %s", buildTask.Metadata.Namespace) - } - if buildTask.Dir == nil || *buildTask.Dir != "src" { - t.Error("Expected dir to be 'src'") - } - } - - if _, ok := tasks["lib1:test"]; !ok { - t.Error("Expected lib1:test task") - } - }, - }, - { - name: "3. When multiple includes specified, It should namespace all tasks", - runfile: &Runfile{ - Filepath: filepath.Join(tmpDir, "Runfile"), - Includes: map[string]IncludeSpec{ - "lib1": { - Runfile: lib1Path, - }, - "lib2": { - Runfile: lib2Path, - }, - }, - }, - wantErr: false, - check: func(t *testing.T, tasks map[string]Task) { - expectedTasks := []string{"lib1:build", "lib1:test", "lib2:deploy"} - if len(tasks) != len(expectedTasks) { - t.Errorf("Expected %d tasks, got %d", len(expectedTasks), len(tasks)) - } - - for _, taskName := range expectedTasks { - if _, ok := tasks[taskName]; !ok { - t.Errorf("Expected task %s to exist", taskName) - } - } - }, - }, - { - name: "4. When include has dir override, It should adjust task dirs", - runfile: &Runfile{ - Filepath: filepath.Join(tmpDir, "Runfile"), - Includes: map[string]IncludeSpec{ - "lib": { - Runfile: lib1Path, - Dir: "libs/lib1", - }, - }, - }, - wantErr: false, - check: func(t *testing.T, tasks map[string]Task) { - buildTask, ok := tasks["lib:build"] - if !ok { - t.Error("Expected lib:build task") - } else { - if buildTask.Dir == nil { - t.Error("Expected dir to be set") - } else if *buildTask.Dir != filepath.Join("libs/lib1", "src") { - t.Errorf("Expected dir to be 'libs/lib1/src', got %s", *buildTask.Dir) - } - } - }, - }, - { - name: "5. When include file doesn't exist, It should return error", - runfile: &Runfile{ - Filepath: filepath.Join(tmpDir, "Runfile"), - Includes: map[string]IncludeSpec{ - "bad": { - Runfile: filepath.Join(tmpDir, "nonexistent.yaml"), - }, - }, - }, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := tt.runfile.resolveIncludedTasks(ctx) - if (err != nil) != tt.wantErr { - t.Errorf("Runfile.resolveIncludedTasks() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !tt.wantErr && tt.check != nil { - tt.check(t, got) - } - }) - } -} \ No newline at end of file diff --git a/pkg/runfile/spec/spec.go b/pkg/runfile/spec/spec.go new file mode 100644 index 0000000..88d4982 --- /dev/null +++ b/pkg/runfile/spec/spec.go @@ -0,0 +1,68 @@ +package spec + +type RunfileSpec struct { + Includes map[string]IncludeSpec `yaml:"includes,omitempty"` + Env map[string]any `yaml:"env,omitempty"` + // load env vars from [dotenv](https://www.dotenv.org/docs/security/env) files + // DotEnv can be in format `[".secrets/dev-setup.env"]` + DotEnv []string `yaml:"dotenv,omitempty"` + Tasks map[string]TaskSpec `yaml:"tasks"` +} + +type IncludeSpec struct { + Runfile string `yaml:"runfile"` + Dir string `yaml:"dir,omitempty"` +} + +type TaskSpec struct { + // Shell can take multiple forms + // - simple string like `bash`, `zsh` etc. + // - or, an array of strings like `[bash, -c]` + Shell any `yaml:"shell,omitempty"` + + // load env vars from [dotenv](https://www.dotenv.org/docs/security/env) files + // DotEnv can be in format `[".secrets/dev-setup.env"]` + // DotEnv can be in format `[".secrets/dev-setup.env"]` + DotEnv []string `yaml:"dotenv,omitempty"` + + // working directory for the task + Dir string `yaml:"dir,omitempty"` + + Env map[string]any `yaml:"env,omitempty"` + + Watch *TaskWatchSpec `yaml:"watch"` + + // Requires []*Requires `yaml:"requires,omitempty"` + + // Interactive means the programs will be run with `stdin` linked to os.Stdout + Interactive bool `yaml:"interactive,omitempty"` + + // Parallel allows you to run commands + Parallel bool `yaml:"parallel"` + + Silent bool `yaml:"silent,omitempty"` + + // Commands can take multiple forms + // - simple string like `echo hello world` + // - a json object with key + // `run`, signifying other tasks to run + // `if`, condition when to run this server + Commands []any `yaml:"cmd"` +} + +type TaskWatchSpec struct { + Enabled bool `yaml:"enabled,omitempty"` + Dirs []string `yaml:"dirs"` + IgnoreDirs []string `yaml:"ignoreDirs"` + Extensions []string `yaml:"extensions"` + IgnoreExtensions []string `yaml:"ignoreExtensions"` + SSE *struct { + Addr string `yaml:"addr"` + } `yaml:"sse,omitempty"` +} + +type CommandObject struct { + Run *string `json:"run" yaml:"run"` + Command *string `json:"cmd" yaml:"cmd"` + Env map[string]any `json:"env" yaml:"env"` +} diff --git a/pkg/runfile/types.go b/pkg/runfile/types.go deleted file mode 100644 index 0cb7697..0000000 --- a/pkg/runfile/types.go +++ /dev/null @@ -1,144 +0,0 @@ -package runfile - -import ( - "context" - "io" - - "github.com/nxtcoder17/fastlog" -) - -type ( - EnvExpr map[string]any - Env map[string]string -) - -type Context struct { - context.Context - logger *fastlog.Logger - taskTrail []string -} - -func NewContext(ctx context.Context, logger *fastlog.Logger) *Context { - return &Context{ - Context: ctx, - logger: logger, - taskTrail: nil, - } -} - -// NewTestContext creates a new Context suitable for testing -func NewTestContext() *Context { - return NewContext(context.TODO(), fastlog.New(fastlog.Options{Writer: io.Discard})) -} - -// Logger returns the logger instance -func (c *Context) Logger() *fastlog.Logger { - return c.logger -} - -// Debug logs a debug message -func (c *Context) Debug(msg string, args ...any) { - c.logger.Debug(msg, args...) -} - -// Only one of the fields must be set -type Requires struct { - Sh *string `json:"sh,omitempty"` - GoTmpl *string `json:"gotmpl,omitempty"` -} - -type Shell []string - -type TaskMetadata struct { - RunfilePath string `json:"-"` - Description string `json:"description"` -} - -type TaskWatch struct { - Enable *bool `json:"enable,omitempty"` - Dirs []string `json:"dirs"` - IgnoreDirs []string `json:"ignoreDirs"` - Extensions []string `json:"extensions"` - IgnoreExtensions []string `json:"ignoreExtensions"` - SSE *struct { - Addr string `json:"addr"` - } `json:"sse,omitempty"` - // ExcludeDirs []string `json:"excludeDirs"` -} - -type Task struct { - Metadata struct { - RunfilePath *string - Namespace string - } `json:"-"` - - Name string `json:"-"` - - Shell any `json:"shell"` - - // load env vars from [.env](https://www.google.com/search?q=sample+dotenv+files&udm=2) files - DotEnv []string `json:"dotenv"` - - // working directory for the task - Dir *string `json:"dir,omitempty"` - - Env EnvExpr `json:"env,omitempty"` - - ParentEnv map[string]string `json:"-"` - - Watch *TaskWatch `json:"watch"` - - Requires []*Requires `json:"requires,omitempty"` - - Interactive bool `json:"interactive,omitempty"` - - // Parallel allows you to run commands - Parallel bool `json:"parallel"` - - // List of commands to be executed in given shell (default: sh) - // can take multiple forms - // - simple string - // - a json object with key - // `run`, signifying other tasks to run - // `if`, condition when to run this server - Commands []any `json:"cmd"` -} - -type CommandJson struct { - Command *string `json:"cmd"` - Run *string `json:"run"` - - Env Env `json:"env"` - - // If is a go template expression, which must evaluate to true, for task to run - If *string `json:"if,omitempty"` -} - -type ParsedCommandJson struct { - Command *string `json:"cmd"` - Run *string `json:"run"` - Env map[string]string `json:"env"` - - // If is a go template expression, which must evaluate to true, for task to run - If *bool `json:"if"` -} - -type Runfile struct { - Filepath string `json:"-"` - - Version string `json:"version,omitempty"` - Includes map[string]IncludeSpec `json:"includes"` - Env EnvExpr `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 ParsedRunfile struct { - Env map[string]string - Tasks map[string]Task -} diff --git a/pkg/writer/colors.go b/pkg/writer/colors.go index b37a910..00ea97b 100644 --- a/pkg/writer/colors.go +++ b/pkg/writer/colors.go @@ -12,13 +12,11 @@ const ( ) func GetStyledPrefix(prefix string) string { - return fmt.Sprintf("%s[%s]%s ", StyleFgGreen, prefix, StyleReset) - // return fmt.Sprintf("%s%s |%s ", Green, prefix, Reset) + return fmt.Sprintf("%s[%s]%s", StyleFgGreen, prefix, StyleReset) } func GetDimStyledPrefix(prefix string) string { - return fmt.Sprintf("%s[%s]%s ", "\033[3;36m", prefix, StyleReset) - // return fmt.Sprintf("%s%s |%s ", Green, prefix, Reset) + return fmt.Sprintf("%s[%s]%s", "\033[3;36m", prefix, StyleReset) } func GetDimmedText(text []byte) string { @@ -31,6 +29,6 @@ func GetCommandHighlight(text []byte) string { } func GetErrorStyledPrefix(prefix string) string { - return fmt.Sprintf("%s[%s]%s ", StyleFgRed, prefix, StyleReset) + return fmt.Sprintf("%s[%s]%s", StyleFgRed, prefix, StyleReset) // return fmt.Sprintf("%s%s |%s ", Green, prefix, Reset) } diff --git a/pkg/writer/writer.go b/pkg/writer/writer.go index ef0567f..c1f826e 100644 --- a/pkg/writer/writer.go +++ b/pkg/writer/writer.go @@ -29,7 +29,11 @@ func (pw *PrefixedWriter) Write(p []byte) (int, error) { break } - if _, err := pw.w.Write(append(pw.prefix, pw.render(line)...)); err != nil { + if _, err := pw.w.Write(pw.prefix); err != nil { + return n, err + } + + if _, err := pw.w.Write(pw.render(line)); err != nil { return n, err } } @@ -40,22 +44,26 @@ var _ io.Writer = (*PrefixedWriter)(nil) type LogWriter struct { io.Writer - mu sync.Mutex + Mu sync.Mutex wg sync.WaitGroup } // Write implements io.Writer. func (s *LogWriter) Write(p []byte) (n int, err error) { - s.mu.Lock() - defer s.mu.Unlock() + s.Mu.Lock() + defer s.Mu.Unlock() return s.Writer.Write(p) } var _ io.Writer = (*LogWriter)(nil) func (s *LogWriter) WithPrefix(prefix string) io.Writer { - if prefix != "" && IsANSITerminal() { - prefix = GetStyledPrefix(prefix) + if prefix != "" { + if IsANSITerminal() { + prefix = GetStyledPrefix(prefix) + " " + } else { + prefix = "[" + prefix + "] " + } } return &PrefixedWriter{ @@ -67,14 +75,23 @@ func (s *LogWriter) WithPrefix(prefix string) io.Writer { } func (s *LogWriter) WithDimmedPrefix(prefix string) io.Writer { - if prefix != "" && IsANSITerminal() { - prefix = GetDimStyledPrefix(prefix) + var render func([]byte) []byte + if prefix != "" { + if IsANSITerminal() { + prefix = GetDimStyledPrefix(prefix) + " " + render = func(b []byte) []byte { return []byte(GetDimmedText(b)) } + } else { + prefix = "[" + prefix + "] " + render = func(b []byte) []byte { return b } + } + } else { + render = func(b []byte) []byte { return b } } return &PrefixedWriter{ w: s.Writer, prefix: []byte(prefix), buf: bytes.NewBuffer(nil), - render: func(b []byte) []byte { return []byte(GetDimmedText(b)) }, + render: render, } }