diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f68ab26 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,138 @@ +name: Release + +on: + workflow_dispatch: + + push: + tags: + - 'v*' + + branches: + - master + + paths: + - "cmd/run/**" + - ".github/**" + - "pkg/**" + - go.* # go.mod, and go.sum + - flake.* + +permissions: + contents: write + packages: write + +jobs: + build-binary: + strategy: + fail-fast: true + matrix: + os: [ubuntu-latest, macos-14, macos-13] + arch: [amd64, arm64] + include: + - os: ubuntu-latest + platform: linux + + - os: macos-13 + platform: darwin + + - os: macos-14 + platform: darwin + exclude: + - os: macos-14 + arch: amd64 + - os: macos-13 + arch: arm64 + + name: Building run-${{ matrix.platform }}-${{ matrix.arch }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: nxtcoder17/actions/setup-cache-go@v1 + with: + cache_key: "run-${{ matrix.platform }}-${{ matrix.arch }}" + working_directory: . + + - uses: nxtcoder17/actions/generate-image-tag@v1 + + - uses: nxtcoder17/actions/setup-nix-cachix@v1 + with: + flake_lock: "./flake.lock" + nix_develop_arguments: ".#default" + cachix_cache_name: ${{ secrets.CACHIX_CACHE_NAME }} + cachix_auth_token: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - name: Build Binary + shell: bash + env: + CGO_ENABLED: 0 + run: |+ + binary=bin/run-${{ matrix.platform }}-${{ matrix.arch }} + go build -o $binary -ldflags="-s -w" -tags urfave_cli_no_docs cmd/run/main.go + + if [ "${{ matrix.platform }}" = "linux" ]; then + upx $binary + fi + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: run-${{ matrix.platform }}-${{ matrix.arch }} + path: bin/* + + release: + needs: build-binary + runs-on: ubuntu-latest + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: ${{ github.workspace }}/binaries + pattern: "run-*" + + - name: flattening all the executable artifacts + shell: bash + run: |+ + ls -R ${{ github.workspace }}/binaries + mkdir -p ${{ github.workspace }}/upload/binaries + shopt -s globstar + file ./** | grep 'executable,' | awk -F: '{print $1}' | xargs -I {} cp {} ${{ github.workspace }}/upload/binaries + shopt -u globstar + + - uses: nxtcoder17/actions/generate-image-tag@v1 + + - name: running for master branch + if: startsWith(github.ref, 'refs/heads/master') + run: |+ + echo "IMAGE_TAG=nightly" | tee -a $GITHUB_ENV | tee -a $GITHUB_OUTPUT + + - name: ensure github release exists + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: |+ + set +e + gh release list -R ${{ github.repository }} | grep -i $IMAGE_TAG + exit_code=$? + if [ $exit_code -ne 0 ]; then + gh release create $IMAGE_TAG -R ${{ github.repository }} --generate-notes --prerelease --draft=false + fi + + - name: upload to github release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: |+ + extra_args="" + if [ "$IMAGE_TAG" = "nightly" ]; then + extra_args="--clobber" + fi + gh release upload $IMAGE_TAG -R ${{github.repository}} $extra_args ${{github.workspace}}/upload/binaries/* + + - name: mark release as latest + if: startsWith(github.ref, 'refs/tags/') + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: |+ + gh release edit $IMAGE_TAG -R ${{ github.repository }} --latest diff --git a/README.md b/README.md index cffc965..0280901 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,11 @@ Create a `Runfile` in the root of your project, and add tasks to it. - [x] Run tasks with Key-Value environment variables - [x] Run tasks with dynamic environment variables (by shell execution) - [x] Run tasks with dotenv files as their environment variables -- [ ] Running tasks in different working directory [reference](https://taskfile.dev/reference/schema/#task) +- [x] Running tasks in different working directory [reference](https://taskfile.dev/reference/schema/#task) +- [x] Running tasks in parallel - [ ] Running tasks with watch mode -- [ ] Running tasks in parallel +- [x] Requirements prior to running a target +- [x] Environment validations and default value ### Example diff --git a/Runfile b/Runfile index 65116bd..38097d4 100644 --- a/Runfile +++ b/Runfile @@ -11,6 +11,11 @@ tasks: echo "DONE" example: + dir: ./examples cmd: - |+ - run -f ./examples/Runfile cook + run cook clean + + test: + cmd: + - go test -json ./pkg/runfile | gotestfmt diff --git a/cmd/run/main.go b/cmd/run/main.go index bfe2a49..9787a59 100644 --- a/cmd/run/main.go +++ b/cmd/run/main.go @@ -3,19 +3,29 @@ package main import ( "context" "fmt" - "log" "os" "os/signal" "path/filepath" + "strings" "syscall" + "github.com/nxtcoder17/fwatcher/pkg/logging" "github.com/nxtcoder17/runfile/pkg/runfile" "github.com/urfave/cli/v3" ) -var Version string = "0.0.1" +var ( + Version string = "0.0.1" + runfileNames []string = []string{ + "Runfile", + "Runfile.yml", + "Runfile.yaml", + } +) func main() { + logger := logging.NewSlogLogger(logging.SlogOptions{}) + cmd := cli.Command{ Name: "run", Version: Version, @@ -26,6 +36,23 @@ func main() { Aliases: []string{"f"}, Value: "", }, + + &cli.BoolFlag{ + Name: "parallel", + Aliases: []string{"p"}, + Value: false, + }, + + &cli.BoolFlag{ + Name: "watch", + Aliases: []string{"w"}, + Value: false, + }, + + &cli.BoolFlag{ + Name: "debug", + Value: false, + }, }, EnableShellCompletion: true, ShellComplete: func(ctx context.Context, c *cli.Command) { @@ -38,7 +65,7 @@ func main() { panic(err) } - runfile, err := runfile.ParseRunFile(runfilePath) + runfile, err := runfile.Parse(runfilePath) if err != nil { panic(err) } @@ -46,13 +73,26 @@ func main() { for k := range runfile.Tasks { fmt.Fprintf(c.Root().Writer, "%s\n", k) } + + m, err := runfile.ParseIncludes() + if err != nil { + panic(err) + } + + for k, v := range m { + for tn := range v.Runfile.Tasks { + fmt.Fprintf(c.Root().Writer, "%s:%s\n", k, tn) + } + } }, Action: func(ctx context.Context, c *cli.Command) error { - if c.Args().Len() > 1 { - return fmt.Errorf("too many arguments") - } - if c.Args().Len() != 1 { - return fmt.Errorf("missing argument") + parallel := c.Bool("parallel") + watch := c.Bool("watch") + debug := c.Bool("debug") + + if c.NArg() == 0 { + c.Command("help").Run(ctx, nil) + return nil } runfilePath, err := locateRunfile(c) @@ -60,13 +100,57 @@ func main() { return err } - runfile, err := runfile.ParseRunFile(runfilePath) + rf, err := runfile.Parse(runfilePath) if err != nil { panic(err) } - s := c.Args().First() - return runfile.Run(ctx, s) + kv := make(map[string]string) + + // 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 + } + + sp := strings.SplitN(arg, "=", 2) + if len(sp) == 2 { + kv[sp[0]] = sp[1] + continue + } + + args = append(args, arg) + } + + if parallel && watch { + return fmt.Errorf("parallel and watch can't be set together") + } + + logger := logging.NewSlogLogger(logging.SlogOptions{ + ShowCaller: debug, + ShowDebugLogs: debug, + SetAsDefaultLogger: true, + }) + + return rf.Run(runfile.NewContext(ctx, logger), runfile.RunArgs{ + Tasks: args, + ExecuteInParallel: parallel, + Watch: watch, + Debug: debug, + KVs: kv, + }) }, } @@ -82,29 +166,39 @@ func main() { }() if err := cmd.Run(ctx, os.Args); err != nil { - log.Fatal(err) + logger.Error(err.Error()) + os.Exit(1) } } func locateRunfile(c *cli.Command) (string, error) { - var runfilePath string switch { case c.IsSet("file"): - runfilePath = c.String("file") + return c.String("file"), nil default: dir, err := os.Getwd() if err != nil { return "", err } - for { - _, err := os.Stat(filepath.Join(dir, "Runfile")) - if err != nil { - dir = filepath.Dir(dir) - continue + + oldDir := "" + + for oldDir != dir { + for _, fn := range runfileNames { + if _, err := os.Stat(filepath.Join(dir, fn)); err != nil { + if !os.IsNotExist(err) { + return "", err + } + continue + } + + return filepath.Join(dir, fn), nil } - runfilePath = filepath.Join(dir, "Runfile") - break + + oldDir = dir + dir = filepath.Dir(dir) } + + return "", fmt.Errorf("failed to locate your nearest Runfile") } - return runfilePath, nil } diff --git a/docs/includes.md b/docs/includes.md new file mode 100644 index 0000000..32bc944 --- /dev/null +++ b/docs/includes.md @@ -0,0 +1,10 @@ +## Includes one runfile into another + +```yaml +includes: + file1: + runfile: ./run1/Runfile + # dir: ../ + run2: + runfile: ./run2/Runfile +``` diff --git a/docs/requirements-for-a-run-target.md b/docs/requirements-for-a-run-target.md new file mode 100644 index 0000000..d738800 --- /dev/null +++ b/docs/requirements-for-a-run-target.md @@ -0,0 +1,27 @@ +--- +Github Issue: https://github.com/nxtcoder17/Runfile/issues/8 +--- + +### Expectations from this implementation ? + +I would like to be able to do stuffs like: +- whether these environment variables have been defined +- whether this filepath exists or not +- whether `this command` or script runs sucessfully + +And, when answers to these questsions are `true`, then only run the target, otherwise throw the errors + +### Problems with Taskfile.dev implementation + +They have 2 ways to tackle this, with + +- `requires`: but, it works with vars only, ~no environment variables~ + +- `preconditions`: test conditions must be a valid linux `test` command, which assumes everyone knows how to read bash's `test` or `if` statements + + +### My Approach + +1. Support for `test` commands, must be there, for advanced users + +2. But, for simpler use cases, there should be alternate ways to do it, something that whole team just understands. diff --git a/examples/Runfile b/examples/Runfile deleted file mode 100644 index 3130c74..0000000 --- a/examples/Runfile +++ /dev/null @@ -1,47 +0,0 @@ -# vim: set ft=yaml: -version: 0.0.1 - -tasks: - cook: - env: - k1: v1 - k2: 'f"\( asfsadfssdfas asfd $Asdfasdfa' - k3: - sh: echo -n "hello" - dotenv: - - .secrets/env - cmd: - - echo "hi hello" - - echo "value of k1 is '$k1'" - - echo "value of k2 is '$k2'" - - echo "value of k3 is '$k3'" - - echo "value of key_id (from .dotenv) is '$key_id', ${#key_id}" - - clean: - name: clean - shell: ["python", "-c"] - dotenv: - - .secrets/env - cmd: - - |+ - import secrets - import os - print(os.environ['key_id']) - # print(secrets.token_hex(32)) - laundry: - name: laundry - shell: ["node", "-e"] - cmd: - - console.log("laundry") - eat: - name: eat - cmd: - - echo "eat" - sleep: - name: sleep - cmd: - - echo "sleep" - code: - name: code - cmd: - - echo "code" diff --git a/examples/Runfile.yml b/examples/Runfile.yml new file mode 100644 index 0000000..9ce57da --- /dev/null +++ b/examples/Runfile.yml @@ -0,0 +1,76 @@ +# vim: set ft=yaml: +version: 0.0.1 + +includes: + file1: + runfile: ./run1/Runfile + # dir: ../ + run2: + runfile: ./run2/Runfile + +tasks: + cook: + env: + k1: v1 + k2: 'f"\( asfsadfssdfas asfd $Asdfasdfa' + k3: + sh: echo -n "hello" + k4: + required: true + k5: + default: + # value: "this is default value" + # sh: echo this should be the default value + gotmpl: len "asdfadf" + dotenv: + - ../.secrets/env + cmd: + # - sleep 5 + # - echo "hi hello" + # - echo "value of k1 is '$k1'" + # - echo "value of k2 is '$k2'" + # - echo "value of k3 is '$k3'" + # - echo "value of key_id (from .dotenv) is '$key_id', ${#key_id}" + - echo "hello from cook" + - echo "k5 is $k5" + + clean: + name: clean + shell: ["python", "-c"] + 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)) + + laundry: + name: laundry + shell: ["node", "-e"] + cmd: + - run: cook + - console.log("hello from laundry") + eat: + name: eat + env: + item: asdfasfd + requires: + - gotmpl: gt (len "sdfsdfas") 5 + cmd: + - echo "eat" + sleep: + name: sleep + cmd: + - echo "sleep" + code: + name: code + cmd: + - echo "code" diff --git a/examples/run1/Runfile b/examples/run1/Runfile new file mode 100644 index 0000000..1a12795 --- /dev/null +++ b/examples/run1/Runfile @@ -0,0 +1,6 @@ +version: 0.0.1 + +tasks: + echo: + cmd: + - echo "hello from run1" diff --git a/examples/run2/Runfile b/examples/run2/Runfile new file mode 100644 index 0000000..99a461f --- /dev/null +++ b/examples/run2/Runfile @@ -0,0 +1,6 @@ +version: 0.0.1 + +tasks: + echo: + cmd: + - echo "hello from run2" diff --git a/flake.nix b/flake.nix index dad0011..633c673 100644 --- a/flake.nix +++ b/flake.nix @@ -26,6 +26,8 @@ go_1_22 upx + + gotestfmt ]; shellHook = '' diff --git a/go.mod b/go.mod index e4160a9..2deb5de 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,26 @@ module github.com/nxtcoder17/runfile go 1.22.7 require ( + github.com/go-task/slim-sprig/v3 v3.0.0 + github.com/joho/godotenv v1.5.1 + github.com/nxtcoder17/fwatcher v1.0.1 github.com/urfave/cli/v3 v3.0.0-alpha9 + golang.org/x/sync v0.8.0 sigs.k8s.io/yaml v1.4.0 ) -require github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/lipgloss v0.13.0 // indirect + github.com/charmbracelet/log v0.4.0 // indirect + github.com/charmbracelet/x/ansi v0.1.4 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/sys v0.19.0 // indirect +) diff --git a/go.sum b/go.sum index 8c4c5ae..7fb069a 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,49 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= +github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= +github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= +github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= +github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM= +github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= 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/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/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/fwatcher v1.0.1 h1:Rqy+7etcGv9L1KIoK8YGGpAhdXW/pkfkXQwdlJzL1a8= +github.com/nxtcoder17/fwatcher v1.0.1/go.mod h1:MNmSwXYOrqp7U1pUxh0GWB5skpjFTWTQXhAA0+sPJcU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/urfave/cli/v3 v3.0.0-alpha9 h1:P0RMy5fQm1AslQS+XCmy9UknDXctOmG/q/FZkUFnJSo= github.com/urfave/cli/v3 v3.0.0-alpha9/go.mod h1:0kK/RUFHyh+yIKSfWxwheGndfnrvYSmYFVeKCh03ZUc= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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= diff --git a/main.go b/main.go deleted file mode 100644 index 57040c6..0000000 --- a/main.go +++ /dev/null @@ -1,7 +0,0 @@ -package main - -import "fmt" - -func main() { - fmt.Println("RUN FILE") -} diff --git a/pkg/functions/helpers.go b/pkg/functions/helpers.go new file mode 100644 index 0000000..51b6ccb --- /dev/null +++ b/pkg/functions/helpers.go @@ -0,0 +1,22 @@ +package functions + +func DefaultIfNil[T any](v *T, dv T) T { + if v == nil { + return dv + } + return *v +} + +// Must panics if err is not nil +// It is intended to be used very sparingly, and only in cases where the caller is +// certain that the error will never be nil in ideal scenarios +func Must[T any](v T, err error) T { + if err != nil { + panic(err) + } + return v +} + +func New[T any](v T) *T { + return &v +} diff --git a/pkg/functions/maps.go b/pkg/functions/maps.go new file mode 100644 index 0000000..fe3bd12 --- /dev/null +++ b/pkg/functions/maps.go @@ -0,0 +1,19 @@ +package functions + +func MapMerge[K comparable, V any](items ...map[K]V) map[K]V { + result := make(map[K]V) + for i := range items { + for k, v := range items[i] { + result[k] = v + } + } + return result +} + +func MapKeys[K comparable, V any](m map[K]V) []K { + keys := make([]K, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} diff --git a/pkg/runfile/errors/errors.go b/pkg/runfile/errors/errors.go new file mode 100644 index 0000000..a3b8049 --- /dev/null +++ b/pkg/runfile/errors/errors.go @@ -0,0 +1,135 @@ +package errors + +import ( + "encoding/json" + "fmt" +) + +type Context struct { + Verbose bool + + Task string + Runfile string + + message *string + err error +} + +func (c Context) WithMessage(msg string) Context { + c.message = &msg + return c +} + +func (c Context) WithErr(err error) Context { + c.err = err + return c +} + +func (c Context) ToString() string { + if !c.Verbose { + if c.message != nil { + return fmt.Sprintf("[%s] %s, got err: %v", c.Task, *c.message, c.err) + } + return fmt.Sprintf("[%s] got err: %v", c.Task, c.err) + } + + m := map[string]string{ + "task": c.Task, + "runfile": c.Runfile, + } + if c.message != nil { + m["message"] = *c.message + } + + if c.err != nil { + m["err"] = c.err.Error() + } + + b, err := json.Marshal(m) + if err != nil { + panic(err) + } + + return string(b) +} + +func (e Context) Error() string { + return e.ToString() +} + +type ( + ErrTaskInvalid struct{ Context } +) + +type ErrTaskFailedRequirements struct { + Context + Requirement string +} + +func (e ErrTaskFailedRequirements) Error() string { + if e.message == nil { + e.Context = e.Context.WithMessage(fmt.Sprintf("failed (requirement: %q)", e.Requirement)) + } + return e.Context.Error() +} + +type TaskNotFound struct { + Context +} + +func (e TaskNotFound) Error() string { + // return fmt.Sprintf("[task] %s, not found in [Runfile] at %s", e.TaskName, e.RunfilePath) + if e.message == nil { + e.Context = e.Context.WithMessage("Not Found") + } + return e.Context.Error() +} + +type ErrTaskGeneric struct { + Context +} + +type InvalidWorkingDirectory struct { + Context +} + +func (e InvalidWorkingDirectory) Error() string { + // return fmt.Sprintf("[task] %s, not found in [Runfile] at %s", e.TaskName, e.RunfilePath) + if e.message == nil { + e.Context = e.Context.WithMessage("Invalid Working Directory") + } + return e.Context.Error() +} + +type InvalidDotEnv struct { + Context +} + +func (e InvalidDotEnv) Error() string { + if e.message == nil { + e.Context = e.Context.WithMessage("invalid dotenv") + } + return e.Context.Error() +} + +type InvalidEnvVar struct { + Context +} + +func (e InvalidEnvVar) Error() string { + if e.message == nil { + e.Context = e.Context.WithMessage("invalid dotenv") + } + return e.Context.Error() +} + +type IncorrectCommand struct { + Context +} + +func (e IncorrectCommand) Error() string { + if e.message == nil { + e.Context = e.Context.WithMessage("incorrect command") + } + return e.Context.Error() +} diff --git a/pkg/runfile/parser.go b/pkg/runfile/parser.go index 33ae39b..91c2235 100644 --- a/pkg/runfile/parser.go +++ b/pkg/runfile/parser.go @@ -1,55 +1,41 @@ package runfile import ( - "bufio" "fmt" + "io" "os" - "strconv" - "strings" + "path/filepath" - "sigs.k8s.io/yaml" + "github.com/joho/godotenv" ) -func ParseRunFile(file string) (*RunFile, error) { - var runfile RunFile - f, err := os.ReadFile(file) - if err != nil { - return &runfile, err - } - err = yaml.Unmarshal(f, &runfile) - if err != nil { - return &runfile, err - } - return &runfile, nil +func parseDotEnv(reader io.Reader) (map[string]string, error) { + return godotenv.Parse(reader) } // parseDotEnv parses the .env file and returns a slice of strings as in os.Environ() -func parseDotEnv(files ...string) ([]string, error) { - results := make([]string, 0, 5) +func parseDotEnvFiles(files ...string) (map[string]string, error) { + results := make(map[string]string) for i := range files { + if !filepath.IsAbs(files[i]) { + return nil, fmt.Errorf("dotenv file path %s, must be absolute", files[i]) + } + f, err := os.Open(files[i]) if err != nil { return nil, err } + m, err := parseDotEnv(f) + if err != nil { + return nil, err + } + f.Close() - s := bufio.NewScanner(f) - for s.Scan() { - s2 := strings.SplitN(s.Text(), "=", 2) - if len(s2) != 2 { - continue - } - s, _ := strconv.Unquote(string(s2[1])) - - // os.Setenv(s2[0], s2[1]) - os.Setenv(s2[0], s) - results = append(results, s2[0]) + for k, v := range m { + results[k] = v } - } - for i := range results { - v := os.Getenv(results[i]) - results[i] = fmt.Sprintf("%s=%v", results[i], v) } return results, nil diff --git a/pkg/runfile/parser_test.go b/pkg/runfile/parser_test.go new file mode 100644 index 0000000..a33331f --- /dev/null +++ b/pkg/runfile/parser_test.go @@ -0,0 +1,103 @@ +package runfile + +import ( + "bytes" + "io" + "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: "key=", + args: args{ + reader: bytes.NewBuffer([]byte(`key=`)), + }, + want: map[string]string{ + "key": "", + }, + wantErr: false, + }, + { + name: "key=1", + args: args{ + reader: bytes.NewBuffer([]byte(`key=1`)), + }, + want: map[string]string{ + "key": "1", + }, + wantErr: false, + }, + { + name: "key=one", + args: args{ + reader: bytes.NewBuffer([]byte(`key=one`)), + }, + want: map[string]string{ + "key": "one", + }, + wantErr: false, + }, + { + name: "key='one'", + args: args{ + reader: bytes.NewBuffer([]byte(`key='one'`)), + }, + want: map[string]string{ + "key": "one", + }, + wantErr: false, + }, + { + name: `key='o"ne'`, + args: args{ + reader: bytes.NewBuffer([]byte(`key='o"ne'`)), + }, + want: map[string]string{ + "key": `o"ne`, + }, + wantErr: false, + }, + { + name: `key="one"`, + args: args{ + reader: bytes.NewBuffer([]byte(`key="one"`)), + }, + want: map[string]string{ + "key": `one`, + }, + wantErr: false, + }, + { + name: `key=sample==`, + 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) + } + }) + } +} diff --git a/pkg/runfile/run.go b/pkg/runfile/run.go index 21b70ef..f8643ca 100644 --- a/pkg/runfile/run.go +++ b/pkg/runfile/run.go @@ -1,24 +1,28 @@ package runfile import ( - "bytes" - "context" "fmt" "io" + "log/slog" "os" "os/exec" + + "github.com/nxtcoder17/runfile/pkg/runfile/errors" + "golang.org/x/sync/errgroup" ) -type runArgs struct { - shell []string - env []string // [key=value, key=value, ...] - cmd string +type cmdArgs struct { + shell []string + env []string // [key=value, key=value, ...] + workingDir string + + cmd string stdout io.Writer stderr io.Writer } -func runInShell(ctx context.Context, args runArgs) error { +func createCommand(ctx Context, args cmdArgs) *exec.Cmd { if args.shell == nil { args.shell = []string{"sh", "-c"} } @@ -32,75 +36,137 @@ func runInShell(ctx context.Context, args runArgs) error { } shell := args.shell[0] - // f, err := os.CreateTemp(os.TempDir(), "runfile-") - // if err != nil { - // return err - // } - // f.WriteString(args.cmd) - // f.Close() - - // cargs := append(args.shell[1:], f.Name()) + cargs := append(args.shell[1:], args.cmd) c := exec.CommandContext(ctx, shell, cargs...) - c.Env = args.env + c.Dir = args.workingDir + c.Env = append(os.Environ(), args.env...) c.Stdout = args.stdout c.Stderr = args.stderr - return c.Run() + return c +} + +type runTaskArgs struct { + taskName string + envOverrides map[string]string } -func (r *RunFile) Run(ctx context.Context, taskName string) error { - task, ok := r.Tasks[taskName] +func (rf *Runfile) runTask(ctx Context, args runTaskArgs) error { + logger := ctx.Logger.With("runfile", rf.attrs.RunfilePath, "task", args.taskName, "env:overrides", args.envOverrides) + logger.Debug("running task") + task, ok := rf.Tasks[args.taskName] if !ok { - return fmt.Errorf("task %s not found", taskName) + return errors.TaskNotFound{Context: errors.Context{Runfile: rf.attrs.RunfilePath, Task: args.taskName}} } - env := make([]string, len(task.Env)) - for k, v := range task.Env { - switch v := v.(type) { - case string: - env = append(env, fmt.Sprintf("%s=%s", k, v)) - case map[string]any: - shcmd, ok := v["sh"] - if !ok { - return fmt.Errorf("env %s is not a string", k) - } - - s, ok := shcmd.(string) - if !ok { - return fmt.Errorf("shell cmd is not a string") - } + task.Name = args.taskName + if task.Env == nil { + task.Env = make(EnvVar) + } + for k, v := range args.envOverrides { + task.Env[k] = v + } + pt, err := ParseTask(ctx, rf, &task) + if err != nil { + return err + } - value := new(bytes.Buffer) + // envVars := append(pt.Environ, args.envOverrides...) + ctx.Debug("debugging env", "pt.environ", pt.Env, "overrides", args.envOverrides, "task", args.taskName) - if err := runInShell(ctx, runArgs{ - shell: task.Shell, - env: os.Environ(), - cmd: s, - stdout: value, + for _, command := range pt.Commands { + if command.Run != "" { + if err := rf.runTask(ctx, runTaskArgs{ + taskName: command.Run, + envOverrides: pt.Env, + // envOverrides: append(pt.Environ, args.envOverrides...), }); err != nil { return err } - env = append(env, fmt.Sprintf("%s=%v", k, value.String())) - default: - panic(fmt.Sprintf("env %s is not a string (%T)", k, v)) + continue + } + + cmd := createCommand(ctx, cmdArgs{ + shell: pt.Shell, + env: ToEnviron(pt.Env), + cmd: command.Command, + workingDir: pt.WorkingDir, + }) + if err := cmd.Run(); err != nil { + return err } } - // parsing dotenv - s, err := parseDotEnv(task.DotEnv...) + return nil +} + +type RunArgs struct { + Tasks []string + ExecuteInParallel bool + Watch bool + Debug bool + KVs map[string]string +} + +func (rf *Runfile) Run(ctx Context, args RunArgs) error { + ctx.Debug("run", "tasks", args.Tasks) + includes, err := rf.ParseIncludes() if err != nil { return err } - // INFO: keys from task.Env will override those coming from dotenv files, when duplicated - env = append(s, env...) + for _, taskName := range args.Tasks { + for k, v := range includes { + for tn := range v.Runfile.Tasks { + if taskName == fmt.Sprintf("%s:%s", k, tn) { + return v.Runfile.runTask(ctx, runTaskArgs{taskName: tn}) + } + } + } - for _, cmd := range task.Commands { - runInShell(ctx, runArgs{ - shell: task.Shell, - env: append(os.Environ(), env...), - cmd: cmd, - }) + task, ok := rf.Tasks[taskName] + if !ok { + return errors.TaskNotFound{Context: errors.Context{ + Task: taskName, + Runfile: rf.attrs.RunfilePath, + }} + } + + // INFO: adding parsed KVs as environments to the specified tasks + for k, v := range args.KVs { + if task.Env == nil { + task.Env = EnvVar{} + } + task.Env[k] = v + } + + rf.Tasks[taskName] = task + } + + if args.ExecuteInParallel { + slog.Default().Debug("running in parallel mode", "tasks", args.Tasks) + g := new(errgroup.Group) + + for _, _tn := range args.Tasks { + tn := _tn + g.Go(func() error { + return rf.runTask(ctx, runTaskArgs{taskName: tn}) + }) + } + + // Wait for all tasks to finish + if err := g.Wait(); err != nil { + return err + } + + return nil } + + for _, tn := range args.Tasks { + if err := rf.runTask(ctx, runTaskArgs{taskName: tn}); err != nil { + return err + } + } + return nil } diff --git a/pkg/runfile/run_test.go b/pkg/runfile/run_test.go new file mode 100644 index 0000000..a5515d8 --- /dev/null +++ b/pkg/runfile/run_test.go @@ -0,0 +1 @@ +package runfile diff --git a/pkg/runfile/runfile.go b/pkg/runfile/runfile.go new file mode 100644 index 0000000..7e0facb --- /dev/null +++ b/pkg/runfile/runfile.go @@ -0,0 +1,66 @@ +package runfile + +import ( + "os" + "path/filepath" + + fn "github.com/nxtcoder17/runfile/pkg/functions" + "sigs.k8s.io/yaml" +) + +type attrs struct { + RunfilePath string +} + +type Runfile struct { + attrs attrs + + Version string `json:"version,omitempty"` + Includes map[string]IncludeSpec `json:"includes"` + Tasks map[string]Task `json:"tasks"` +} + +type IncludeSpec struct { + Runfile string `json:"runfile"` + Dir string `json:"dir,omitempty"` +} + +type ParsedIncludeSpec struct { + Runfile *Runfile +} + +func Parse(file string) (*Runfile, error) { + var runfile Runfile + f, err := os.ReadFile(file) + if err != nil { + return &runfile, err + } + if err = yaml.Unmarshal(f, &runfile); err != nil { + return nil, err + } + + runfile.attrs.RunfilePath = fn.Must(filepath.Abs(file)) + return &runfile, nil +} + +func (rf *Runfile) ParseIncludes() (map[string]ParsedIncludeSpec, error) { + m := make(map[string]ParsedIncludeSpec, len(rf.Includes)) + for k, v := range rf.Includes { + r, err := Parse(v.Runfile) + if err != nil { + return nil, err + } + + for it := range r.Tasks { + if v.Dir != "" { + nt := r.Tasks[it] + nt.Dir = &v.Dir + r.Tasks[it] = nt + } + } + + m[k] = ParsedIncludeSpec{Runfile: r} + } + + return m, nil +} diff --git a/pkg/runfile/task-parser.go b/pkg/runfile/task-parser.go new file mode 100644 index 0000000..4aeb826 --- /dev/null +++ b/pkg/runfile/task-parser.go @@ -0,0 +1,198 @@ +package runfile + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "text/template" + + sprig "github.com/go-task/slim-sprig/v3" + fn "github.com/nxtcoder17/runfile/pkg/functions" + "github.com/nxtcoder17/runfile/pkg/runfile/errors" +) + +type ParsedTask struct { + Shell []string `json:"shell"` + WorkingDir string `json:"workingDir"` + Env map[string]string `json:"environ"` + Commands []CommandJson `json:"commands"` +} + +// func ParseTask(ctx Context, rf *Runfile, taskName string) (*ParsedTask, error) { +func ParseTask(ctx Context, rf *Runfile, task *Task) (*ParsedTask, error) { + if task == nil { + return nil, fmt.Errorf("task does not exist") + } + + errctx := errors.Context{Task: task.Name, Runfile: rf.attrs.RunfilePath} + + for _, requirement := range task.Requires { + if requirement == nil { + continue + } + + if requirement.Sh != nil { + cmd := createCommand(ctx, cmdArgs{ + shell: []string{"sh", "-c"}, + env: nil, + workingDir: filepath.Dir(rf.attrs.RunfilePath), + cmd: *requirement.Sh, + stdout: fn.Must(os.OpenFile(os.DevNull, os.O_WRONLY, 0o755)), + stderr: fn.Must(os.OpenFile(os.DevNull, os.O_WRONLY, 0o755)), + }) + if err := cmd.Run(); err != nil { + return nil, errors.ErrTaskFailedRequirements{Context: errctx.WithErr(err), Requirement: *requirement.Sh} + } + continue + } + + if requirement.GoTmpl != nil { + t := template.New("requirement") + t = t.Funcs(sprig.FuncMap()) + templateExpr := fmt.Sprintf(`{{ %s }}`, *requirement.GoTmpl) + t, err := t.Parse(templateExpr) + if err != nil { + return nil, err + } + b := new(bytes.Buffer) + if err := t.ExecuteTemplate(b, "requirement", map[string]string{}); err != nil { + return nil, err + } + + if b.String() != "true" { + return nil, errors.ErrTaskFailedRequirements{Context: errctx.WithErr(fmt.Errorf("`%s` evaluated to `%s` (wanted: `true`)", templateExpr, b.String())), Requirement: *requirement.GoTmpl} + } + + continue + } + } + + if task.Shell == nil { + task.Shell = []string{"sh", "-c"} + } + + if task.Dir == nil { + task.Dir = fn.New(fn.Must(os.Getwd())) + } + + fi, err := os.Stat(*task.Dir) + if err != nil { + return nil, errors.InvalidWorkingDirectory{Context: errctx.WithErr(err)} + } + + if !fi.IsDir() { + return nil, errors.InvalidWorkingDirectory{Context: errctx.WithErr(fmt.Errorf("path (%s), is not a directory", *task.Dir))} + } + + dotenvPaths, err := resolveDotEnvFiles(filepath.Dir(rf.attrs.RunfilePath), task.DotEnv...) + if err != nil { + return nil, errors.InvalidDotEnv{Context: errctx.WithErr(err).WithMessage("failed to resolve dotenv paths")} + } + + dotenvVars, err := parseDotEnvFiles(dotenvPaths...) + if err != nil { + return nil, errors.InvalidDotEnv{Context: errctx.WithErr(err).WithMessage("failed to parse dotenv files")} + } + + // env := make([]string, 0, len(os.Environ())+len(dotenvVars)) + // env := make([]string, 0, len(dotenvVars)+len(task.Env)) + // env = append(env, task.Environ...) + // if !task.ignoreSystemEnv { + // env = append(env, os.Environ()...) + // } + // for k, v := range dotenvVars { + // env = append(env, fmt.Sprintf("%s=%v", k, v)) + // } + + // INFO: keys from task.Env will override those coming from dotenv files, when duplicated + envVars, err := parseEnvVars(ctx, task.Env, EvaluationArgs{ + Shell: task.Shell, + Env: dotenvVars, + }) + if err != nil { + return nil, errors.InvalidEnvVar{Context: errctx.WithErr(err).WithMessage("failed to parse/evaluate env vars")} + } + + // for k, v := range envVars { + // env = append(env, fmt.Sprintf("%s=%v", k, v)) + // } + + commands := make([]CommandJson, 0, len(task.Commands)) + for i := range task.Commands { + c2, err := parseCommand(rf, task.Commands[i]) + if err != nil { + return nil, errors.IncorrectCommand{Context: errctx.WithErr(err).WithMessage(fmt.Sprintf("failed to parse command: %+v", task.Commands[i]))} + } + commands = append(commands, *c2) + } + + return &ParsedTask{ + Shell: task.Shell, + WorkingDir: *task.Dir, + Env: fn.MapMerge(dotenvVars, envVars), + Commands: commands, + }, nil +} + +// returns absolute paths to dotenv files +func resolveDotEnvFiles(pwd string, dotEnvFiles ...string) ([]string, error) { + paths := make([]string, 0, len(dotEnvFiles)) + + for _, v := range dotEnvFiles { + dotenvPath := v + if !filepath.IsAbs(v) { + dotenvPath = filepath.Join(pwd, v) + } + fi, err := os.Stat(dotenvPath) + if err != nil { + return nil, err + } + + if fi.IsDir() { + return nil, fmt.Errorf("dotenv file must be a file, but %s is a directory", v) + } + + paths = append(paths, dotenvPath) + } + + return paths, nil +} + +func parseCommand(rf *Runfile, command any) (*CommandJson, error) { + switch c := command.(type) { + case string: + { + return &CommandJson{ + Command: c, + }, nil + } + case map[string]any: + { + var cj CommandJson + b, err := json.Marshal(c) + if err != nil { + return nil, err + } + + if err := json.Unmarshal(b, &cj); err != nil { + return nil, err + } + + if cj.Run == "" { + return nil, fmt.Errorf("key: 'run', must be specified when setting command in json format") + } + + if _, ok := rf.Tasks[cj.Run]; !ok { + return nil, fmt.Errorf("[run target]: %s, not found in Runfile (%s)", cj.Run, rf.attrs.RunfilePath) + } + + return &cj, nil + } + default: + { + return nil, fmt.Errorf("invalid command") + } + } +} diff --git a/pkg/runfile/task-parser_test.go b/pkg/runfile/task-parser_test.go new file mode 100644 index 0000000..9872047 --- /dev/null +++ b/pkg/runfile/task-parser_test.go @@ -0,0 +1,671 @@ +package runfile + +import ( + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + fn "github.com/nxtcoder17/runfile/pkg/functions" +) + +func TestParseTask(t *testing.T) { + type args struct { + ctx context.Context + rf *Runfile + taskName string + } + + areEqual := func(t *testing.T, got, want *ParsedTask) bool { + if want == nil { + return false + } + + if strings.Join(got.Shell, ",") != strings.Join(want.Shell, ",") { + t.Logf("shell not equal") + return false + } + + if len(got.Env) != len(want.Env) { + t.Logf("environments not equal") + return false + } + + gkeys := fn.MapKeys(got.Env) + + for _, k := range gkeys { + v, ok := want.Env[k] + if !ok || v != got.Env[k] { + t.Logf("environments not equal") + return false + } + } + + if got.WorkingDir != want.WorkingDir { + t.Logf("working dir not equal") + return false + } + + if fmt.Sprintf("%#v", got.Commands) != fmt.Sprintf("%#v", want.Commands) { + t.Logf("commands not equal:\n got:\t%#v\nwant:\t%#v", got.Commands, want.Commands) + return false + } + + return true + } + + // for dotenv test + dotenvTestFile, err := os.CreateTemp(os.TempDir(), ".env") + if err != nil { + t.Error(err) + return + } + fmt.Fprintf(dotenvTestFile, "hello=world\n") + dotenvTestFile.Close() + + type test struct { + name string + args args + want *ParsedTask + wantErr bool + } + + testRequires := []test{ + { + name: "[requires] condition specified, but it neither has 'sh' or 'gotmpl' key", + args: args{ + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + Shell: nil, + ignoreSystemEnv: true, + Requires: []*Requires{ + {}, + }, + Commands: nil, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + WorkingDir: fn.Must(os.Getwd()), + Commands: []CommandJson{}, + }, + wantErr: false, + }, + + { + name: "[requires] condition specified, with gotmpl key", + args: args{ + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + Shell: nil, + ignoreSystemEnv: true, + Requires: []*Requires{ + { + GoTmpl: fn.New(`eq 5 5`), + }, + }, + Commands: nil, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + WorkingDir: fn.Must(os.Getwd()), + Commands: []CommandJson{}, + }, + wantErr: false, + }, + + { + name: "[requires] condition specified, with sh key", + args: args{ + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + Shell: nil, + ignoreSystemEnv: true, + Requires: []*Requires{ + { + Sh: fn.New(`echo hello`), + }, + }, + Commands: nil, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + WorkingDir: fn.Must(os.Getwd()), + Commands: []CommandJson{}, + }, + wantErr: false, + }, + + { + name: "[unhappy/requires] condition specified, with sh key", + args: args{ + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + Shell: nil, + ignoreSystemEnv: true, + Requires: []*Requires{ + { + Sh: fn.New(`echo hello && exit 1`), + }, + }, + Commands: nil, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + WorkingDir: fn.Must(os.Getwd()), + Commands: []CommandJson{}, + }, + wantErr: true, + }, + } + + testEnviroments := []test{ + { + name: "[unhappy/env] required env, not provided", + args: args{ + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + ignoreSystemEnv: true, + Env: EnvVar{ + "hello": map[string]any{ + "required": true, + }, + }, + Commands: nil, + }, + }, + }, + taskName: "test", + }, + want: nil, + wantErr: true, + }, + { + name: "[env] required env, provided", + args: args{ + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + ignoreSystemEnv: true, + Env: EnvVar{ + "hello": map[string]any{ + "required": true, + }, + }, + DotEnv: []string{ + dotenvTestFile.Name(), + }, + Commands: nil, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + WorkingDir: fn.Must(os.Getwd()), + Env: map[string]string{ + "hello": "world", + }, + Commands: []CommandJson{}, + }, + wantErr: false, + }, + } + + tests := []test{ + { + name: "[shell] if not specified, defaults to [sh, -c]", + args: args{ + ctx: nil, + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + Shell: nil, + ignoreSystemEnv: true, + Dir: fn.New("."), + Commands: nil, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + WorkingDir: ".", + Commands: []CommandJson{}, + }, + wantErr: false, + }, + { + name: "[shell] if specified, must be acknowledged", + args: args{ + ctx: nil, + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + Shell: []string{"python", "-c"}, + ignoreSystemEnv: true, + Dir: fn.New("."), + Commands: nil, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"python", "-c"}, + WorkingDir: ".", + Commands: []CommandJson{}, + }, + wantErr: false, + }, + { + name: "[env] key: value", + args: args{ + ctx: nil, + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + Shell: []string{"sh", "-c"}, + ignoreSystemEnv: true, + Env: map[string]any{ + "hello": "hi", + "k1": 1, + }, + Dir: fn.New("."), + Commands: nil, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + Env: map[string]string{ + "hello": "hi", + "k1": "1", + }, + WorkingDir: ".", + Commands: []CommandJson{}, + }, + wantErr: false, + }, + { + name: "[env] key: JSON object format", + args: args{ + ctx: nil, + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + ignoreSystemEnv: true, + Env: map[string]any{ + "hello": map[string]any{ + "sh": "echo hi", + }, + }, + Dir: fn.New("."), + Commands: nil, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + Env: map[string]string{ + "hello": "hi", + }, + WorkingDir: ".", + Commands: []CommandJson{}, + }, + wantErr: false, + }, + { + name: "[unhappy/env] JSON object format [must throw err, when] sh key does not exist in value", + args: args{ + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + ignoreSystemEnv: true, + Env: map[string]any{ + "k1": map[string]any{ + "asdfasf": "asdfad", + }, + }, + }, + }, + }, + taskName: "test", + }, + wantErr: true, + }, + { + name: "[unhappy/env] JSON object format [must throw err, when] sh (key)'s value is not a string", + args: args{ + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + ignoreSystemEnv: true, + Env: map[string]any{ + "k1": map[string]any{ + "sh": []string{"asdfsadf"}, + }, + }, + }, + }, + }, + taskName: "test", + }, + wantErr: true, + }, + { + name: "[dotenv] loads environment from given file", + args: args{ + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + ignoreSystemEnv: true, + DotEnv: []string{ + dotenvTestFile.Name(), + }, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + WorkingDir: fn.Must(os.Getwd()), + Env: map[string]string{ + "hello": "world", + }, + Commands: []CommandJson{}, + }, + wantErr: false, + }, + { + name: "[unhappy/dotenv] throws err, when file does not exist", + args: args{ + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + ignoreSystemEnv: true, + DotEnv: []string{ + "/tmp/env-aasfksadjfkl", + }, + }, + }, + }, + taskName: "test", + }, + wantErr: true, + }, + { + name: "[unhappy/dotenv] throws err, when filepath exists [but] is not a file (might be a directory or something else)", + args: args{ + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + ignoreSystemEnv: true, + DotEnv: []string{ + "/tmp", + }, + }, + }, + }, + taskName: "test", + }, + wantErr: true, + }, + { + name: "[working_dir] if not specified, should be current working directory", + args: args{ + ctx: nil, + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + ignoreSystemEnv: true, + Commands: nil, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + WorkingDir: fn.Must(os.Getwd()), + Commands: []CommandJson{}, + }, + wantErr: false, + }, + { + name: "[working_dir] when specified, must be acknowledged", + args: args{ + ctx: nil, + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + ignoreSystemEnv: true, + Dir: fn.New("/tmp"), + Commands: nil, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + WorkingDir: "/tmp", + Commands: []CommandJson{}, + }, + wantErr: false, + }, + { + name: "[unhappy/working_dir] must throw err, when directory does not exist", + args: args{ + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + ignoreSystemEnv: true, + Dir: fn.New("/tmp/xsdfjasdfkjdskfjasl"), + }, + }, + }, + taskName: "test", + }, + wantErr: true, + }, + { + name: "[unhappy/working_dir] must throw err, when directory specified is not a directory (might be something else, or a file)", + args: args{ + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + ignoreSystemEnv: true, + Dir: fn.New(filepath.Join(fn.Must(os.Getwd()), "task.go")), + }, + }, + }, + taskName: "test", + }, + wantErr: true, + }, + { + name: "[commands] string commands: single line", + args: args{ + ctx: nil, + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + ignoreSystemEnv: true, + Commands: []any{ + "echo hello", + }, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + WorkingDir: fn.Must(os.Getwd()), + Commands: []CommandJson{ + {Command: "echo hello"}, + }, + }, + wantErr: false, + }, + + { + name: "[commands] string commands: multiline", + args: args{ + ctx: nil, + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + ignoreSystemEnv: true, + Commands: []any{ + ` +echo "hello" +echo "hi" +`, + }, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + WorkingDir: fn.Must(os.Getwd()), + Commands: []CommandJson{ + { + Command: ` +echo "hello" +echo "hi" +`, + }, + }, + }, + wantErr: false, + }, + + { + name: "[commands] JSON commands", + args: args{ + ctx: nil, + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + ignoreSystemEnv: true, + Commands: []any{ + "echo i will call hello, now", + map[string]any{ + "run": "hello", + }, + }, + }, + "hello": { + ignoreSystemEnv: true, + Commands: []any{ + "echo hello everyone", + }, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + WorkingDir: fn.Must(os.Getwd()), + Commands: []CommandJson{ + {Command: "echo i will call hello, now"}, + {Run: "hello"}, + }, + }, + wantErr: false, + }, + { + name: "[unhappy/commands] JSON commands [must throw err, when] run target does not exist", + args: args{ + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + ignoreSystemEnv: true, + Commands: []any{ + "echo i will call hello, now", + map[string]any{ + "run": "hello", + }, + }, + }, + }, + }, + taskName: "test", + }, + wantErr: true, + }, + { + name: "[unhappy/runfile] target task does not exist", + args: args{ + rf: &Runfile{ + Tasks: map[string]Task{}, + }, + taskName: "test", + }, + wantErr: true, + }, + } + + tests = append(tests, testRequires...) + tests = append(tests, testEnviroments...) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var task *Task + v, ok := tt.args.rf.Tasks[tt.args.taskName] + if !ok { + task = nil + } else { + task = &v + } + + got, err := ParseTask(NewContext(context.TODO(), slog.Default()), tt.args.rf, task) + if (err != nil) != tt.wantErr { + t.Errorf("ParseTask(), got = %v, error = %v, wantErr %v", got, err, tt.wantErr) + return + } + + // if !reflect.DeepEqual(got, tt.want) { + if !tt.wantErr { + if !areEqual(t, got, tt.want) { + t.Errorf("ParseTask():> \n\tgot:\t%v,\n\twant:\t%v", got, tt.want) + } + } + }) + } +} diff --git a/pkg/runfile/task.go b/pkg/runfile/task.go new file mode 100644 index 0000000..2baa192 --- /dev/null +++ b/pkg/runfile/task.go @@ -0,0 +1,54 @@ +package runfile + +// Only one of the fields must be set +type Requires struct { + Sh *string `json:"sh,omitempty"` + GoTmpl *string `json:"gotmpl,omitempty"` +} + +/* +EnvVar Values could take multiple forms: +- my_key: "value" +or + - my_key: + sh: "echo hello hi" + +Object values with `sh` key, such that the output of this command will be the value of the top-level key +*/ +type EnvVar map[string]any + +type Task struct { + Name string `json:"-"` + // Shell in which above commands will be executed + // Default: ["sh", "-c"] + /* Common Usecases could be: + - ["bash", "-c"] + - ["python", "-c"] + - ["node", "-e"] + */ + Shell []string `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 EnvVar `json:"env,omitempty"` + + // this field is for testing purposes only + ignoreSystemEnv bool `json:"-"` + + Requires []*Requires `json:"requires,omitempty"` + + // 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 + Commands []any `json:"cmd"` +} + +type CommandJson struct { + Command string + Run string `json:"run"` +} diff --git a/pkg/runfile/type.go b/pkg/runfile/type.go deleted file mode 100644 index f62304d..0000000 --- a/pkg/runfile/type.go +++ /dev/null @@ -1,15 +0,0 @@ -package runfile - -type RunFile struct { - Version string - - Tasks map[string]TaskSpec `json:"tasks"` -} - -type TaskSpec struct { - // load env vars from [.env](https://www.google.com/search?q=sample+dotenv+files&udm=2) files - DotEnv []string `json:"dotenv"` - Env map[string]any `json:"env"` - Commands []string `json:"cmd"` - Shell []string `json:"shell"` -} diff --git a/pkg/runfile/types.go b/pkg/runfile/types.go new file mode 100644 index 0000000..ae81759 --- /dev/null +++ b/pkg/runfile/types.go @@ -0,0 +1,168 @@ +package runfile + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "strings" + "text/template" + + sprig "github.com/go-task/slim-sprig/v3" + fn "github.com/nxtcoder17/runfile/pkg/functions" +) + +type Context struct { + context.Context + *slog.Logger +} + +func NewContext(ctx context.Context, logger *slog.Logger) Context { + lgr := logger + if lgr == nil { + lgr = slog.Default() + } + + return Context{Context: ctx, Logger: lgr} +} + +type EvaluationArgs struct { + Shell []string + Env map[string]string +} + +func ToEnviron(m map[string]string) []string { + results := os.Environ() + for k, v := range m { + results = append(results, fmt.Sprintf("%s=%v", k, v)) + } + return results +} + +type EnvKV struct { + Key string + + Value *string `json:"value"` + Sh *string `json:"sh"` + GoTmpl *string `json:"gotmpl"` +} + +func (ejv EnvKV) Parse(ctx Context, args EvaluationArgs) (*string, error) { + switch { + case ejv.Value != nil: + { + return ejv.Value, nil + } + case ejv.Sh != nil: + { + value := new(bytes.Buffer) + + cmd := createCommand(ctx, cmdArgs{ + shell: args.Shell, + env: ToEnviron(args.Env), + cmd: *ejv.Sh, + stdout: value, + }) + if err := cmd.Run(); err != nil { + return nil, err + } + + return fn.New(strings.TrimSpace(value.String())), nil + } + case ejv.GoTmpl != nil: + { + t := template.New(ejv.Key).Funcs(sprig.FuncMap()) + t, err := t.Parse(fmt.Sprintf(`{{ %s }}`, *ejv.GoTmpl)) + if err != nil { + return nil, err + } + + value := new(bytes.Buffer) + if err := t.ExecuteTemplate(value, ejv.Key, map[string]string{}); err != nil { + return nil, err + } + + return fn.New(strings.TrimSpace(value.String())), nil + } + default: + { + return nil, fmt.Errorf("failed to parse, unknown format, one of [value, sh, gotmpl] must be set") + } + } +} + +func parseEnvVars(ctx Context, ev EnvVar, args EvaluationArgs) (map[string]string, error) { + env := make(map[string]string, len(ev)) + for k, v := range ev { + switch v := v.(type) { + case string: + env[k] = v + case map[string]any: + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + + var envAsJson struct { + *EnvKV + Required bool + Default *EnvKV + } + + if err := json.Unmarshal(b, &envAsJson); err != nil { + return nil, err + } + + switch { + case envAsJson.Required: + { + isDefined := false + if _, ok := os.LookupEnv(k); ok { + isDefined = true + } + + if !isDefined { + if _, ok := args.Env[k]; ok { + isDefined = true + } + } + + if !isDefined { + return nil, fmt.Errorf("env: %q, not defined", k) + } + } + + case envAsJson.EnvKV != nil: + { + envAsJson.Key = k + s, err := envAsJson.EnvKV.Parse(ctx, args) + if err != nil { + return nil, err + } + env[k] = *s + } + + case envAsJson.Default != nil: + { + envAsJson.Default.Key = k + s, err := envAsJson.Default.Parse(ctx, args) + if err != nil { + return nil, err + } + env[k] = *s + } + default: + { + return nil, fmt.Errorf("either required, value, sh, gotmpl or default, must be defined") + } + } + + default: + env[k] = fmt.Sprintf("%v", v) + } + } + + return env, nil +}