├── .github └── workflows │ ├── runny-cd.yml │ └── runny-ci.yml ├── .gitignore ├── .goreleaser.yaml ├── .pre-commit-config.yaml ├── .runny.yaml ├── .vscode ├── extensions.json └── settings.json ├── README.md ├── README_test.go ├── completions ├── runny.bash ├── runny.fish └── runny.zsh ├── go.mod ├── go.sum ├── main.go ├── runny ├── cmd.go ├── cmd_test.go ├── constants.go ├── fixtures │ ├── invalid-circular-dependency.yaml │ ├── invalid-runny.yaml │ ├── invalid-yaml.yaml │ ├── minimal.yaml │ └── simple-good.yaml ├── model.go ├── model_test.go ├── shell.go ├── shell_test.go ├── utils.go └── utils_test.go └── schema ├── main.go └── runny.schema.json /.github/workflows/runny-cd.yml: -------------------------------------------------------------------------------- 1 | name: Deployment 2 | 3 | on: 4 | push: 5 | # run only against version tags 6 | tags: 7 | - "v*" 8 | 9 | permissions: 10 | contents: write 11 | # packages: write 12 | # issues: write 13 | 14 | jobs: 15 | goreleaser: 16 | # Generates the Github release, and updates the simonwhitaker/tap/gibo 17 | # Homebrew formula 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | - run: git fetch --force --tags 24 | - uses: actions/setup-go@v5 25 | with: 26 | go-version-file: go.mod 27 | # More assembly might be required: Docker logins, GPG, etc. It all depends 28 | # on your needs. 29 | - uses: goreleaser/goreleaser-action@v6 30 | with: 31 | # either 'goreleaser' (default) or 'goreleaser-pro': 32 | distribution: goreleaser 33 | version: latest 34 | args: release --clean 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.PUBLISHER_TOKEN }} 37 | -------------------------------------------------------------------------------- /.github/workflows/runny-ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: CI 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v4 21 | with: 22 | go-version: '1.22.5' 23 | 24 | - name: Build 25 | run: go build -v ./... 26 | 27 | - name: Test 28 | run: go test -v ./... 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Generated by gibo (https://github.com/simonwhitaker/gibo) 2 | ### https://raw.github.com/github/gitignore/4488915eec0b3a45b5c63ead28f286819c0917de/Go.gitignore 3 | 4 | # If you prefer the allow list template instead of the deny list, see community template: 5 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 6 | # 7 | # Binaries for programs and plugins 8 | *.exe 9 | *.exe~ 10 | *.dll 11 | *.so 12 | *.dylib 13 | 14 | # Test binary, built with `go test -c` 15 | *.test 16 | 17 | # Output of the go coverage tool, specifically when used with LiteIDE 18 | *.out 19 | 20 | # Dependency directories (remove the comment below to include it) 21 | # vendor/ 22 | 23 | # Go workspace file 24 | go.work 25 | dist 26 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | version: 2 4 | before: 5 | hooks: 6 | # You may remove this if you don't use go modules. 7 | - go mod tidy 8 | # you may remove this if you don't need go generate 9 | # - go generate ./... 10 | builds: 11 | - env: 12 | - CGO_ENABLED=0 13 | goos: 14 | - linux 15 | - windows 16 | - darwin 17 | 18 | archives: 19 | - format: tar.gz 20 | # this name template makes the OS and Arch compatible with the results of uname. 21 | name_template: >- 22 | {{ .ProjectName }}_ 23 | {{- title .Os }}_ 24 | {{- if eq .Arch "amd64" }}x86_64 25 | {{- else if eq .Arch "386" }}i386 26 | {{- else }}{{ .Arch }}{{ end }} 27 | {{- if .Arm }}v{{ .Arm }}{{ end }} 28 | # use zip for windows archives 29 | format_overrides: 30 | - goos: windows 31 | format: zip 32 | files: 33 | - completions/* 34 | 35 | brews: 36 | - name: runny 37 | homepage: https://github.com/simonwhitaker/runny 38 | repository: 39 | owner: simonwhitaker 40 | name: homebrew-tap 41 | commit_author: 42 | name: Simon Whitaker 43 | email: sw@netcetera.org 44 | extra_install: | 45 | bash_completion.install "completions/runny.bash" => "runny" 46 | fish_completion.install "completions/runny.fish" 47 | zsh_completion.install "completions/runny.zsh" => "_runny" 48 | 49 | checksum: 50 | name_template: "checksums.txt" 51 | snapshot: 52 | name_template: "{{ incpatch .Version }}-next" 53 | changelog: 54 | sort: asc 55 | filters: 56 | exclude: 57 | - "^docs:" 58 | - "^test:" 59 | # The lines beneath this are called `modelines`. See `:help modeline` 60 | # Feel free to remove those if you don't want/use them. 61 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 62 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 63 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v3.2.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | exclude: "runny.schema.json" 10 | - id: check-yaml 11 | exclude: "invalid-yaml.yaml" 12 | - id: check-added-large-files 13 | - repo: https://github.com/dnephin/pre-commit-golang 14 | rev: v0.5.1 15 | hooks: 16 | - id: go-fmt 17 | - id: go-unit-tests 18 | - id: go-generate 19 | args: ["./..."] 20 | -------------------------------------------------------------------------------- /.runny.yaml: -------------------------------------------------------------------------------- 1 | shell: /bin/bash 2 | commands: 3 | clean: 4 | run: | 5 | go clean ./... 6 | rm -rf dist 7 | install-pre-commit: 8 | internal: true 9 | if: "! command -v pre-commit" 10 | run: brew install pre-commit 11 | install-pre-commit-hooks: 12 | needs: 13 | - install-pre-commit 14 | run: pre-commit install 15 | install-fish-completions: 16 | # Homebrew installs these for you, but this is useful if you're installing with `go install .` 17 | run: ln -s $PWD/completions/runny.fish ~/.config/fish/completions 18 | release: 19 | argnames: 20 | - tag 21 | run: git tag -am $tag $tag && git push origin refs/tags/$tag 22 | release-next: 23 | run: | 24 | latest_version=$(git tag --list "v*" --sort "-refname" | head -1) 25 | next_version=$(echo ${latest_version} | awk -F. -v OFS=. '{$NF += 1 ; print}') 26 | printf "Release $next_version? (Current release is $latest_version) [yN] " 27 | read answer 28 | if [[ $answer =~ ^[Yy]$ ]]; then 29 | git tag -am $next_version $next_version && git push origin refs/tags/$next_version 30 | fi 31 | generate: 32 | run: go generate ./... 33 | test: 34 | run: go test ./... 35 | test-coverage: 36 | run: go test -coverprofile=c.out ./... && go tool cover -func="c.out" 37 | test-coverage-html: 38 | run: go test -coverprofile=c.out ./... && go tool cover -html="c.out" 39 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["redhat.vscode-yaml", "golang.go"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "yaml.schemas": { 3 | "./schema/runny.schema.json": [ 4 | ".runny.yaml", 5 | "examples/*.yaml", 6 | "fixtures/*.yaml" 7 | ] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🍯 Runny: for running things 2 | 3 | Makefiles are for boomers. The future is Runny. 4 | 5 | ## Features 6 | 7 | * ❤️ Simple YAML syntax (inspired by Github Actions) 8 | * 🪄 Full schema validaton == autocomplete in your favourite code editor 9 | * 🧱 Build workflows through composition with `needs` and `then` 10 | * 🏃‍♂️ Run steps conditionally with `if` 11 | 12 | ## Installation 13 | 14 | ```command 15 | brew install simonwhitaker/tap/runny 16 | ``` 17 | 18 | ## Usage 19 | 20 | Create a `.runny.yaml` file: 21 | 22 | ```yaml 23 | shell: /bin/bash 24 | commands: 25 | install-uv: 26 | if: "! command -v uv" 27 | run: pip install uv 28 | pip-sync: 29 | needs: 30 | - install-uv 31 | run: uv pip sync requirements.txt 32 | pip-compile-and-sync: 33 | needs: 34 | - install-uv 35 | run: | 36 | uv pip compile requirements.in -o requirements.txt 37 | uv pip sync requirements.txt 38 | pip-install: 39 | argnames: 40 | - packagespec 41 | run: echo $packagespec >> requirements.in 42 | then: 43 | - pip-compile-and-sync 44 | ``` 45 | 46 | Then run commands with runny: 47 | 48 | ```command 49 | runny pip-install ruff 50 | ``` 51 | 52 | ## Examples 53 | 54 | ### Go 55 | 56 | ```yaml 57 | commands: 58 | clean: 59 | run: | 60 | go clean ./... 61 | rm -rf dist 62 | install-goreleaser: 63 | if: "! command -v goreleaser" 64 | run: brew install goreleaser/tap/goreleaser 65 | release: 66 | needs: 67 | - clean 68 | - install-goreleaser 69 | run: | 70 | export GITHUB_TOKEN=$(gh auth token) 71 | goreleaser 72 | generate: 73 | run: go generate ./... 74 | test: 75 | run: go test ./... 76 | test-coverage: 77 | run: go test -coverprofile=c.out ./... && go tool cover -func="c.out" 78 | test-coverage-html: 79 | run: go test -coverprofile=c.out ./... && go tool cover -html="c.out" 80 | ``` 81 | 82 | ### Python 83 | 84 | ```yaml 85 | commands: 86 | update-requirements: 87 | run: pip freeze > requirements.txt 88 | pip-install: 89 | argnames: 90 | - packagespec 91 | run: pip install $packagespec 92 | then: 93 | - update-requirements 94 | ``` 95 | 96 | ### Docker Compose 97 | 98 | Docker Compose has good command-line completion already. But using runny, you can add entries for just the commands you use regularly, then get an uncluttered list of options when you tab-complete. 99 | 100 | ```yaml 101 | commands: 102 | up: 103 | run: docker compose up -d 104 | down: 105 | run: docker compose down 106 | build-and-up: 107 | run: docker compose up --build -d 108 | logs: 109 | argnames: 110 | - service 111 | run: docker compose logs $service 112 | shell: 113 | argnames: 114 | - service 115 | run: docker compose exec $service sh 116 | ``` 117 | -------------------------------------------------------------------------------- /README_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/gomarkdown/markdown/ast" 8 | "github.com/gomarkdown/markdown/parser" 9 | "github.com/simonwhitaker/runny/runny" 10 | "gopkg.in/yaml.v3" 11 | ) 12 | 13 | func TestREADMECodeBlocks(t *testing.T) { 14 | p := parser.New() 15 | md, err := os.ReadFile("README.md") 16 | if err != nil { 17 | t.Fatalf("Got an error reading README.md (which is ironic): %v", err) 18 | } 19 | root := p.Parse(md) 20 | ast.WalkFunc(root, func(node ast.Node, entering bool) ast.WalkStatus { 21 | if codeBlock, ok := node.(*ast.CodeBlock); ok && entering { 22 | lang := string(codeBlock.Info) 23 | if lang == "yaml" { 24 | var c runny.Config 25 | err := yaml.Unmarshal(codeBlock.Literal, &c) 26 | if err != nil { 27 | t.Fatalf("YAML example can't be parsed:\n%s", string(codeBlock.Literal)) 28 | } 29 | if len(c.Commands) == 0 { 30 | t.Fatalf("YAML example doesn't contain any commands:\n%s", string(codeBlock.Literal)) 31 | } 32 | } 33 | } 34 | return ast.GoToNext 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /completions/runny.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # See https://opensource.com/article/18/3/creating-bash-completion-script 4 | _runny_completions() { 5 | runny_commands=$(runny | cut -f1) 6 | COMPREPLY=($(compgen -W "${runny_commands}" "${COMP_WORDS[1]}")) 7 | } 8 | 9 | complete -F _runny_completions runny 10 | -------------------------------------------------------------------------------- /completions/runny.fish: -------------------------------------------------------------------------------- 1 | complete -c runny --no-files -a "(runny)" 2 | -------------------------------------------------------------------------------- /completions/runny.zsh: -------------------------------------------------------------------------------- 1 | #compdef runny 2 | 3 | # Install as _runny 4 | # 5 | # e.g. ln -s $PWD/runny.zsh /usr/local/share/zsh/site-functions/_runny 6 | 7 | _arguments "1: :($(runny | cut -f1 ))" 8 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/simonwhitaker/runny 2 | 3 | go 1.22.5 4 | 5 | require ( 6 | github.com/dominikbraun/graph v0.23.0 7 | github.com/fatih/color v1.17.0 8 | github.com/gomarkdown/markdown v0.0.0-20240730141124-034f12af3bf6 9 | github.com/invopop/jsonschema v0.12.0 10 | golang.org/x/term v0.22.0 11 | gopkg.in/yaml.v3 v3.0.1 12 | ) 13 | 14 | require ( 15 | github.com/bahlo/generic-list-go v0.2.0 // indirect 16 | github.com/buger/jsonparser v1.1.1 // indirect 17 | github.com/mailru/easyjson v0.7.7 // indirect 18 | github.com/mattn/go-colorable v0.1.13 // indirect 19 | github.com/mattn/go-isatty v0.0.20 // indirect 20 | github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect 21 | golang.org/x/sys v0.22.0 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= 2 | github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= 3 | github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= 4 | github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo= 8 | github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc= 9 | github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= 10 | github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= 11 | github.com/gomarkdown/markdown v0.0.0-20240730141124-034f12af3bf6 h1:ZPy+2XJ8u0bB3sNFi+I72gMEMS7MTg7aZCCXPOjV8iw= 12 | github.com/gomarkdown/markdown v0.0.0-20240730141124-034f12af3bf6/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= 13 | github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI= 14 | github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= 15 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 16 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 17 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 18 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 19 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 20 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 21 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 22 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 23 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 24 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 25 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 26 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 27 | github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= 28 | github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= 29 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 30 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 31 | golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= 32 | golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 33 | golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= 34 | golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= 35 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 36 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 37 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 38 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 39 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/simonwhitaker/runny/runny" 4 | 5 | func main() { 6 | runny.Run() 7 | } 8 | -------------------------------------------------------------------------------- /runny/cmd.go: -------------------------------------------------------------------------------- 1 | package runny 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/fatih/color" 8 | ) 9 | 10 | func run(path string) error { 11 | runny, err := readConfig(path) 12 | if err != nil { 13 | return err 14 | } 15 | 16 | // Parse command-line options 17 | args := os.Args[1:] 18 | for len(args) > 0 && args[0][0] == '-' { 19 | option := args[0] 20 | switch option { 21 | case "-h", "--help": 22 | runny.PrintHelp() 23 | return nil 24 | case "-v", "--verbose": 25 | runny.verbose = true 26 | default: 27 | return fmt.Errorf("unknown option: %s", option) 28 | } 29 | args = args[1:] 30 | } 31 | 32 | // Process runny command 33 | if len(args) > 0 { 34 | name := CommandName(args[0]) 35 | err := runny.Execute(name, args[1:]...) 36 | if err != nil { 37 | return err 38 | } 39 | } else { 40 | runny.PrintCommands() 41 | } 42 | return nil 43 | } 44 | 45 | func Run() { 46 | err := run(".runny.yaml") 47 | if err != nil { 48 | errStr := fmt.Sprintf("%v\n", err) 49 | red := color.New(color.FgRed) 50 | red.Fprint(os.Stderr, errStr) 51 | os.Exit(1) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /runny/cmd_test.go: -------------------------------------------------------------------------------- 1 | package runny 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestPrivateRun(t *testing.T) { 9 | oldArgs := os.Args 10 | defer func() { 11 | os.Args = oldArgs 12 | }() 13 | 14 | os.Args = []string{os.Args[0]} 15 | err := run("fixtures/minimal.yaml") 16 | if err != nil { 17 | t.Fatalf("Expected success, got error: %v", err) 18 | } 19 | 20 | os.Args = []string{os.Args[0], "-v"} 21 | err = run("fixtures/minimal.yaml") 22 | if err != nil { 23 | t.Fatalf("Expected success when run with -v, got error: %v", err) 24 | } 25 | 26 | os.Args = []string{os.Args[0], "-h"} 27 | err = run("fixtures/minimal.yaml") 28 | if err != nil { 29 | t.Fatalf("Expected success when run with -h, got error: %v", err) 30 | } 31 | 32 | os.Args = []string{os.Args[0], "-Z"} 33 | err = run("fixtures/minimal.yaml") 34 | if err == nil { 35 | t.Fatalf("Expected failure when run with unknown command-line arg, got success") 36 | } 37 | 38 | os.Args = []string{os.Args[0], "ok"} 39 | err = run("fixtures/minimal.yaml") 40 | if err != nil { 41 | t.Fatalf("Expected success when run with known command, got error: %v", err) 42 | } 43 | 44 | os.Args = []string{os.Args[0], "not-ok"} 45 | err = run("fixtures/minimal.yaml") 46 | if err == nil { 47 | t.Fatalf("Expected failure when run with unknown command, got success") 48 | } 49 | 50 | err = run("fixtures/invalid-yaml.yaml") 51 | if err == nil { 52 | t.Fatalf("Expected error, got success") 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /runny/constants.go: -------------------------------------------------------------------------------- 1 | package runny 2 | 3 | import "github.com/fatih/color" 4 | 5 | var defaultShell = "/bin/bash" 6 | var primaryColor *color.Color = color.New(color.Bold) 7 | var secondaryColor *color.Color = color.New(color.FgHiBlack) 8 | -------------------------------------------------------------------------------- /runny/fixtures/invalid-circular-dependency.yaml: -------------------------------------------------------------------------------- 1 | commands: 2 | foo: 3 | needs: 4 | - bar 5 | bar: 6 | needs: 7 | - foo 8 | -------------------------------------------------------------------------------- /runny/fixtures/invalid-runny.yaml: -------------------------------------------------------------------------------- 1 | shell: /bin/bash 2 | commands: 3 | - name: foo 4 | run: ls foo 5 | -------------------------------------------------------------------------------- /runny/fixtures/invalid-yaml.yaml: -------------------------------------------------------------------------------- 1 | commands: 2 | foo: { 3 | -------------------------------------------------------------------------------- /runny/fixtures/minimal.yaml: -------------------------------------------------------------------------------- 1 | commands: 2 | ok: 3 | run: "true" 4 | -------------------------------------------------------------------------------- /runny/fixtures/simple-good.yaml: -------------------------------------------------------------------------------- 1 | shell: /bin/bash 2 | commands: 3 | foo: 4 | run: ls foo 5 | bar: 6 | needs: 7 | - foo 8 | run: ls bar 9 | baz: 10 | if: test -e foo.txt 11 | run: ls baz 12 | -------------------------------------------------------------------------------- /runny/model.go: -------------------------------------------------------------------------------- 1 | package runny 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "slices" 7 | "strings" 8 | 9 | "github.com/fatih/color" 10 | "golang.org/x/term" 11 | ) 12 | 13 | type CommandName string 14 | 15 | type CommandDef struct { 16 | // The command to be run 17 | Run string `json:"run,omitempty"` 18 | // A list of the commands that must be run before this one 19 | Needs []CommandName `json:"needs,omitempty"` 20 | // A list of the commands that must be run after this one 21 | Then []CommandName `json:"then,omitempty"` 22 | // A conditional expression; this command only runs if the If expression evaluates to true 23 | If string `json:"if,omitempty"` 24 | // A list of environment variables to be set when running the command 25 | Env []string `json:"env,omitempty"` 26 | // The shell to be used when running the command. Defaults to /bin/bash 27 | Shell string `json:"shell,omitempty"` 28 | // A list of argument names to be passed on the command line when invoking Runny. These arguments can be accessed in 29 | // the Run string by prefixing them with $. 30 | ArgNames []string `json:"argnames,omitempty"` 31 | // If true, the command is suppressed from the output generated when you run Runny with no arguments, and from shell 32 | // completion. 33 | Internal bool `json:"internal,omitempty"` 34 | } 35 | 36 | func (cmd *CommandDef) GetShell(c *Config) (Shell, error) { 37 | shellString := cmd.Shell 38 | if len(shellString) == 0 { 39 | return c.GetShell() 40 | } 41 | shell, err := NewShell(shellString) 42 | if err != nil { 43 | return nil, err 44 | } 45 | return shell, nil 46 | } 47 | 48 | type Config struct { 49 | Commands map[CommandName]CommandDef `json:"commands"` 50 | // The shell to be used when running all commands. Defaults to /bin/bash 51 | Shell string `json:"shell,omitempty"` 52 | // A list of environment variables to be set when running all commands 53 | Env []string `json:"env,omitempty"` 54 | // Set if the -v/--verbose flag is set when invoking Runny 55 | verbose bool 56 | } 57 | 58 | func (c *Config) GetShell() (Shell, error) { 59 | shellString := c.Shell 60 | if len(shellString) == 0 { 61 | shellString = defaultShell 62 | } 63 | shell, err := NewShell(shellString) 64 | if err != nil { 65 | return nil, err 66 | } 67 | return shell, nil 68 | } 69 | 70 | func (c *Config) PrintHelp() { 71 | titleString := color.New(color.FgYellow, color.Bold).Sprintf("🍯 runny") 72 | usageString := color.New(color.Bold).Sprintf("runny [options] [command]") 73 | fmt.Printf(`%s -- for running things. 74 | 75 | Usage: 76 | %s 77 | 78 | Options: 79 | -h, --help Show this help 80 | -v, --verbose Enable verbose mode 81 | 82 | Run without arguments to list commands.`, titleString, usageString) 83 | fmt.Print("\n") 84 | } 85 | 86 | func (c *Config) PrintCommands() { 87 | commands := c.Commands 88 | names := []CommandName{} 89 | for key, cmd := range commands { 90 | if c.verbose || !cmd.Internal { 91 | names = append(names, key) 92 | } 93 | } 94 | 95 | slices.Sort(names) 96 | var separator = " " 97 | var maxLineLength int 98 | 99 | if term.IsTerminal(int(os.Stdout.Fd())) { 100 | if c.verbose { 101 | maxLineLength = 0 102 | } else { 103 | width, _, err := term.GetSize(int(os.Stdin.Fd())) 104 | if err == nil { 105 | maxLineLength = width 106 | } 107 | } 108 | } else { 109 | separator = "\t" 110 | maxLineLength = 0 111 | } 112 | 113 | for _, name := range names { 114 | maxCommandLength := maxLineLength - len(name) - 1 115 | var rawCommand = commandStringToSingleLine(commands[name].Run, maxCommandLength) 116 | 117 | fmt.Print(primaryColor.Sprint(name)) 118 | fmt.Print(separator) 119 | fmt.Println(secondaryColor.Sprint(rawCommand)) 120 | } 121 | } 122 | 123 | func (c *Config) Execute(name CommandName, args ...string) error { 124 | command, ok := c.Commands[name] 125 | if !ok { 126 | return fmt.Errorf("unknown command: %s", name) 127 | } 128 | 129 | shell, err := command.GetShell(c) 130 | if err != nil { 131 | return err 132 | } 133 | 134 | env := append(c.Env, command.Env...) 135 | 136 | // Check the If condition 137 | cond := strings.TrimSpace(command.If) 138 | if len(cond) > 0 { 139 | err := shell.Run(cond, []string{}, false, c.verbose, env) 140 | if err != nil { 141 | // Run returns an error if the exit status is not zero. So in this case, this means the test failed. 142 | if c.verbose { 143 | secondaryColor.Printf("%v: '%v' not true, skipping\n", name, cond) 144 | } 145 | return nil 146 | } 147 | } 148 | 149 | // Handle Needs 150 | for _, name := range command.Needs { 151 | err := c.Execute(name) 152 | if err != nil { 153 | return err 154 | } 155 | } 156 | 157 | // Collect args 158 | if len(command.ArgNames) > 0 { 159 | if len(args) < len(command.ArgNames) { 160 | return fmt.Errorf("%d named args defined but only %d supplied", len(command.ArgNames), len(args)) 161 | } 162 | for _, argName := range command.ArgNames { 163 | env = append(env, fmt.Sprintf("%s=%s", argName, args[0])) 164 | args = args[1:] 165 | } 166 | } 167 | 168 | // Handle the Run 169 | run := strings.TrimSpace(command.Run) 170 | if len(run) > 0 { 171 | err := shell.Run(run, args, true, c.verbose, env) 172 | if err != nil { 173 | return err 174 | } 175 | } 176 | 177 | // Handle Then 178 | for _, name := range command.Then { 179 | err := c.Execute(name) 180 | if err != nil { 181 | return err 182 | } 183 | } 184 | 185 | return nil 186 | } 187 | -------------------------------------------------------------------------------- /runny/model_test.go: -------------------------------------------------------------------------------- 1 | package runny 2 | 3 | import "testing" 4 | 5 | func TestConfigGetShell(t *testing.T) { 6 | c := Config{} 7 | shell, err := c.GetShell() 8 | if shell == nil || err != nil { 9 | t.Errorf("Expected a valid shell, got an error: %v", err) 10 | } 11 | 12 | c = Config{Shell: "/bin/bash"} 13 | shell, err = c.GetShell() 14 | if shell == nil || err != nil { 15 | t.Errorf("Expected a valid shell, got an error: %v", err) 16 | } 17 | 18 | c = Config{Shell: "/bin/pwsh"} 19 | _, err = c.GetShell() 20 | if err == nil { 21 | t.Errorf("Expected an error, but the call succeeded") 22 | } 23 | } 24 | 25 | func TestCommandDefGetShell(t *testing.T) { 26 | cmdWithShell := CommandDef{Shell: "/bin/zsh"} 27 | cmdWithoutShell := CommandDef{} 28 | c := Config{Shell: "/bin/bash", Commands: map[CommandName]CommandDef{ 29 | "cmdWithShell": cmdWithShell, 30 | "cmdWithoutShell": cmdWithoutShell, 31 | }} 32 | 33 | shell, err := cmdWithShell.GetShell(&c) 34 | if shell, ok := shell.(*PosixShell); ok && shell.command != "/bin/zsh" { 35 | t.Errorf("Expected a valid shell, got an error: %v", err) 36 | } 37 | 38 | shell, err = cmdWithoutShell.GetShell(&c) 39 | if shell, ok := shell.(*PosixShell); ok && shell.command != "/bin/bash" { 40 | t.Errorf("Expected a valid shell, got an error: %v", err) 41 | } 42 | } 43 | 44 | func ExampleConfig_PrintHelp() { 45 | c := Config{} 46 | c.PrintHelp() 47 | // Output: 🍯 runny -- for running things. 48 | // 49 | // Usage: 50 | // runny [options] [command] 51 | // 52 | // Options: 53 | // -h, --help Show this help 54 | // -v, --verbose Enable verbose mode 55 | // 56 | // Run without arguments to list commands. 57 | } 58 | 59 | func ExampleConfig_PrintCommands() { 60 | c := Config{Commands: map[CommandName]CommandDef{"foo": {Run: "bar"}}} 61 | c.PrintCommands() 62 | // Output: foo bar 63 | } 64 | -------------------------------------------------------------------------------- /runny/shell.go: -------------------------------------------------------------------------------- 1 | package runny 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path" 8 | "strings" 9 | ) 10 | 11 | type Shell interface { 12 | Run(command string, extraArgs []string, echoStdout, verbose bool, env []string) error 13 | } 14 | 15 | func NewShell(command string) (Shell, error) { 16 | switch path.Base(command) { 17 | case "pwsh", "powershell": 18 | return nil, fmt.Errorf("unsupported shell: %s", command) 19 | default: 20 | return PosixShell{command: command}, nil 21 | } 22 | } 23 | 24 | type PosixShell struct { 25 | // PosixShell might be the wrong term here. Fish for example isn't strictly POSIX-compliant. But really, any shell 26 | // that allows you to run a command with `shell -c command` works. 27 | command string 28 | } 29 | 30 | func (shell PosixShell) Run(command string, extraArgs []string, echoStdout, verbose bool, env []string) error { 31 | if len(extraArgs) > 0 { 32 | command = command + " " + strings.Join(extraArgs, " ") 33 | } 34 | 35 | if verbose { 36 | secondaryColor.Printf("Executing %s\n", command) 37 | } 38 | args := []string{"-c", command} 39 | 40 | cmd := exec.Command(shell.command, args...) 41 | cmd.Env = append(os.Environ(), env...) 42 | cmd.Stderr = os.Stderr 43 | cmd.Stdin = os.Stdin 44 | if echoStdout { 45 | cmd.Stdout = os.Stdout 46 | } 47 | 48 | return cmd.Run() 49 | } 50 | -------------------------------------------------------------------------------- /runny/shell_test.go: -------------------------------------------------------------------------------- 1 | package runny 2 | 3 | import "testing" 4 | 5 | func TestNewShell(t *testing.T) { 6 | shell, err := NewShell("/bin/bash") 7 | if shell == nil || err != nil { 8 | t.Errorf("Expected a valid shell, got an error: %v", err) 9 | } 10 | 11 | _, err = NewShell("/bin/pwsh") 12 | if err == nil { 13 | t.Errorf("Expected an error") 14 | } 15 | } 16 | 17 | func TestPosixShellRun(t *testing.T) { 18 | ps := PosixShell{command: "/bin/bash"} 19 | err := ps.Run("true", []string{}, false, false, []string{}) 20 | if err != nil { 21 | t.Errorf("Expected success, got an error: %v", err) 22 | } 23 | 24 | err = ps.Run("true", []string{"fred"}, false, false, []string{}) 25 | if err != nil { 26 | t.Errorf("Expected success when passing a valid arg, got an error: %v", err) 27 | } 28 | 29 | err = ps.Run("true", []string{}, true, false, []string{}) 30 | if err != nil { 31 | t.Errorf("Expected success when echoing stdout, got an error: %v", err) 32 | } 33 | 34 | err = ps.Run("true", []string{}, false, true, []string{}) 35 | if err != nil { 36 | t.Errorf("Expected success when running in verbose mode, got an error: %v", err) 37 | } 38 | 39 | err = ps.Run("true", []string{}, false, false, []string{"FOO=foo"}) 40 | if err != nil { 41 | t.Errorf("Expected success when passing env variables, got an error: %v", err) 42 | } 43 | 44 | err = ps.Run("false", []string{}, false, false, []string{}) 45 | if err == nil { 46 | t.Errorf("Expected error if the command fails, but no error was returned") 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /runny/utils.go: -------------------------------------------------------------------------------- 1 | package runny 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/dominikbraun/graph" 9 | "gopkg.in/yaml.v3" 10 | ) 11 | 12 | func commandStringToSingleLine(command string, maxlength int) string { 13 | command = strings.TrimSpace(command) 14 | lines := strings.Split(command, "\n") 15 | trimmedLines := []string{} 16 | for _, line := range lines { 17 | trimmedLines = append(trimmedLines, strings.TrimSpace(line)) 18 | } 19 | result := strings.Join(trimmedLines, "; ") 20 | if maxlength > 0 && len(result) > maxlength { 21 | result = result[:maxlength-1] + "…" 22 | } 23 | return result 24 | } 25 | 26 | func readConfig(path string) (Config, error) { 27 | // Read .runny.yaml from the current directory 28 | var conf Config 29 | yamlFile, err := os.ReadFile(path) 30 | if err != nil { 31 | return conf, err 32 | } 33 | 34 | err = yaml.Unmarshal(yamlFile, &conf) 35 | if err != nil { 36 | if strings.Contains(err.Error(), "cannot unmarshal") { 37 | return conf, fmt.Errorf("invalid runny config file: %s", path) 38 | } 39 | return conf, err 40 | } 41 | 42 | return conf, conf.validate() 43 | } 44 | 45 | func (c *Config) validate() error { 46 | hash := func(c CommandName) string { 47 | return string(c) 48 | } 49 | g := graph.New(hash, graph.Directed(), graph.PreventCycles()) 50 | 51 | for cmdName := range c.Commands { 52 | // The only error that can be returned here is if the vertex already exists (see 53 | // https://github.com/dominikbraun/graph/blob/a999520a23a8fc232bfe3ef40f69a6f7d9f5bfde/store.go#L92), and the 54 | // YAML unmarshaller already prevents that. 55 | g.AddVertex(cmdName) 56 | } 57 | 58 | for cmdName, cmd := range c.Commands { 59 | for _, needsName := range cmd.Needs { 60 | err := g.AddEdge(hash(cmdName), hash(needsName)) 61 | if err != nil { 62 | return fmt.Errorf("error declaring %s as dependency of %s: %v", needsName, cmdName, err) 63 | } 64 | } 65 | } 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /runny/utils_test.go: -------------------------------------------------------------------------------- 1 | package runny 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | type Input struct { 9 | command string 10 | maxLength int 11 | } 12 | 13 | type Param struct { 14 | input Input 15 | expected string 16 | } 17 | 18 | func TestCommandStringToSingleLine(t *testing.T) { 19 | params := []Param{ 20 | {input: Input{command: "", maxLength: 80}, expected: ""}, 21 | {input: Input{command: "foo bar wibble", maxLength: 10}, expected: "foo bar w…"}, 22 | {input: Input{command: "foo\nbar", maxLength: 80}, expected: "foo; bar"}, 23 | {input: Input{command: " foo \n bar ", maxLength: 80}, expected: "foo; bar"}, 24 | {input: Input{command: "Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aliquam convallis. Nunc lacus. Curabitur nunc mauris, commodo vel, eleifend in, ornare sit amet, felis. Nullam mi neque, feugiat et, porttitor vitae, pharetra non, lacus. Fusce imperdiet sem quis dui.", maxLength: 0}, expected: "Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aliquam convallis. Nunc lacus. Curabitur nunc mauris, commodo vel, eleifend in, ornare sit amet, felis. Nullam mi neque, feugiat et, porttitor vitae, pharetra non, lacus. Fusce imperdiet sem quis dui."}, 25 | } 26 | for _, p := range params { 27 | output := commandStringToSingleLine(p.input.command, p.input.maxLength) 28 | if output != p.expected { 29 | t.Fatalf("Expected %s, got %s\n", p.expected, output) 30 | } 31 | } 32 | } 33 | 34 | func TestReadConfig(t *testing.T) { 35 | conf, err := readConfig("fixtures/simple-good.yaml") 36 | if err != nil { 37 | t.Fatalf("Got error when reading in config file: %v\n", err) 38 | } 39 | 40 | expectedLenCommands := 3 41 | if len(conf.Commands) != expectedLenCommands { 42 | t.Fatalf("Expected %d commands, got %d\n", expectedLenCommands, len(conf.Commands)) 43 | } 44 | 45 | expectedCommandFooRun := "ls foo" 46 | if conf.Commands["foo"].Run != expectedCommandFooRun { 47 | t.Fatalf("Expected foo command's run value to be %s, got %s", expectedCommandFooRun, conf.Commands["foo"].Run) 48 | } 49 | } 50 | 51 | func TestReadInvalidConfigs(t *testing.T) { 52 | _, err := readConfig("fixtures/invalid-runny.yaml") 53 | if err == nil { 54 | t.Fatalf("Expected an error when reading invalid runny config, but reading was successful") 55 | } 56 | 57 | if !strings.Contains(err.Error(), "invalid runny config file") { 58 | t.Fatalf("unexpected error message: %v", err) 59 | } 60 | 61 | _, err = readConfig("fixtures/invalid-yaml.yaml") 62 | if err == nil { 63 | t.Fatalf("Expected an error when reading invalid YAML file, but reading was successful") 64 | } 65 | } 66 | 67 | func TestReadMissingFIle(t *testing.T) { 68 | _, err := readConfig("fixtures/does-not-exist.yaml") 69 | if err == nil { 70 | t.Fatalf("Expected an error when reading missing config, but reading was successful") 71 | } 72 | 73 | if !strings.Contains(err.Error(), "no such file or directory") { 74 | t.Fatalf("unexpected error message: %v", err) 75 | } 76 | 77 | } 78 | 79 | func TestConfigWithCircularDependency(t *testing.T) { 80 | _, err := readConfig("fixtures/invalid-circular-dependency.yaml") 81 | if err == nil { 82 | t.Fatalf("Expected an error when reading invalid config, but reading was succeeded") 83 | } 84 | 85 | if !strings.Contains(err.Error(), "edge would create a cycle") { 86 | t.Fatalf("unexpected error message: %v", err) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /schema/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | //go:generate go run . ./runny.schema.json 4 | 5 | import ( 6 | "encoding/json" 7 | "fmt" 8 | "os" 9 | 10 | "github.com/invopop/jsonschema" 11 | "github.com/simonwhitaker/runny/runny" 12 | ) 13 | 14 | func main() { 15 | schema := jsonschema.Reflect(&runny.Config{}) 16 | bytes, err := schema.MarshalJSON() 17 | if err != nil { 18 | panic(err) 19 | } 20 | 21 | // jsonschema doesn't support indenting, so we need to unmarshal/marshal with the json package 22 | var tempJsonObj map[string]interface{} 23 | err = json.Unmarshal(bytes, &tempJsonObj) 24 | if err != nil { 25 | panic(err) 26 | } 27 | 28 | indentedSchema, err := json.MarshalIndent(tempJsonObj, "", " ") 29 | schemaString := string(indentedSchema[:]) 30 | 31 | if err != nil { 32 | panic(err) 33 | } 34 | if len(os.Args) > 1 { 35 | filename := os.Args[1] 36 | f, err := os.Create(filename) 37 | if err != nil { 38 | panic(err) 39 | } 40 | defer f.Close() 41 | f.WriteString(schemaString) 42 | } else { 43 | fmt.Println(schemaString) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /schema/runny.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$defs": { 3 | "CommandDef": { 4 | "additionalProperties": false, 5 | "properties": { 6 | "argnames": { 7 | "items": { 8 | "type": "string" 9 | }, 10 | "type": "array" 11 | }, 12 | "env": { 13 | "items": { 14 | "type": "string" 15 | }, 16 | "type": "array" 17 | }, 18 | "if": { 19 | "type": "string" 20 | }, 21 | "internal": { 22 | "type": "boolean" 23 | }, 24 | "needs": { 25 | "items": { 26 | "type": "string" 27 | }, 28 | "type": "array" 29 | }, 30 | "run": { 31 | "type": "string" 32 | }, 33 | "shell": { 34 | "type": "string" 35 | }, 36 | "then": { 37 | "items": { 38 | "type": "string" 39 | }, 40 | "type": "array" 41 | } 42 | }, 43 | "type": "object" 44 | }, 45 | "Config": { 46 | "additionalProperties": false, 47 | "properties": { 48 | "commands": { 49 | "additionalProperties": { 50 | "$ref": "#/$defs/CommandDef" 51 | }, 52 | "type": "object" 53 | }, 54 | "env": { 55 | "items": { 56 | "type": "string" 57 | }, 58 | "type": "array" 59 | }, 60 | "shell": { 61 | "type": "string" 62 | } 63 | }, 64 | "required": [ 65 | "commands" 66 | ], 67 | "type": "object" 68 | } 69 | }, 70 | "$id": "https://github.com/simonwhitaker/runny/runny/config", 71 | "$ref": "#/$defs/Config", 72 | "$schema": "https://json-schema.org/draft/2020-12/schema" 73 | } --------------------------------------------------------------------------------