├── .github ├── dependabot.yml └── workflows │ ├── docs.yml │ ├── release.yaml │ └── test.yaml ├── .gitignore ├── .golangci.yaml ├── .goreleaser.yml ├── .run ├── lets --version.run.xml ├── lets print-env.run.xml └── lets.run.xml ├── .vscode └── launch.json ├── Dockerfile ├── LICENSE ├── README.md ├── checksum ├── checksum.go └── checksum_test.go ├── cmd ├── completion.go ├── lsp.go ├── root.go ├── root_test.go ├── self.go └── subcommand.go ├── config ├── config │ ├── checksum.go │ ├── clone.go │ ├── cmd.go │ ├── cmd_test.go │ ├── command.go │ ├── config.go │ ├── config_test.go │ ├── deps.go │ ├── env.go │ ├── mixin.go │ ├── ref.go │ └── version.go ├── find.go ├── find_test.go ├── load.go ├── load_test.go ├── path │ └── path.go ├── validate.go ├── validate_test.go └── workdir.go ├── docker-compose.yml ├── docopt └── docopts.go ├── docs ├── .gitignore ├── README.md ├── blog │ └── 2020-05-24-history-of-lets.md ├── docs │ ├── advanced_usage.md │ ├── architecture.md │ ├── basic_usage.md │ ├── best_practices.md │ ├── changelog.md │ ├── cli.md │ ├── completion.md │ ├── config.md │ ├── contribute.md │ ├── development.md │ ├── env.md │ ├── example_js.md │ ├── examples.md │ ├── ide_support.md │ ├── installation.mdx │ ├── quick_start.md │ └── what_is_lets.md ├── docusaurus.config.js ├── lets-architecture-diagram.drawio ├── package-lock.json ├── package.json ├── sidebars.js ├── src │ ├── css │ │ └── custom.css │ └── pages │ │ ├── index.js │ │ └── styles.module.css └── static │ ├── CNAME │ ├── img │ ├── check.svg │ ├── doc.svg │ ├── favicon.ico │ ├── gear.svg │ ├── lets-architecture-diagram.png │ ├── logo.png │ └── logo.svg │ └── schema.json ├── env └── env.go ├── examples └── python │ ├── .gitignore │ ├── Dockerfile │ ├── Readme.md │ ├── docker-compose.yaml │ ├── lets.yaml │ ├── requirements.txt │ └── server │ ├── __main__.py │ └── __pycache__ │ └── __main__.cpython-38.pyc ├── executor ├── env.go ├── env_test.go └── executor.go ├── go.mod ├── go.sum ├── install.sh ├── lets.build.yaml ├── lets.yaml ├── logging ├── formatter.go ├── log.go ├── log_test.go └── writerhook.go ├── lsp ├── handlers.go ├── server.go ├── storage.go ├── treesitter.go ├── treesitter_test.go └── utils.go ├── main.go ├── package-lock.json ├── set ├── set.go └── set_test.go ├── test ├── args.go └── temp_file.go ├── tests ├── command_after.bats ├── command_after │ └── lets.yaml ├── command_checksum.bats ├── command_checksum │ ├── bar_1.txt │ ├── foo_1.txt │ ├── foo_2.txt │ ├── lets.yaml │ └── subdir │ │ └── .gitkeep ├── command_cmd.bats ├── command_cmd │ └── lets.yaml ├── command_depends.bats ├── command_depends │ ├── lets-parallel-in-depends.yaml │ └── lets.yaml ├── command_docopt_cmd_placeholder.bats ├── command_docopt_cmd_placeholder │ └── lets.yaml ├── command_env.bats ├── command_env │ ├── foo.txt │ ├── lets.aliased-env.yaml │ └── lets.yaml ├── command_eval_env.bats ├── command_eval_env │ └── lets.yaml ├── command_help.bats ├── command_help │ └── lets.yaml ├── command_name.bats ├── command_name │ └── lets.yaml ├── command_options.bats ├── command_options │ ├── echoArgs.sh │ └── lets.yaml ├── command_persist_checksum.bats ├── command_persist_checksum │ ├── foo_1.txt │ ├── foo_2.txt │ ├── lets.yaml │ └── use_persist_without_checksum │ │ └── lets.yaml ├── command_ref.bats ├── command_ref │ ├── lets.mixin.yaml │ ├── lets.no-command.yaml │ └── lets.yaml ├── command_shell.bats ├── command_shell │ └── lets.yaml ├── command_work_dir.bats ├── command_work_dir │ ├── lets.yaml │ └── project │ │ └── text.txt ├── commands_required.bats ├── commands_required │ └── lets.yaml ├── completion.bats ├── completion │ ├── lets.yaml │ └── no_lets_file │ │ └── .gitkeep ├── config_version.bats ├── config_version │ ├── lets-with-version-0.0.1.yaml │ ├── lets-with-version-0.0.3.yaml │ └── lets-without-version.yaml ├── default_env.bats ├── default_env │ ├── a │ │ ├── b │ │ │ └── .gitkeep │ │ └── lets.yaml │ └── lets.yaml ├── find_config.bats ├── find_config │ ├── .gitkeep │ ├── a │ │ ├── .gitkeep │ │ └── b │ │ │ └── .gitkeep │ ├── lets.yaml │ └── lets1.yaml ├── global_before.bats ├── global_before │ ├── lets.mix.yaml │ └── lets.yaml ├── global_env.bats ├── global_env │ ├── foo.txt │ ├── lets.aliased-env.yaml │ └── lets.yaml ├── global_eval_env.bats ├── global_eval_env │ └── lets.yaml ├── help.bats ├── help │ └── lets.yaml ├── init.bats ├── init │ └── exists │ │ └── lets.yaml ├── mixins.bats ├── mixins │ ├── lets.mix.yaml │ └── lets.yaml ├── no_depends.bats ├── no_depends │ └── lets.yaml ├── no_lets_file.bats ├── no_lets_file │ ├── .gitkeep │ └── broken_lets.yaml ├── override_env.bats ├── override_env │ └── lets.yaml ├── root_flags.bats ├── root_flags │ ├── lets.yaml │ └── lets1.yaml ├── test_helpers.bash ├── version.bats ├── zsh_completion.bats_ └── zsh_completion │ ├── completion_helper.sh │ └── lets.yaml ├── upgrade ├── registry │ └── registry.go ├── upgrade.go └── upgrade_test.go ├── util ├── dir.go ├── file.go └── version.go └── workdir └── workdir.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: / 5 | labels: 6 | - dependencies 7 | - go 8 | schedule: 9 | day: sunday 10 | interval: weekly 11 | 12 | - package-ecosystem: "npm" 13 | directory: "/docs" 14 | schedule: 15 | interval: "weekly" 16 | # Disable all pull requests for npm dependencies in docs directory 17 | open-pull-requests-limit: 0 -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | docs: 10 | name: Deploy docs 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: 16.x 18 | 19 | - name: Install dependencies 20 | run: npm install 21 | working-directory: ./docs 22 | 23 | - name: Copy install.sh to static 24 | run: cp ./install.sh ./docs/static/install.sh 25 | 26 | - name: Build website 27 | run: npm run build 28 | working-directory: ./docs 29 | 30 | - name: Deploy 31 | uses: peaceiris/actions-gh-pages@v3 32 | with: 33 | github_token: ${{ secrets.GITHUB_TOKEN }} 34 | publish_dir: ./docs/build 35 | user_name: github-actions[bot] 36 | user_email: 41898282+github-actions[bot]@users.noreply.github.com -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | # Sequence of patterns matched against refs/tags 4 | tags: 5 | - 'v*' # Push events to matching v*, i.e. v1.0.0, v1.0.0-rc1 6 | name: Release 7 | jobs: 8 | goreleaser: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | - name: Unshallow 14 | run: git fetch --prune --unshallow 15 | - name: Set up Go 16 | uses: actions/setup-go@v1 17 | with: 18 | go-version: 1.24.x 19 | - name: Run GoReleaser (dry run) 20 | env: 21 | PACKAGE_NAME: github.com/lets-cli/lets 22 | GOLANG_CROSS_VERSION: v1.24 23 | run: | 24 | docker run \ 25 | --rm \ 26 | -e CGO_ENABLED=1 \ 27 | -v /var/run/docker.sock:/var/run/docker.sock \ 28 | -v `pwd`:/go/src/${PACKAGE_NAME}\ 29 | -v `pwd`/sysroot:/sysroot \ 30 | -w /go/src/${PACKAGE_NAME} \ 31 | ghcr.io/goreleaser/goreleaser-cross:${GOLANG_CROSS_VERSION} \ 32 | --clean --skip=validate --skip=publish 33 | - name: Run GoReleaser 34 | env: 35 | PACKAGE_NAME: github.com/lets-cli/lets 36 | GOLANG_CROSS_VERSION: v1.24 37 | run: | 38 | docker run \ 39 | --rm \ 40 | -e CGO_ENABLED=1 \ 41 | -e GITHUB_TOKEN="${{secrets.GITHUB_TOKEN}}" \ 42 | -e HOMEBREW_TAP_GITHUB_TOKEN="${{secrets.GH_PAT}}" \ 43 | -e AUR_GITHUB_TOKEN="${{secrets.AUR_SSH_PRIVATE_KEY}}" \ 44 | -v /var/run/docker.sock:/var/run/docker.sock \ 45 | -v `pwd`:/go/src/${PACKAGE_NAME}\ 46 | -v `pwd`/sysroot:/sysroot \ 47 | -w /go/src/${PACKAGE_NAME} \ 48 | ghcr.io/goreleaser/goreleaser-cross:${GOLANG_CROSS_VERSION} \ 49 | release --clean 50 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | branches: 4 | - master 5 | types: 6 | - assigned 7 | - opened 8 | - synchronize 9 | - reopened 10 | 11 | name: Test 12 | jobs: 13 | test-unit: 14 | strategy: 15 | matrix: 16 | platform: [ubuntu-latest, macos-latest] 17 | runs-on: ${{ matrix.platform }} 18 | steps: 19 | - name: Install Dependencies (macOS) 20 | if: runner.os == 'macOS' 21 | run: brew install bash 22 | - name: Setup go 23 | uses: actions/setup-go@v2 24 | with: 25 | go-version: 1.24.x 26 | - name: Checkout code 27 | uses: actions/checkout@v2 28 | - run: go install gotest.tools/gotestsum@latest 29 | - name: Test unit 30 | env: 31 | LETS_CONFIG_DIR: .. 32 | run: gotestsum --format testname -- ./... -coverprofile=coverage.out 33 | 34 | test-bats: 35 | runs-on: ubuntu-latest 36 | steps: 37 | - name: Checkout code 38 | uses: actions/checkout@v2 39 | - name: Install Lets 40 | uses: lets-cli/lets-action@v1.1 41 | with: 42 | version: latest 43 | - name: Test bats 44 | run: timeout 120 lets test-bats 45 | 46 | lint: 47 | runs-on: ubuntu-latest 48 | steps: 49 | - name: Checkout 50 | uses: actions/checkout@v2 51 | - name: Install Lets 52 | uses: lets-cli/lets-action@v1.1 53 | with: 54 | version: latest 55 | - name: Run lint 56 | run: lets lint 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | .idea 17 | .vscode 18 | !.vscode/launch.json 19 | .history 20 | dist 21 | 22 | # lets binary 23 | lets 24 | lets-dev 25 | .lets 26 | lets.my.yaml 27 | _lets 28 | coverage.out 29 | node_modules 30 | TODO 31 | TODO.md 32 | 33 | .DS_Store 34 | __debug_bin* 35 | # Added by goreleaser init: 36 | dist/ 37 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | run: 2 | tests: false 3 | go: "1.23" 4 | 5 | linters: 6 | enable-all: true 7 | disable: 8 | - typecheck 9 | - gomoddirectives 10 | - containedctx 11 | - gochecknoglobals 12 | - goimports 13 | - funlen 14 | - godox 15 | - maligned 16 | - goerr113 17 | - exhaustivestruct 18 | - wrapcheck 19 | - prealloc # enable it sometimes 20 | - wsl 21 | - ifshort 22 | - unparam 23 | - cyclop 24 | - gocyclo 25 | - gocognit 26 | - tagliatelle 27 | - nestif 28 | - nlreturn 29 | - goprintffuncname 30 | - exhaustruct 31 | - wastedassign 32 | - nilnil 33 | - recvcheck 34 | - musttag 35 | - mnd 36 | - lll 37 | - gocritic 38 | - forcetypeassert 39 | - exhaustive 40 | - depguard 41 | - revive 42 | - gosec 43 | - copyloopvar 44 | 45 | linters-settings: 46 | lll: 47 | line-length: 120 48 | varnamelen: 49 | min-name-length: 1 50 | 51 | issues: 52 | exclude-rules: 53 | - path: _test\.go 54 | linters: 55 | - gomnd 56 | - path: set\.go 57 | linters: 58 | - typecheck 59 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | project_name: lets 3 | 4 | before: 5 | hooks: 6 | - go mod tidy 7 | 8 | release: 9 | prerelease: auto 10 | 11 | builds: 12 | - id: darwin-amd64 13 | main: . 14 | goos: 15 | - darwin 16 | goarch: 17 | - amd64 18 | env: 19 | - PKG_CONFIG_SYSROOT_DIR=/sysroot/macos/amd64 20 | - PKG_CONFIG_PATH=/sysroot/macos/amd64/usr/local/lib/pkgconfig 21 | - CC=o64-clang 22 | - CXX=o64-clang++ 23 | flags: 24 | - -mod=readonly 25 | ldflags: 26 | - -s -w -X main.version={{.Version}} 27 | - id: darwin-arm64 28 | main: . 29 | goos: 30 | - darwin 31 | goarch: 32 | - arm64 33 | env: 34 | - PKG_CONFIG_SYSROOT_DIR=/sysroot/macos/arm64 35 | - PKG_CONFIG_PATH=/sysroot/macos/arm64/usr/local/lib/pkgconfig 36 | - CC=oa64-clang 37 | - CXX=oa64-clang++ 38 | flags: 39 | - -mod=readonly 40 | ldflags: 41 | - -s -w -X main.version={{.Version}} 42 | - id: linux-amd64 43 | main: . 44 | goos: 45 | - linux 46 | goarch: 47 | - amd64 48 | env: 49 | - CC=x86_64-linux-gnu-gcc 50 | - CXX=x86_64-linux-gnu-g++ 51 | flags: 52 | - -mod=readonly 53 | ldflags: 54 | - -s -w -X main.version={{.Version}} 55 | 56 | archives: 57 | - formats: [tar.gz] 58 | name_template: >- 59 | {{ .ProjectName }}_ 60 | {{- title .Os }}_ 61 | {{- if eq .Arch "amd64" }}x86_64 62 | {{- else if eq .Arch "386" }}i386 63 | {{- else if eq .Arch "darwin" }}Darwin 64 | {{- else if eq .Arch "linux" }}Linux 65 | {{- else }}{{ .Arch }}{{ end }} 66 | 67 | brews: 68 | - name: lets 69 | description: "CLI task runner for productive developers - a better alternative to make" 70 | homepage: "https://lets-cli.org/" 71 | license: "MIT" 72 | repository: 73 | owner: lets-cli 74 | name: homebrew-tap 75 | token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" 76 | directory: Formula 77 | 78 | aurs: 79 | - name: lets-bin 80 | homepage: "https://lets-cli.org/" 81 | description: "CLI task runner for productive developers - a better alternative to make" 82 | license: "MIT" 83 | maintainers: 84 | - 'Kindritskiy Maksym ' 85 | contributors: 86 | - "Luis Martinez " 87 | private_key: '{{ .Env.AUR_GITHUB_TOKEN }}' 88 | git_url: 'ssh://aur@aur.archlinux.org/lets-bin.git' 89 | package: |- 90 | install -Dm755 "./lets-bin" "${pkgdir}/usr/bin/lets" 91 | commit_author: 92 | name: 'Github Action Bot' 93 | email: kindritskiy.m@gmail.com 94 | 95 | checksum: 96 | name_template: '{{ .ProjectName }}_checksums.txt' 97 | 98 | snapshot: 99 | version_template: "{{ .Tag }}-{{ .ShortCommit }}" 100 | 101 | changelog: 102 | sort: asc 103 | filters: 104 | exclude: 105 | - '^docs:' 106 | - '^test:' 107 | - Merge pull request 108 | - Merge branch 109 | -------------------------------------------------------------------------------- /.run/lets --version.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.run/lets print-env.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.run/lets.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch Package", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "${fileDirname}" 13 | }, 14 | { 15 | "name": "Run", 16 | "type": "go", 17 | "request": "launch", 18 | "mode": "auto", 19 | "program": "${workspaceRoot}", 20 | "args": [] 21 | } 22 | ] 23 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24-bookworm AS builder 2 | 3 | ENV GOPROXY=https://proxy.golang.org 4 | ENV CGO_ENABLED=1 5 | # disable all compiler errors 6 | ENV CGO_CFLAGS=-w 7 | 8 | WORKDIR /app 9 | 10 | RUN apt-get update && apt-get install -y \ 11 | git gcc \ 12 | zsh # for zsh completion tests 13 | 14 | RUN cd /tmp && \ 15 | git clone https://github.com/bats-core/bats-core && \ 16 | git clone https://github.com/bats-core/bats-support.git /bats/bats-support && \ 17 | git clone https://github.com/bats-core/bats-assert.git /bats/bats-assert && \ 18 | cd bats-core && \ 19 | ./install.sh /usr && \ 20 | echo Bats installed 21 | 22 | RUN go install gotest.tools/gotestsum@latest 23 | 24 | COPY go.mod . 25 | COPY go.sum . 26 | 27 | RUN go mod download 28 | 29 | FROM golangci/golangci-lint:v1.64.7-alpine AS linter 30 | 31 | RUN mkdir -p /.cache && chmod -R 777 /.cache 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-prensent Kindritskiy Max and other contributors lets-cli 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lets 2 | 3 | CLI task runner for productive developers 4 | 5 | `lets` takes the best from Makefile and bash and presents you a simple yet powerful tool for defining and running cli tasks and commands. 6 | 7 | Just describe your commands in `lets.yaml` and `lets` will do the rest. 8 | 9 | ## Docs 10 | 11 | **Docs** - [https://lets-cli.org](https://lets-cli.org) 12 | 13 | **Installation** - [https://lets-cli.org/docs/installation](https://lets-cli.org/docs/installation) 14 | 15 | **Changelog** - [https://lets-cli.org/docs/changelog](https://lets-cli.org/docs/changelog) 16 | -------------------------------------------------------------------------------- /checksum/checksum_test.go: -------------------------------------------------------------------------------- 1 | package checksum 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | 8 | "github.com/lets-cli/lets/test" 9 | ) 10 | 11 | // get filename without last 4 chars - to make tests more predictable. 12 | func getFilePrefix(filename string) string { 13 | return fmt.Sprintf("%s*", filename[:len(filename)-4]) 14 | } 15 | 16 | const expectChecksum = "56a89c168888554d9cafa50c2f37c249dde6e37d" 17 | 18 | func TestCalculateChecksumSimpleFilename(t *testing.T) { 19 | tempDir := os.TempDir() 20 | file1 := test.CreateTempFile(tempDir, "lets_checksum_test_1") 21 | file2 := test.CreateTempFile(tempDir, "lets_checksum_test_2") 22 | 23 | defer os.Remove(file1.Name()) 24 | defer os.Remove(file2.Name()) 25 | 26 | _, err := file1.Write([]byte("qwerty1")) 27 | if err != nil { 28 | t.Errorf("Can not write test file. Error: %s", err) 29 | } 30 | 31 | _, err = file2.Write([]byte("asdfg2")) 32 | if err != nil { 33 | t.Errorf("Can not write test file. Error: %s", err) 34 | } 35 | 36 | checksum, err := CalculateChecksum(tempDir, []string{ 37 | file1.Name(), 38 | file2.Name(), 39 | }) 40 | if err != nil { 41 | t.Errorf("Checksum is not correct. Error: %s", err) 42 | } 43 | 44 | if expectChecksum != checksum { 45 | t.Errorf("Checksum is not correct. Expect: %s, got: %s", expectChecksum, checksum) 46 | } 47 | } 48 | 49 | func TestCalculateChecksumGlobPattern(t *testing.T) { 50 | tempDir := os.TempDir() 51 | file1 := test.CreateTempFile(tempDir, "lets_checksum_test_1") 52 | file2 := test.CreateTempFile(tempDir, "lets_checksum_test_2") 53 | 54 | defer os.Remove(file1.Name()) 55 | defer os.Remove(file2.Name()) 56 | 57 | _, err := file1.Write([]byte("qwerty1")) 58 | if err != nil { 59 | t.Errorf("Can not write test file. Error: %s", err) 60 | } 61 | 62 | _, err = file2.Write([]byte("asdfg2")) 63 | if err != nil { 64 | t.Errorf("Can not write test file. Error: %s", err) 65 | } 66 | 67 | f1Prefix := getFilePrefix(file1.Name()) 68 | f2Prefix := getFilePrefix(file2.Name()) 69 | checksum, err := CalculateChecksum(tempDir, []string{ 70 | f1Prefix, 71 | f2Prefix, 72 | }) 73 | if err != nil { 74 | t.Errorf("Checksum is not correct. Error: %s", err) 75 | } 76 | 77 | if expectChecksum != checksum { 78 | t.Errorf("Checksum is not correct. Expect: %s, got: %s", expectChecksum, checksum) 79 | } 80 | } 81 | 82 | func TestCalculateChecksumFromListOrMap(t *testing.T) { 83 | tempDir := os.TempDir() 84 | file1 := test.CreateTempFile(tempDir, "lets_checksum_test_1") 85 | file2 := test.CreateTempFile(tempDir, "lets_checksum_test_2") 86 | 87 | defer os.Remove(file1.Name()) 88 | defer os.Remove(file2.Name()) 89 | 90 | _, err := file1.Write([]byte("qwerty1")) 91 | if err != nil { 92 | t.Errorf("Can not write test file. Error: %s", err) 93 | } 94 | 95 | _, err = file2.Write([]byte("asdfg2")) 96 | if err != nil { 97 | t.Errorf("Can not write test file. Error: %s", err) 98 | } 99 | 100 | // declare command with checksum as list 101 | f1Prefix := getFilePrefix(file1.Name()) 102 | f2Prefix := getFilePrefix(file2.Name()) 103 | checksumSources := map[string][]string{ 104 | DefaultChecksumKey: {f1Prefix, f2Prefix}, 105 | } 106 | 107 | checksumMap, err := CalculateChecksumFromSources(tempDir, checksumSources) 108 | if err != nil { 109 | t.Errorf("Checksum is not correct. Error: %s", err) 110 | } 111 | 112 | if checksumMap[DefaultChecksumKey] != expectChecksum { 113 | t.Errorf( 114 | "Checksum is not correct for command with checksum as list. Expect: %s, got: %s", 115 | expectChecksum, 116 | checksumMap[DefaultChecksumKey], 117 | ) 118 | } 119 | 120 | // declare command with checksum as map but with same files 121 | checksumSources1 := map[string][]string{ 122 | "misc": {f1Prefix, f2Prefix}, 123 | } 124 | 125 | checksumMap1, err := CalculateChecksumFromSources(tempDir, checksumSources1) 126 | 127 | if err != nil { 128 | t.Errorf("Checksum is not correct. Error: %s", err) 129 | } 130 | 131 | if checksumMap1["misc"] != expectChecksum { 132 | t.Errorf( 133 | "Checksum is not correct for command with checksum as map. Expect: %s, got: %s", 134 | expectChecksum, 135 | checksumMap1["misc"], 136 | ) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /cmd/lsp.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/lets-cli/lets/lsp" 5 | "github.com/pkg/errors" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | func initLspCommand(version string) *cobra.Command { 10 | lspCmd := &cobra.Command{ 11 | Use: "lsp", 12 | Short: "Language Server Protocol (LSP) server", 13 | RunE: func(cmd *cobra.Command, args []string) error { 14 | if err := lsp.Run(cmd.Context(), version); err != nil { 15 | return errors.Wrap(err, "lsp error") 16 | } 17 | return nil 18 | }, 19 | } 20 | 21 | return lspCmd 22 | } 23 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // newRootCmd represents the base command when called without any subcommands. 11 | func newRootCmd(version string) *cobra.Command { 12 | cmd := &cobra.Command{ 13 | Use: "lets", 14 | Short: "A CLI task runner", 15 | Args: cobra.ArbitraryArgs, 16 | RunE: func(cmd *cobra.Command, args []string) error { 17 | return PrintHelpMessage(cmd) 18 | }, 19 | TraverseChildren: true, 20 | FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, 21 | Version: version, 22 | // handle errors manually 23 | SilenceErrors: true, 24 | // print help message manyally 25 | SilenceUsage: true, 26 | } 27 | cmd.AddGroup(&cobra.Group{ID: "main", Title: "Commands:"}, &cobra.Group{ID: "internal", Title: "Internal commands:"}) 28 | cmd.SetHelpCommandGroupID("internal") 29 | return cmd 30 | } 31 | 32 | // CreateRootCommand used to run only root command without config. 33 | func CreateRootCommand(version string) *cobra.Command { 34 | rootCmd := newRootCmd(version) 35 | 36 | initRootFlags(rootCmd) 37 | 38 | return rootCmd 39 | } 40 | 41 | func initRootFlags(rootCmd *cobra.Command) { 42 | rootCmd.Flags().StringToStringP("env", "E", nil, "set env variable for running command KEY=VALUE") 43 | rootCmd.Flags().StringArray("only", []string{}, "run only specified command(s) described in cmd as map") 44 | rootCmd.Flags().StringArray("exclude", []string{}, "run all but excluded command(s) described in cmd as map") 45 | rootCmd.Flags().Bool("upgrade", false, "upgrade lets to latest version") 46 | rootCmd.Flags().Bool("init", false, "create a new lets.yaml in the current folder") 47 | rootCmd.Flags().Bool("no-depends", false, "skip 'depends' for running command") 48 | rootCmd.Flags().CountP("debug", "d", "show debug logs (or use LETS_DEBUG=1). If used multiple times, shows more verbose logs") //nolint:lll 49 | rootCmd.Flags().StringP("config", "c", "", "config file (default is lets.yaml)") 50 | rootCmd.Flags().Bool("all", false, "show all commands (including the ones with _)") 51 | } 52 | 53 | func PrintHelpMessage(cmd *cobra.Command) error { 54 | help := cmd.UsageString() 55 | help = fmt.Sprintf("%s\n\n%s", cmd.Short, help) 56 | help = strings.Replace(help, "lets [command] --help", "lets help [command]", 1) 57 | _, err := fmt.Fprint(cmd.OutOrStdout(), help) 58 | return err 59 | } 60 | 61 | func PrintVersionMessage(cmd *cobra.Command) error { 62 | _, err := fmt.Fprintf(cmd.OutOrStdout(), "lets version %s\n", cmd.Version) 63 | return err 64 | } 65 | -------------------------------------------------------------------------------- /cmd/root_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/lets-cli/lets/config/config" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func newTestRootCmd(args []string) (rootCmd *cobra.Command) { 12 | root := CreateRootCommand("v0.0.0-test") 13 | root.SetArgs(args) 14 | InitCompletionCmd(root, nil) 15 | 16 | return root 17 | } 18 | 19 | func newTestRootCmdWithConfig(args []string) (rootCmd *cobra.Command, out *bytes.Buffer) { 20 | bufOut := new(bytes.Buffer) 21 | 22 | cfg := &config.Config{ 23 | Commands: make(map[string]*config.Command), 24 | } 25 | cfg.Commands["foo"] = &config.Command{Name: "foo"} 26 | cfg.Commands["bar"] = &config.Command{Name: "bar"} 27 | 28 | root := CreateRootCommand("v0.0.0-test") 29 | root.SetArgs(args) 30 | root.SetOut(bufOut) 31 | root.SetErr(bufOut) 32 | 33 | InitCompletionCmd(root, cfg) 34 | InitSubCommands(root, cfg, true, out) 35 | 36 | return root, bufOut 37 | } 38 | 39 | func TestRootCmd(t *testing.T) { 40 | t.Run("should init completion subcommand", func(t *testing.T) { 41 | var args []string 42 | rootCmd := newTestRootCmd(args) 43 | 44 | expectedTotal := 1 // completion 45 | 46 | comp, _, _ := rootCmd.Find([]string{"completion"}) 47 | if comp.Name() != "completion" { 48 | t.Errorf("no '%s' subcommand in the root command", "completion") 49 | } 50 | totalCommands := len(rootCmd.Commands()) 51 | if totalCommands != expectedTotal { 52 | t.Errorf( 53 | "root cmd has different number of subcommands than expected. Exp: %d, Got: %d", 54 | expectedTotal, 55 | totalCommands, 56 | ) 57 | } 58 | }) 59 | } 60 | 61 | func TestRootCmdWithConfig(t *testing.T) { 62 | t.Run("should init sub commands", func(t *testing.T) { 63 | var args []string 64 | rootCmd, _ := newTestRootCmdWithConfig(args) 65 | 66 | expectedTotal := 3 // foo, bar, completion 67 | 68 | comp, _, _ := rootCmd.Find([]string{"completion"}) 69 | if comp.Name() != "completion" { 70 | t.Errorf("no '%s' subcommand in the root command", "completion") 71 | } 72 | totalCommands := len(rootCmd.Commands()) 73 | if totalCommands != expectedTotal { 74 | t.Errorf( 75 | "root cmd has different number of subcommands than expected. Exp: %d, Got: %d", 76 | expectedTotal, 77 | totalCommands, 78 | ) 79 | } 80 | }) 81 | } 82 | -------------------------------------------------------------------------------- /cmd/self.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | // InitSelfCmd intializes root 'self' subcommand. 8 | func InitSelfCmd(rootCmd *cobra.Command, version string) { 9 | selfCmd := &cobra.Command{ 10 | Use: "self", 11 | Hidden: false, 12 | Short: "Manage lets CLI itself", 13 | GroupID: "internal", 14 | RunE: func(cmd *cobra.Command, args []string) error { 15 | return PrintHelpMessage(cmd) 16 | }, 17 | } 18 | 19 | rootCmd.AddCommand(selfCmd) 20 | 21 | selfCmd.AddCommand(initLspCommand(version)) 22 | } 23 | -------------------------------------------------------------------------------- /config/config/checksum.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/lets-cli/lets/checksum" 5 | ) 6 | 7 | // Checksum type for all checksum uses (env, command.env, command,checksum). 8 | type Checksum map[string][]string 9 | 10 | // UnmarshalYAML implements yaml.Unmarshaler interface. 11 | func (c *Checksum) UnmarshalYAML(unmarshal func(interface{}) error) error { 12 | if *c == nil { 13 | *c = make(Checksum) 14 | } 15 | 16 | var patterns []string 17 | if err := unmarshal(&patterns); err == nil { 18 | (*c)[checksum.DefaultChecksumKey] = patterns 19 | 20 | return nil 21 | } 22 | 23 | var patternsMap map[string][]string 24 | if err := unmarshal(&patternsMap); err != nil { 25 | return err 26 | } 27 | 28 | for key, patterns := range patternsMap { 29 | (*c)[key] = patterns 30 | } 31 | 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /config/config/clone.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "cmp" 4 | 5 | func cloneSlice[I any](a []I) []I { 6 | if a == nil { 7 | return nil 8 | } 9 | 10 | arr := make([]I, len(a)) 11 | copy(arr, a) 12 | 13 | return arr 14 | } 15 | 16 | func cloneMap[K cmp.Ordered, V any](m map[K]V) map[K]V { 17 | if m == nil { 18 | return nil 19 | } 20 | 21 | mapping := make(map[K]V, len(m)) 22 | for k, v := range m { 23 | mapping[k] = v 24 | } 25 | 26 | return mapping 27 | } 28 | 29 | func cloneMapSlice[K cmp.Ordered, V []string](m map[K]V) map[K]V { 30 | if m == nil { 31 | return nil 32 | } 33 | 34 | mapping := make(map[K]V, len(m)) 35 | for k, v := range m { 36 | mapping[k] = cloneSlice(v) 37 | } 38 | 39 | return mapping 40 | } 41 | -------------------------------------------------------------------------------- /config/config/cmd.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type Cmds struct { 9 | Commands []*Cmd 10 | Append bool 11 | Parallel bool 12 | } 13 | 14 | type Cmd struct { 15 | Name string 16 | Script string // list will be joined 17 | } 18 | 19 | // A workaround function which helps to prevent breaking 20 | // strings with special symbols (' ', '*', '$', '#'...) 21 | // When you run a command with an argument containing one of these, you put it into quotation marks: 22 | // lets alembic -n dev revision --autogenerate -m "revision message" 23 | // which makes shell understand that "revision message" is a single argument, but not two args 24 | // The problem is, lets constructs a script string 25 | // and then passes it to an appropriate interpreter (sh -c $SCRIPT) 26 | // so we need to wrap args with quotation marks to prevent breaking 27 | // This also solves problem with json params: --key='{"value": 1}' => '--key={"value": 1}'. 28 | func escapeArgs(args []string) []string { 29 | var escapedArgs []string 30 | 31 | for _, arg := range args { 32 | // wraps every argument with quotation marks to avoid ambiguity 33 | // TODO: maybe use some kind of blacklist symbols to wrap only necessary args 34 | escapedArg := fmt.Sprintf("'%s'", arg) 35 | escapedArgs = append(escapedArgs, escapedArg) 36 | } 37 | 38 | return escapedArgs 39 | } 40 | 41 | // UnmarshalYAML implements the yaml.Unmarshaler interface. 42 | func (c *Cmds) UnmarshalYAML(unmarshal func(interface{}) error) error { 43 | var script string 44 | if err := unmarshal(&script); err == nil { 45 | c.Commands = []*Cmd{{Name: "", Script: script}} 46 | 47 | return nil 48 | } 49 | 50 | var cmdList []string 51 | if err := unmarshal(&cmdList); err == nil { 52 | script := strings.TrimSpace(strings.Join(cmdList, " ")) 53 | c.Commands = []*Cmd{{Name: "", Script: script}} 54 | c.Append = true 55 | 56 | return nil 57 | } 58 | 59 | var cmdMap map[string]string 60 | if err := unmarshal(&cmdMap); err == nil { 61 | for name, script := range cmdMap { 62 | c.Commands = append(c.Commands, &Cmd{Name: name, Script: script}) 63 | } 64 | c.Parallel = true 65 | 66 | return nil 67 | } 68 | 69 | return nil 70 | } 71 | 72 | func (c Cmds) Clone() Cmds { 73 | commands := make([]*Cmd, len(c.Commands)) 74 | 75 | for idx, cmd := range c.Commands { 76 | commands[idx] = &Cmd{ 77 | Name: cmd.Name, 78 | Script: cmd.Script, 79 | } 80 | } 81 | 82 | cmds := Cmds{ 83 | Commands: commands, 84 | Append: c.Append, 85 | Parallel: c.Parallel, 86 | } 87 | 88 | return cmds 89 | } 90 | 91 | // AppendArgs appends arguments to cmd script. 92 | func (c Cmds) AppendArgs(args []string) { 93 | if !c.Append { 94 | return 95 | } 96 | 97 | c.Commands[0].Script = fmt.Sprintf( 98 | "%s %s", 99 | c.Commands[0].Script, 100 | strings.Join(escapeArgs(args), " "), 101 | ) 102 | } 103 | -------------------------------------------------------------------------------- /config/config/cmd_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/lithammer/dedent" 10 | "gopkg.in/yaml.v3" 11 | ) 12 | 13 | // that's how shell does it. 14 | func simulateProcessShellArgs(inputCmdList []string) []string { 15 | var cmdList []string 16 | 17 | for _, arg := range inputCmdList { 18 | isEnquoted := len(arg) >= 2 && (arg[0] == '\'' && arg[len(arg)-1] == '\'') 19 | if isEnquoted { 20 | quoteless := arg[1 : len(arg)-1] 21 | cmdList = append(cmdList, quoteless) 22 | } else { 23 | cmdList = append(cmdList, arg) 24 | } 25 | } 26 | 27 | return cmdList 28 | } 29 | 30 | func CmdFixture(t *testing.T, text string, args []string) Cmds { 31 | buf := bytes.NewBufferString(text) 32 | var cmd struct { 33 | Cmd Cmds 34 | } 35 | os.Args = args 36 | if err := yaml.NewDecoder(buf).Decode(&cmd); err != nil { 37 | t.Fatalf("cmd fixture decode error: %s", err) 38 | } 39 | 40 | return cmd.Cmd 41 | } 42 | 43 | func TestCommandFieldCmd(t *testing.T) { 44 | t.Run("as string", func(t *testing.T) { 45 | cmd := CmdFixture(t, "cmd: echo Hello", []string{}) 46 | exp := "echo Hello" 47 | if cmd.Commands[0].Script != exp { 48 | t.Errorf("wrong output. \nexpect %s \ngot: %s", exp, cmd.Commands[0].Script) 49 | } 50 | }) 51 | 52 | t.Run("as list", func(t *testing.T) { 53 | args := []string{"/bin/lets", "hello", "World"} 54 | cmd := CmdFixture(t, "cmd: [echo, Hello]", args) 55 | exp := `echo Hello` 56 | if cmd.Commands[0].Script != exp { 57 | t.Errorf("wrong output. \nexpect %s \ngot: %s", exp, cmd.Commands[0].Script) 58 | } 59 | }) 60 | 61 | t.Run("as map", func(t *testing.T) { 62 | text := dedent.Dedent(` 63 | cmd: 64 | foo: echo Foo 65 | bar: echo Bar 66 | `) 67 | cmd := CmdFixture(t, text, []string{}) 68 | expFoo := "echo Foo" 69 | expBar := "echo Bar" 70 | if cmdLen := len(cmd.Commands); cmdLen != 2 { 71 | t.Errorf("expect %d commands\ngot: %d", 2, cmdLen) 72 | } 73 | 74 | for _, command := range cmd.Commands { 75 | switch command.Name { 76 | case "foo": 77 | if command.Script != expFoo { 78 | t.Errorf("wrong output. \nexpect %s \ngot: %s", expFoo, command.Script) 79 | } 80 | case "bar": 81 | if command.Script != expBar { 82 | t.Errorf("wrong output. \nexpect %s \ngot: %s", expBar, command.Script) 83 | } 84 | default: 85 | t.Fatalf("unexpected command %s", command.Name) 86 | } 87 | } 88 | }) 89 | } 90 | 91 | func TestEscapeArguments(t *testing.T) { 92 | t.Run("escape value if json", func(t *testing.T) { 93 | jsonArg := `--kwargs={"age": 20}` 94 | escaped := escapeArgs([]string{jsonArg})[0] 95 | exp := `'--kwargs={"age": 20}'` 96 | if escaped != exp { 97 | t.Errorf("wrong output. \nexpect: %s \ngot: %s", exp, escaped) 98 | } 99 | }) 100 | 101 | t.Run("escape string with whitespace", func(t *testing.T) { 102 | letsCmd := "lets commitCrime" 103 | appendArgs := "-m 'azaza lalka'" 104 | fullCommand := strings.Join([]string{letsCmd, appendArgs}, " ") 105 | 106 | cmdList := simulateProcessShellArgs(strings.Split(fullCommand, " ")) 107 | 108 | args := cmdList[2:] 109 | escapedArgs := escapeArgs(args) 110 | resultArgs := strings.Join(simulateProcessShellArgs(escapedArgs), " ") 111 | 112 | if resultArgs != appendArgs { 113 | t.Errorf("wrong output. \nexpect: %s \ngot: %s", appendArgs, resultArgs) 114 | } 115 | }) 116 | } 117 | -------------------------------------------------------------------------------- /config/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "bytes" 5 | "maps" 6 | "testing" 7 | 8 | "github.com/lithammer/dedent" 9 | "gopkg.in/yaml.v3" 10 | ) 11 | 12 | func ConfigFixture(t *testing.T, text string) *Config { 13 | buf := bytes.NewBufferString(text) 14 | c := NewConfig(".", ".", ".") 15 | if err := yaml.NewDecoder(buf).Decode(&c); err != nil { 16 | t.Fatalf("config fixture decode error: %s", err) 17 | } 18 | 19 | return c 20 | } 21 | 22 | func TestParseConfig(t *testing.T) { 23 | t.Run("append args to cmd as list", func(t *testing.T) { 24 | args := []string{"World", "--foo", `--bar='{"age": 20}'`} 25 | text := dedent.Dedent(` 26 | shell: bash 27 | commands: 28 | hello: 29 | cmd: [echo, Hello] 30 | `) 31 | cfg := ConfigFixture(t, text) 32 | cmd := cfg.Commands["hello"] 33 | cmd.Cmds.AppendArgs(args) 34 | 35 | exp := `echo Hello 'World' '--foo' '--bar='{"age": 20}''` 36 | if script := cmd.Cmds.Commands[0].Script; script != exp { 37 | t.Errorf("wrong output. \nexpect %s \ngot: %s", exp, script) 38 | } 39 | }) 40 | 41 | t.Run("parse env with alias", func(t *testing.T) { 42 | text := dedent.Dedent(` 43 | shell: bash 44 | 45 | x-default-env: &default-env 46 | HELLO: WORLD 47 | 48 | env: 49 | <<: *default-env 50 | FOO: BAR 51 | 52 | commands: 53 | hello: 54 | cmd: [echo, Hello] 55 | `) 56 | cfg := ConfigFixture(t, text) 57 | 58 | env := cfg.Env.Dump() 59 | expected := map[string]string{ 60 | "FOO": "BAR", 61 | "HELLO": "WORLD", 62 | } 63 | if !maps.Equal(env, expected) { 64 | t.Errorf("wrong output. \nexpect %s \ngot: %s", expected, env) 65 | } 66 | }) 67 | 68 | t.Run("invalid alias name - does not start with x-", func(t *testing.T) { 69 | text := dedent.Dedent(` 70 | shell: bash 71 | 72 | default-env: &default-env 73 | HELLO: WORLD 74 | 75 | env: 76 | <<: *default-env 77 | FOO: BAR 78 | 79 | commands: 80 | hello: 81 | cmd: [echo, Hello] 82 | `) 83 | 84 | buf := bytes.NewBufferString(text) 85 | c := NewConfig(".", ".", ".") 86 | err := yaml.NewDecoder(buf).Decode(&c) 87 | if err.Error() != "keyword 'default-env' not supported" { 88 | t.Errorf("config must not allow custom keywords") 89 | } 90 | }) 91 | } 92 | -------------------------------------------------------------------------------- /config/config/deps.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "slices" 6 | 7 | "gopkg.in/yaml.v3" 8 | ) 9 | 10 | type Dep struct { 11 | Name string 12 | Args []string 13 | Env *Envs 14 | } 15 | 16 | type Deps struct { 17 | Keys []string 18 | Mapping map[string]Dep 19 | } 20 | 21 | // UnmarshalYAML implements the yaml.Unmarshaler interface. 22 | func (d *Deps) UnmarshalYAML(node *yaml.Node) error { 23 | if node.Kind != yaml.SequenceNode { 24 | return errors.New("lets: 'depends' must be a sequence") 25 | } 26 | 27 | for i := range len(node.Content) { 28 | node := node.Content[i] 29 | 30 | var dep Dep 31 | if err := node.Decode(&dep); err != nil { 32 | return err 33 | } 34 | d.Set(dep.Name, dep) 35 | } 36 | 37 | return nil 38 | } 39 | 40 | func (d *Deps) Clone() *Deps { 41 | if d == nil { 42 | return nil 43 | } 44 | 45 | mapping := make(map[string]Dep, len(d.Mapping)) 46 | for k, v := range d.Mapping { 47 | mapping[k] = v.Clone() 48 | } 49 | 50 | return &Deps{ 51 | Keys: cloneSlice(d.Keys), 52 | Mapping: mapping, 53 | } 54 | } 55 | 56 | // Range allows you to loop into the Deps in its right order. 57 | func (d *Deps) Range(yield func(key string, value Dep) error) error { 58 | if d == nil { 59 | return nil 60 | } 61 | 62 | for _, k := range d.Keys { 63 | if err := yield(k, d.Mapping[k]); err != nil { 64 | return err 65 | } 66 | } 67 | return nil 68 | } 69 | 70 | // Set sets a value to a given key. 71 | func (d *Deps) Set(key string, value Dep) { 72 | if d.Mapping == nil { 73 | d.Mapping = make(map[string]Dep, 1) 74 | } 75 | if !slices.Contains(d.Keys, key) { 76 | d.Keys = append(d.Keys, key) 77 | } 78 | d.Mapping[key] = value 79 | } 80 | 81 | // Get get a value by a given key. 82 | func (d *Deps) Get(key string) *Dep { 83 | if d == nil || d.Mapping == nil { 84 | return nil 85 | } 86 | 87 | dep, ok := d.Mapping[key] 88 | if !ok { 89 | return nil 90 | } 91 | 92 | return &dep 93 | } 94 | 95 | // Has checks if a value exists by a given key. 96 | func (d *Deps) Has(key string) bool { 97 | if d == nil || d.Mapping == nil { 98 | return false 99 | } 100 | 101 | _, ok := d.Mapping[key] 102 | return ok 103 | } 104 | 105 | // UnmarshalYAML implements yaml.Unmarshaler interface. 106 | func (d *Dep) UnmarshalYAML(unmarshal func(interface{}) error) error { 107 | var cmdName string 108 | if err := unmarshal(&cmdName); err == nil { 109 | d.Name = cmdName 110 | return nil 111 | } 112 | 113 | var cmd struct { 114 | Name string 115 | Env *Envs 116 | } 117 | 118 | if err := unmarshal(&cmd); err != nil { 119 | return err 120 | } 121 | 122 | d.Name = cmd.Name 123 | d.Env = cmd.Env 124 | 125 | var cmdArgsStr struct { 126 | Args string 127 | } 128 | 129 | if err := unmarshal(&cmdArgsStr); err == nil { 130 | if cmdArgsStr.Args != "" { 131 | d.Args = []string{cmdArgsStr.Args} 132 | } 133 | return nil 134 | } 135 | 136 | var cmdArgs struct { 137 | Args []string 138 | } 139 | 140 | if err := unmarshal(&cmdArgs); err != nil { 141 | return err 142 | } 143 | 144 | d.Args = cmdArgs.Args 145 | 146 | return nil 147 | } 148 | 149 | func (d Dep) Clone() Dep { 150 | return Dep{ 151 | Name: d.Name, 152 | Args: cloneSlice(d.Args), 153 | Env: d.Env.Clone(), 154 | } 155 | } 156 | 157 | func (d Dep) HasArgs() bool { 158 | return len(d.Args) > 0 159 | } 160 | -------------------------------------------------------------------------------- /config/config/mixin.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "encoding/hex" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | "time" 14 | 15 | "github.com/lets-cli/lets/util" 16 | ) 17 | 18 | type Mixins []*Mixin 19 | 20 | type Mixin struct { 21 | FileName string 22 | // e.g. .gitignored 23 | Ignored bool 24 | Remote *RemoteMixin 25 | } 26 | 27 | type RemoteMixin struct { 28 | URL string 29 | Version string 30 | 31 | mixinsDir string 32 | } 33 | 34 | // Filename is name of mixin file (hash from url). 35 | func (rm *RemoteMixin) Filename() string { 36 | hasher := sha256.New() 37 | hasher.Write([]byte(rm.URL)) 38 | 39 | if rm.Version != "" { 40 | hasher.Write([]byte(rm.Version)) 41 | } 42 | 43 | return hex.EncodeToString(hasher.Sum(nil)) 44 | } 45 | 46 | // Path is abs path to mixin file (.lets/mixins/). 47 | func (rm *RemoteMixin) Path() string { 48 | return filepath.Join(rm.mixinsDir, rm.Filename()) 49 | } 50 | 51 | func (rm *RemoteMixin) persist(data []byte) error { 52 | f, err := os.OpenFile(rm.Path(), os.O_CREATE|os.O_WRONLY, 0o755) //nolint:nosnakecase 53 | if err != nil { 54 | return fmt.Errorf("can not open file %s to persist mixin: %w", rm.Path(), err) 55 | } 56 | 57 | _, err = f.Write(data) 58 | if err != nil { 59 | return fmt.Errorf("can not write mixin to file %s: %w", rm.Path(), err) 60 | } 61 | 62 | return nil 63 | } 64 | 65 | func (rm *RemoteMixin) exists() bool { 66 | return util.FileExists(rm.Path()) 67 | } 68 | 69 | func (rm *RemoteMixin) tryRead() ([]byte, error) { 70 | if !rm.exists() { 71 | return nil, nil 72 | } 73 | data, err := os.ReadFile(rm.Path()) 74 | if err != nil { 75 | return nil, fmt.Errorf("can not read mixin config file at %s: %w", rm.Path(), err) 76 | } 77 | 78 | return data, nil 79 | } 80 | 81 | func (rm *RemoteMixin) download() ([]byte, error) { 82 | // TODO: maybe create a client for this? 83 | ctx, cancel := context.WithTimeout(context.Background(), 60*5*time.Second) 84 | defer cancel() 85 | 86 | req, err := http.NewRequestWithContext( 87 | ctx, 88 | http.MethodGet, 89 | rm.URL, 90 | nil, 91 | ) 92 | if err != nil { 93 | return nil, err 94 | } 95 | 96 | client := &http.Client{ 97 | Timeout: 15 * 60 * time.Second, // TODO: move to client struct 98 | } 99 | 100 | resp, err := client.Do(req) 101 | if err != nil { 102 | return nil, fmt.Errorf("failed to make request: %w", err) 103 | } 104 | 105 | defer resp.Body.Close() 106 | 107 | if resp.StatusCode == http.StatusNotFound { 108 | return nil, fmt.Errorf("no such file at: %s", rm.URL) 109 | } else if resp.StatusCode < 200 || resp.StatusCode > 299 { 110 | return nil, fmt.Errorf("network error: %s", resp.Status) 111 | } 112 | 113 | data, err := io.ReadAll(resp.Body) 114 | if err != nil { 115 | return nil, fmt.Errorf("failed to read response: %w", err) 116 | } 117 | 118 | return data, nil 119 | } 120 | 121 | // Trim `-` prefix. 122 | // Using this prefix we allow to include non-existed mixins (git-ignored for example). 123 | func normalizeMixinFilename(filename string) string { 124 | return strings.TrimPrefix(filename, "-") 125 | } 126 | 127 | // Ignored means that it is okay if minix does not exist. 128 | // It can be a git-ignored file for example. 129 | func isIgnoredMixin(filename string) bool { 130 | return strings.HasPrefix(filename, "-") 131 | } 132 | 133 | func (m *Mixin) UnmarshalYAML(unmarshal func(interface{}) error) error { 134 | var filename string 135 | if err := unmarshal(&filename); err == nil { 136 | m.FileName = normalizeMixinFilename(filename) 137 | m.Ignored = isIgnoredMixin(filename) 138 | return nil 139 | } 140 | 141 | var remote struct { 142 | URL string 143 | Version string 144 | } 145 | 146 | if err := unmarshal(&remote); err != nil { 147 | return err 148 | } 149 | 150 | m.Remote = &RemoteMixin{ 151 | // TODO check if url is valid 152 | URL: remote.URL, 153 | Version: remote.Version, 154 | } 155 | 156 | return nil 157 | } 158 | 159 | func (m *Mixin) IsRemote() bool { 160 | return m.Remote != nil 161 | } 162 | -------------------------------------------------------------------------------- /config/config/ref.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/kballard/go-shellquote" 7 | ) 8 | 9 | type ref struct { 10 | Name string 11 | Args []string 12 | } 13 | 14 | type refArgs []string 15 | 16 | // UnmarshalYAML implements yaml.Unmarshaler interface. 17 | func (a *refArgs) UnmarshalYAML(unmarshal func(interface{}) error) error { 18 | if *a == nil { 19 | *a = make(refArgs, 0) 20 | } 21 | 22 | var arg string 23 | if err := unmarshal(&arg); err == nil { 24 | args, err := shellquote.Split(arg) 25 | if err != nil { 26 | return errors.New("can not parse args into list") 27 | } 28 | 29 | *a = append(*a, args...) 30 | 31 | return nil 32 | } 33 | 34 | var args []string 35 | if err := unmarshal(&args); err != nil { 36 | return err 37 | } 38 | 39 | *a = append(*a, args...) 40 | 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /config/config/version.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/lets-cli/lets/util" 7 | ) 8 | 9 | type Version string 10 | 11 | // UnmarshalYAML implements yaml.Unmarshaler interface. 12 | func (v *Version) UnmarshalYAML(unmarshal func(interface{}) error) error { 13 | var ver string 14 | if err := unmarshal(&ver); err != nil { 15 | return errors.New("version must be a valid semver string") 16 | } 17 | 18 | _, err := util.ParseVersion(ver) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | *v = Version(ver) 24 | 25 | return nil 26 | } 27 | -------------------------------------------------------------------------------- /config/find.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | 7 | "github.com/fatih/color" 8 | "github.com/lets-cli/lets/config/path" 9 | "github.com/lets-cli/lets/util" 10 | "github.com/lets-cli/lets/workdir" 11 | log "github.com/sirupsen/logrus" 12 | ) 13 | 14 | const defaultConfigFile = "lets.yaml" 15 | 16 | type PathInfo struct { 17 | Filename string 18 | AbsPath string 19 | WorkDir string 20 | // .lets abs path 21 | DotLetsDir string 22 | } 23 | 24 | // FindConfig will try to find best match for config file. 25 | // Rules are: 26 | // - if specified configName - try to load only that file 27 | // - if specified configDir - try to look for a config only in that dir - don't do recursion 28 | // - if not specified any of params above - try to find config recursively. 29 | func FindConfig(configName string, configDir string) (PathInfo, error) { 30 | configDirSpecifiedByUser := configDir != "" 31 | 32 | if configName == "" { 33 | configName = defaultConfigFile 34 | } 35 | 36 | // work dir is where to start looking for lets.yaml 37 | workDir, err := getWorkDir(configName, configDir) 38 | if err != nil { 39 | return PathInfo{}, err 40 | } 41 | 42 | log.Debugf("%s", color.BlueString("lets: found %s config file in %s directory", configName, workDir)) 43 | 44 | configAbsPath := "" 45 | 46 | // if user specified full path to config file 47 | if filepath.IsAbs(configName) { //nolint:nestif 48 | configAbsPath = configName 49 | } else { 50 | if configDirSpecifiedByUser { 51 | configAbsPath, err = path.GetFullConfigPath(configName, workDir) 52 | if err != nil { 53 | return PathInfo{}, err 54 | } 55 | } else { 56 | // try to find abs config path up in parent dir tree 57 | configAbsPath, err = path.GetFullConfigPathRecursive(configName, workDir) 58 | if err != nil { 59 | return PathInfo{}, err 60 | } 61 | } 62 | } 63 | 64 | // just to be sure that work dir is correct 65 | workDir = filepath.Dir(configAbsPath) 66 | 67 | dotLetsDir, err := workdir.GetDotLetsDir(workDir) 68 | if err != nil { 69 | return PathInfo{}, fmt.Errorf("can not get .lets absolute path: %w", err) 70 | } 71 | 72 | if err := util.SafeCreateDir(dotLetsDir); err != nil { 73 | return PathInfo{}, fmt.Errorf("can not create .lets dir: %w", err) 74 | } 75 | 76 | pathInfo := PathInfo{ 77 | AbsPath: configAbsPath, 78 | WorkDir: workDir, 79 | Filename: configName, 80 | DotLetsDir: dotLetsDir, 81 | } 82 | 83 | return pathInfo, nil 84 | } 85 | -------------------------------------------------------------------------------- /config/find_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestFindConfig(t *testing.T) { 8 | t.Run("just find config", func(t *testing.T) { 9 | _, err := FindConfig("", "") 10 | if err != nil { 11 | t.Errorf("can not find test config: %s", err) 12 | } 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /config/load.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/lets-cli/lets/config/config" 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | func Load(configName string, configDir string, version string) (*config.Config, error) { 12 | configPath, err := FindConfig(configName, configDir) 13 | if err != nil { 14 | return nil, err 15 | } 16 | f, err := os.Open(configPath.AbsPath) 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | c := config.NewConfig( 22 | configPath.WorkDir, 23 | configPath.AbsPath, 24 | configPath.DotLetsDir, 25 | ) 26 | if err := yaml.NewDecoder(f).Decode(c); err != nil { 27 | return nil, fmt.Errorf("failed to parse %s: %w", configPath.Filename, err) 28 | } 29 | 30 | if err = validate(c, version); err != nil { 31 | return nil, err 32 | } 33 | 34 | if err := c.SetupEnv(); err != nil { 35 | return nil, err 36 | } 37 | 38 | return c, nil 39 | } 40 | -------------------------------------------------------------------------------- /config/load_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestLoadConfig(t *testing.T) { 8 | t.Run("just load config", func(t *testing.T) { 9 | _, err := Load("", "", "0.0.0-test") 10 | if err != nil { 11 | t.Errorf("can not load test config: %s", err) 12 | } 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /config/path/path.go: -------------------------------------------------------------------------------- 1 | package path 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "path/filepath" 7 | 8 | "github.com/lets-cli/lets/util" 9 | ) 10 | 11 | var ErrFileNotExists = errors.New("file not exists") 12 | 13 | // find config file non-recursively 14 | // filename is a file to find and work dir is where to start. 15 | func GetFullConfigPath(filename string, workDir string) (string, error) { 16 | fileAbsPath, err := filepath.Abs(filepath.Join(workDir, filename)) 17 | if err != nil { 18 | return "", fmt.Errorf("can not get absolute workdir path: %w", err) 19 | } 20 | 21 | if !util.FileExists(fileAbsPath) { 22 | return "", fmt.Errorf("%w: %s", ErrFileNotExists, fileAbsPath) 23 | } 24 | 25 | return fileAbsPath, nil 26 | } 27 | 28 | // find config file recursively 29 | // filename is a file to find and work dir is where to start. 30 | func GetFullConfigPathRecursive(filename string, workDir string) (string, error) { 31 | fileAbsPath, err := filepath.Abs(filepath.Join(workDir, filename)) 32 | if err != nil { 33 | return "", fmt.Errorf("can not get absolute workdir path: %w", err) 34 | } 35 | 36 | if util.FileExists(fileAbsPath) { 37 | return fileAbsPath, nil 38 | } 39 | 40 | // else we get parent and try again up until we reach roof of fs 41 | parentDir := filepath.Dir(workDir) 42 | if parentDir == "/" { 43 | return "", fmt.Errorf("file does not exist: %s", filename) 44 | } 45 | 46 | return GetFullConfigPathRecursive(filename, parentDir) 47 | } 48 | -------------------------------------------------------------------------------- /config/validate.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/lets-cli/lets/config/config" 8 | "github.com/lets-cli/lets/util" 9 | ) 10 | 11 | // Validate loaded config. 12 | func validate(config *config.Config, letsVersion string) error { 13 | if err := validateVersion(config, letsVersion); err != nil { 14 | return err 15 | } 16 | 17 | if err := validateDepends(config); err != nil { 18 | return err 19 | } 20 | 21 | if len(config.Commands) == 0 { 22 | return errors.New("'commands' can not be empty") 23 | } 24 | 25 | return nil 26 | } 27 | 28 | func validateVersion(cfg *config.Config, letsVersion string) error { 29 | // no version specified on config 30 | if cfg.Version == "" { 31 | return nil 32 | } 33 | 34 | cfgVersionParsed, err := util.ParseVersion(cfg.Version) 35 | if err != nil { 36 | return fmt.Errorf("failed to parse config version: %w", err) 37 | } 38 | 39 | letsVersionParsed, err := util.ParseVersion(letsVersion) 40 | if err != nil { 41 | return fmt.Errorf("failed to parse lets version: %w", err) 42 | } 43 | 44 | isDev := letsVersionParsed.PreRelease == "dev" 45 | if letsVersionParsed.LessThan(*cfgVersionParsed) && !isDev { 46 | return fmt.Errorf( 47 | "config version '%s' is not compatible with 'lets' version '%s'. "+ 48 | "Please upgrade 'lets' to '%s' "+ 49 | "using 'lets --upgrade' command or following documentation at https://lets-cli.org/docs/installation'", 50 | cfgVersionParsed, 51 | letsVersionParsed, 52 | cfgVersionParsed, 53 | ) 54 | } 55 | 56 | return nil 57 | } 58 | 59 | func validateDepends(cfg *config.Config) error { 60 | for _, cmd := range cfg.Commands { 61 | cmd := cmd 62 | err := cmd.Depends.Range(func(key string, value config.Dep) error { 63 | dependency, exists := cfg.Commands[key] 64 | 65 | if !exists { 66 | return fmt.Errorf( 67 | "command '%s' depends on command '%s' which is not exist", 68 | cmd.Name, key, 69 | ) 70 | } 71 | 72 | if dependency.Cmds.Parallel { 73 | return fmt.Errorf( 74 | "command '%s' depends on command '%s', but parallel cmd is not allowed in depends yet", 75 | cmd.Name, dependency.Name, 76 | ) 77 | } 78 | 79 | return nil 80 | }) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | for _, other := range cfg.Commands { 86 | if cmd.Name == other.Name { 87 | continue 88 | } 89 | 90 | // if any two commands have each other command in deps, raise error. 91 | if cmd.Depends.Has(other.Name) && other.Depends.Has(cmd.Name) { 92 | return fmt.Errorf( 93 | "command '%s' have circular depends on command '%s'", 94 | cmd.Name, other.Name, 95 | ) 96 | } 97 | } 98 | } 99 | 100 | return nil 101 | } 102 | -------------------------------------------------------------------------------- /config/validate_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/lets-cli/lets/config/config" 7 | ) 8 | 9 | func TestValidateCommandInDependsExists(t *testing.T) { 10 | t.Run("command depends on non-existing command", func(t *testing.T) { 11 | testCfg := &config.Config{ 12 | Commands: make(map[string]*config.Command), 13 | } 14 | deps := &config.Deps{} 15 | deps.Set("bar", config.Dep{Name: "bar"}) 16 | testCfg.Commands["foo"] = &config.Command{ 17 | Name: "foo", 18 | Depends: deps, 19 | } 20 | err := validateDepends(testCfg) 21 | if err == nil { 22 | t.Error("command foo depends on non-existing command bar. Must fail") 23 | } 24 | }) 25 | } 26 | 27 | func TestValidateCircularDeps(t *testing.T) { 28 | t.Run("command skip itself", func(t *testing.T) { 29 | testCfg := &config.Config{ 30 | Commands: make(map[string]*config.Command), 31 | } 32 | depsA := &config.Deps{} 33 | testCfg.Commands["a"] = &config.Command{ 34 | Name: "a", 35 | Depends: depsA, 36 | } 37 | 38 | depsB := &config.Deps{} 39 | testCfg.Commands["b"] = &config.Command{ 40 | Name: "b", 41 | Depends: depsB, 42 | } 43 | 44 | err := validateDepends(testCfg) 45 | if err != nil { 46 | t.Errorf("checked itself when validation circular depends. got: %s", err) 47 | } 48 | }) 49 | 50 | t.Run("command with similar name should not fail validation", func(t *testing.T) { 51 | testCfg := &config.Config{ 52 | Commands: make(map[string]*config.Command), 53 | } 54 | depsA := &config.Deps{} 55 | depsA.Set("b1", config.Dep{Name: "b1"}) 56 | testCfg.Commands["a"] = &config.Command{ 57 | Name: "a", 58 | Depends: depsA, 59 | } 60 | 61 | depsB := &config.Deps{} 62 | depsB.Set("a", config.Dep{Name: "a"}) 63 | testCfg.Commands["b"] = &config.Command{ 64 | Name: "b", 65 | Depends: depsB, 66 | } 67 | 68 | depsB1 := &config.Deps{} 69 | testCfg.Commands["b1"] = &config.Command{ 70 | Name: "b1", 71 | Depends: depsB1, 72 | } 73 | 74 | err := validateDepends(testCfg) 75 | if err != nil { 76 | t.Errorf("checked itself when validation circular depends. got: %s", err) 77 | } 78 | }) 79 | 80 | t.Run("validation should fail", func(t *testing.T) { 81 | testCfg := &config.Config{ 82 | Commands: make(map[string]*config.Command), 83 | } 84 | depsA := &config.Deps{} 85 | depsA.Set("b", config.Dep{Name: "b"}) 86 | testCfg.Commands["a"] = &config.Command{ 87 | Name: "a", 88 | Depends: depsA, 89 | } 90 | 91 | depsB := &config.Deps{} 92 | depsB.Set("a", config.Dep{Name: "a"}) 93 | testCfg.Commands["b"] = &config.Command{ 94 | Name: "b", 95 | Depends: depsB, 96 | } 97 | 98 | err := validateDepends(testCfg) 99 | 100 | if err == nil { 101 | t.Errorf("validation should fail. got: %s", err) 102 | } 103 | }) 104 | } 105 | -------------------------------------------------------------------------------- /config/workdir.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | // workDir is where lets.yaml found or rootDir points to. 9 | func getWorkDir(filename string, rootDir string) (string, error) { 10 | workDir, err := os.Getwd() 11 | if err != nil { 12 | return "", fmt.Errorf("failed to get workdir for config %s: %w", filename, err) 13 | } 14 | 15 | if rootDir != "" { 16 | workDir = rootDir 17 | } 18 | 19 | return workDir, nil 20 | } 21 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | base: &base 3 | image: lets 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | working_dir: /app 8 | volumes: 9 | - ./:/app 10 | 11 | lint: 12 | image: lets-lint 13 | working_dir: /app 14 | user: ${CURRENT_UID} 15 | volumes: 16 | - ./:/app 17 | entrypoint: golangci-lint run -v -c .golangci.yaml --fix 18 | 19 | test: 20 | <<: *base 21 | environment: 22 | LETS_CONFIG_DIR: .. 23 | command: gotestsum --format testname -- ./... -coverprofile=coverage.out 24 | 25 | test-bats: 26 | <<: *base 27 | environment: 28 | NO_COLOR: 1 29 | BATS_UTILS_PATH: /bats 30 | command: 31 | - bash 32 | - -c 33 | - | 34 | go build -o /usr/bin/lets *.go 35 | if [[ -n "${LETSOPT_TEST}" ]]; then 36 | bats tests/"${LETSOPT_TEST}" ${LETSOPT_OPTS} 37 | else 38 | bats tests ${LETSOPT_OPTS} 39 | fi 40 | -------------------------------------------------------------------------------- /docopt/docopts.go: -------------------------------------------------------------------------------- 1 | package docopt 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | dopt "github.com/docopt/docopt-go" 9 | ) 10 | 11 | // aliases for docopt types. 12 | type ( 13 | Opts = dopt.Opts 14 | Option = dopt.Option 15 | ) 16 | 17 | var docoptParser = &dopt.Parser{ 18 | HelpHandler: dopt.NoHelpHandler, 19 | OptionsFirst: false, 20 | SkipHelpFlags: false, 21 | } 22 | 23 | // Parse parses docopts for command options with args from os.Args. 24 | func Parse(cmdName string, args []string, docopts string) (Opts, error) { 25 | // no options at all 26 | if docopts == "" { 27 | return Opts{}, nil 28 | } 29 | 30 | return docoptParser.ParseArgs(docopts, append([]string{cmdName}, args...), "") 31 | } 32 | 33 | // ParseOptions parses docopts only to get all available options for a command. 34 | func ParseOptions(docopts string, cmdName string) ([]Option, error) { 35 | return docoptParser.ParseOptions(docopts, []string{cmdName}) 36 | } 37 | 38 | func OptsToLetsOpt(opts Opts) map[string]string { 39 | envMap := make(map[string]string, len(opts)) 40 | 41 | for origKey, value := range opts { 42 | if !isOptKey(origKey) { 43 | continue 44 | } 45 | key := normalizeKey(origKey) 46 | envKey := "LETSOPT_" + key 47 | 48 | var strValue string 49 | switch value := value.(type) { 50 | case string: 51 | strValue = value 52 | case bool: 53 | if value { 54 | strValue = strconv.FormatBool(value) 55 | } else { 56 | strValue = "" 57 | } 58 | case []string: 59 | strValue = strings.Join(value, " ") 60 | case nil: 61 | strValue = "" 62 | default: 63 | strValue = "" 64 | } 65 | 66 | envMap[envKey] = strValue 67 | } 68 | 69 | return envMap 70 | } 71 | 72 | func OptsToLetsCli(opts Opts) map[string]string { 73 | cliMap := make(map[string]string, len(opts)) 74 | formatVal := func(k, v string) string { 75 | return fmt.Sprintf("%s %s", k, v) 76 | } 77 | 78 | for origKey, value := range opts { 79 | if !isOptKey(origKey) { 80 | continue 81 | } 82 | 83 | key := normalizeKey(origKey) 84 | cliKey := "LETSCLI_" + key 85 | 86 | var strValue string 87 | 88 | switch value := value.(type) { 89 | case string: 90 | if value != "" { 91 | strValue = formatVal(origKey, value) 92 | } 93 | case bool: 94 | if value { 95 | strValue = origKey 96 | } 97 | case []string: 98 | if len(value) == 0 { 99 | strValue = "" 100 | } else { 101 | values := value 102 | if strings.HasPrefix(origKey, "-") { 103 | values = append([]string{origKey}, values...) 104 | } 105 | // TODO maybe we should escape each value 106 | strValue = strings.Join(values, " ") 107 | } 108 | case nil: 109 | strValue = "" 110 | default: 111 | strValue = "" 112 | } 113 | 114 | cliMap[cliKey] = strValue 115 | } 116 | 117 | return cliMap 118 | } 119 | 120 | func isOptKey(key string) bool { 121 | if key == "--" { 122 | return false 123 | } 124 | 125 | if strings.HasPrefix(key, "--") { 126 | return true 127 | } 128 | 129 | if strings.HasPrefix(key, "-") { 130 | return true 131 | } 132 | 133 | if strings.HasPrefix(key, "<") && strings.HasSuffix(key, ">") { 134 | return true 135 | } 136 | 137 | return false 138 | } 139 | 140 | func normalizeKey(origKey string) string { 141 | key := strings.TrimLeft(origKey, "-") 142 | key = strings.TrimLeft(key, "<") 143 | key = strings.TrimRight(key, ">") 144 | key = strings.ReplaceAll(key, "-", "_") 145 | key = strings.ToUpper(key) 146 | 147 | return key 148 | } 149 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Updating website docs 2 | 3 | -------------------------------------------------------------------------------- /docs/blog/2020-05-24-history-of-lets.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: history_of_lets 3 | title: History of lets 4 | author: Kindritskiy Max 5 | author_title: Lets core developer 6 | author_url: https://github.com/kindermax 7 | author_image_url: https://avatars3.githubusercontent.com/u/10552804?v=4 8 | tags: [lets] 9 | --- 10 | 11 | History of Lets. 12 | 13 | -------------------------------------------------------------------------------- /docs/docs/architecture.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: architecture 3 | title: Architecture 4 | --- 5 | 6 | ![Architecture diagram](/img/lets-architecture-diagram.png) 7 | 8 | ## Parser 9 | 10 | At the start of lets application, parser tries to find `lets.yaml` file starting from current directory up to the `/`. 11 | 12 | When config file is found, parser tries to read/parse and validate yaml config. 13 | 14 | ### How parsing works ? 15 | 16 | `config.go:Config` struct implements `UnmarshalYAML` function, so when `yaml.Unmarshal` called with `Config` instance passed in, 17 | custom unmarshalling code is executed. 18 | 19 | Its common to make some normalization of commands and its data during parsing phase so the rest of the code 20 | does not have to do any kind of normalization on its own. 21 | 22 | To add a new field you probably must implement `UnmarshalYAML` somehow. 23 | 24 | #### Mixins 25 | Lets has feature called [mixins](config.md#mixins). When parser meets `mixins` directive, 26 | it basically repeats all read/parse logic on minix files. 27 | 28 | Since mixin config files have some limitations, although they are parsed the same way, validation is a bit different. 29 | 30 | ### Validation 31 | 32 | There are two validation phases. 33 | 34 | First validation phase happens during unmarshalling and checks if: 35 | - directives names valid 36 | - directives types valid (array, map, string, number, etc.) 37 | - references to command in `depends` directive points to existing commands 38 | 39 | Second phase happens after we ensured that config is syntactically and semantically correct. 40 | 41 | Int the second phase we are checking: 42 | - config version 43 | - circular dependencies in commands 44 | 45 | ### Env 46 | TODO 47 | 48 | ## Cobra CLI Framework 49 | 50 | We are using `Cobra` CLI framework and delegating to it most of the work related to parsing 51 | command line arguments, help messages etc. 52 | 53 | ### Binding our config with Cobra 54 | 55 | Now we have to bind our config to `Cobra`. 56 | 57 | Cobra has a concept of `cobra.Command`. It is a representation of command in CLI application, for example: 58 | 59 | ```bash 60 | git commit 61 | git pull 62 | ``` 63 | 64 | `git` is a CLI applications and 65 | `commit` and `pull` are commands. 66 | 67 | In a traditional `lets` application commands will be what is declared in `lets.yaml` commands section. 68 | 69 | To achieve this we are creating so-called `root` command and `subcommands` from config. 70 | 71 | #### Root command 72 | 73 | Root command is responsible for: 74 | - `lets` own command line flags such as `--version`, `--upgrade`, `--help` and so on. 75 | - `lets` commands autocompletion in terminal 76 | 77 | #### Subcommands 78 | 79 | Subcommand is created from our `Config.Commands` (see `initSubCommands` function). 80 | 81 | In subcommand's `RunE` callback we are parsing/validation/normalizing command line arguments for this subcommand 82 | and then finally executing command with `Executor`. 83 | 84 | Since we are using `docopt` as an argument parser for subcommands, we don't let `Cobra` parse and interpret args, 85 | and instead we are passing raw arguments as is to `Executor`. 86 | 87 | ## Executor 88 | 89 | `Executor` is responsible for: 90 | 91 | - parsing and preparing args using `docopt` 92 | - calculating and storing command's checksums 93 | - executing other commands from `depends` section 94 | - preparing environment 95 | - running command in OS using `exec.Command` 96 | -------------------------------------------------------------------------------- /docs/docs/basic_usage.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: basic_usage 3 | title: Basic usage 4 | --- 5 | 6 | We will start with a simple example here. More advanced usage you will find in the [Advanced section](advanced_usage.md). 7 | 8 | Assume you have a `node.js` project. 9 | 10 | ### Create config 11 | 12 | Go to your project repo and create `lets.yaml` by running `lets --init`. 13 | 14 | Now add `.lets` to `.gitignore`. `.lets` is a lets directory where it stores some internal metadata. You do not need to commit this directory. 15 | 16 | ### Write first command 17 | 18 | First of all you want to be able to run your project. 19 | 20 | You have your `package.json` with all dependencies and scripts in it. 21 | 22 | Lets create first command: 23 | 24 | ```yaml 25 | shell: bash 26 | 27 | commands: 28 | run: 29 | description: Run nodejs server 30 | cmd: npm run server 31 | ``` 32 | 33 | That's it. You've just created your first `lets` command. 34 | 35 | Run `lets` in terminal to see all available commands. 36 | 37 | ### Run first command 38 | 39 | Now you can use this command to start your server. 40 | 41 | **`lets run`** 42 | -------------------------------------------------------------------------------- /docs/docs/best_practices.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: best_practices 3 | title: Best practices 4 | --- 5 | 6 | ### Naming conventions 7 | 8 | Prefer single word over plural. 9 | 10 | It is better to leverage semantics of `lets` as an intention to do something. For example it is natural saying `lets test` or `lets build` something. 11 | 12 | `bad` 13 | 14 | ``` 15 | lets runs 16 | ``` 17 | 18 | `good` 19 | 20 | ``` 21 | lets run 22 | ``` 23 | 24 | --- 25 | 26 | `bad` 27 | 28 | ``` 29 | lets tests 30 | ``` 31 | 32 | `good` 33 | 34 | ``` 35 | lets test 36 | ``` 37 | 38 | ### Default commands 39 | 40 | If you have many projects (lets say - microservices) - it would be great to have one way to run and operate them when developing 41 | 42 | - `run` command - the main purpouse of this command is to run all in once. If all projects has this command its easier to remember. 43 | - `test` command - each projects should have a tests and a way to run them, either one file or all tests at once 44 | - `init` command - some kind of project initialization - creates missing files, dirs for developer, checks permissions, login to docker registry, checks inotify limits for tools such as webpack and other file watchers. 45 | 46 | ### Split `lets.yaml` when it becomes big. 47 | 48 | If `lets.yaml` became big, it may be great to split it in a smaller, more specific files using `mixins` directive. 49 | For example: 50 | 51 | - **`lets.yaml`** 52 | - **`lets.test.yaml`** 53 | - **`lets.build.yaml`** 54 | - **`lets.frontend.yaml`** 55 | - **`lets.i18n.yaml`** 56 | 57 | In each of these files we then hold all specific tasks. 58 | 59 | ### Use checskums 60 | 61 | Checksums can help you decrease amount of task executions. How ? Lets see. 62 | 63 | Suppose we have `js` project and we obviously holding all dependencies in `package.json`. 64 | Also we are using Docker for reproducible development environment. 65 | 66 | Dockerfile 67 | 68 | ```bash 69 | FROM alpine:3.8 70 | 71 | WORKDIR /work 72 | 73 | COPY package.json . 74 | 75 | RUN npm install 76 | 77 | CMD ["npm start"] 78 | ``` 79 | 80 | What if we want to rebuild docker image every time we changed dependency ? 81 | 82 | lets.yaml 83 | 84 | ``` 85 | shell: bash 86 | 87 | commands: 88 | run: 89 | depends: 90 | - build 91 | cmd: docker-compose up application 92 | 93 | build: 94 | checksum: 95 | - package.json 96 | persist_checksum: true 97 | cmd: | 98 | if [[ ${LETS_CHECKSUM_CHANGED} == true ]]; then 99 | docker-compose build application 100 | else 101 | Image is up to date 102 | fi 103 | ``` 104 | 105 | As you can see, we execute `build` command each time we execute `run` command (`depends`). 106 | 107 | `persist_checksum` will save calculated checksum to `.lets` directory and all subsequent calls of `build` command will 108 | read checksum from disk, calculate new checksum, and compare them. If `package.json` will change - we will rebuild the image. 109 | 110 | 111 | ### Initialize project using `init` 112 | 113 | You can use `init` keyword to write a script that will do some initialization on lets startup, like creating some dirs, configs or installing project dependencies. 114 | 115 | By default, `init` runs each time the `lets` program is executed. 116 | 117 | You can make `init` conditional, by simply creating a file and checking if it exists at the start of `init` script. 118 | 119 | Example: 120 | 121 | ``` 122 | shell: bash 123 | 124 | init: | 125 | if [[ ! -f .lets/init_done ]]; then 126 | echo "calling init script" 127 | touch .lets/init_done 128 | fi 129 | ``` 130 | 131 | In this example we are checking for `.lets/init_done` file existence. If it does not exist, we will call init script and create `init_done` file as a marker of successfull init script invocation. 132 | 133 | We are using `.lets` dir here because this dir will be created by `lets` itself and is generally a good place to create such files, but you are free to create files with any name and in any directory you want. 134 | -------------------------------------------------------------------------------- /docs/docs/cli.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: cli 3 | title: CLI options 4 | --- 5 | 6 | ### Global options 7 | 8 | |Option|Type|Default|Description| 9 | |------|:--:|:-----:|-----------| 10 | |`-E, --env`|`stringToString`||set env variable for running command KEY=VALUE (default [])| 11 | |`--all`|`bool`|false|show all commands (include hidden commands which start with `_`)| 12 | |`--init`|`bool`|false|creates a new lets.yaml in the current folder| 13 | |`--only`|`stringArray`||run only specified command(s) described in cmd as map| 14 | |`--exclude`|`stringArray`||run all but excluded command(s) described in cmd as map| 15 | |`--upgrade`|`bool`|false|upgrade lets to latest version| 16 | |`--no-depends`|`bool`|false|skip 'depends' for running command| 17 | |`-c, --config`|`string`|lets.yaml|specify config| 18 | |`-d, --debug`|`bool`|false|verbose logs| 19 | |`-h, --help`|||help for lets| 20 | |`-v, --version`|||version for lets| 21 | -------------------------------------------------------------------------------- /docs/docs/completion.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: completion 3 | title: Shell completion 4 | --- 5 | 6 | You can use Bash/Zsh/Oh-My-Zsh completion in you terminal 7 | 8 | * **Bash** 9 | 10 | Add `source <(lets completion -s bash)` to your `~/.bashrc` or `~/.bash_profile` 11 | 12 | * **Zsh/Oh-My-Zsh** 13 | 14 | There is a [repo](https://github.com/lets-cli/lets-zsh-plugin) with zsh plugin with instructions 15 | 16 | -------------------------------------------------------------------------------- /docs/docs/contribute.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: contribute 3 | title: Contribute 4 | --- 5 | 6 | 7 | Contributions are always welcome. 8 | 9 | Just go and checkout [issues](https://github.com/lets-cli/lets/issues). Surely you will find some good first issue. :) 10 | -------------------------------------------------------------------------------- /docs/docs/development.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: development 3 | title: Development 4 | --- 5 | 6 | ## Build 7 | 8 | We are suggesting to use `lets-dev` name for development binary, so you could 9 | have stable `lets` version untouched. 10 | 11 | To build a binary: 12 | 13 | ```bash 14 | go build -o lets-dev *.go 15 | ``` 16 | 17 | To install in system 18 | 19 | ```bash 20 | go build -o lets-dev *.go && sudo mv ./lets-dev /usr/local/bin/lets-dev 21 | ``` 22 | 23 | Or if you already have `lets` installed in your system: 24 | 25 | ```bash 26 | lets build-and-install [--path=] 27 | ``` 28 | `path` - your custom executable $PATH, defaults to `/usr/local/bin` 29 | 30 | After install - check version of lets - `lets-dev --version` - it should be development 31 | 32 | It will install `lets-dev` to /usr/local/bin/lets-dev, or wherever you`ve specified in path, and set version to development with current tag and timestamp 33 | 34 | ## Test 35 | 36 | To run all tests: 37 | 38 | ```bash 39 | lets test 40 | ``` 41 | 42 | To run unit tests: 43 | 44 | ```bash 45 | lets test-unit 46 | ``` 47 | 48 | To get coverage: 49 | 50 | ```bash 51 | lets coverage 52 | ``` 53 | 54 | To test `lets` output we are using `bats` - bash automated testing: 55 | 56 | ```bash 57 | lets test-bats 58 | 59 | # or run one test 60 | 61 | lets test-bats global_env.bats 62 | ``` 63 | 64 | ## Release 65 | 66 | To release a new version: 67 | 68 | ```bash 69 | lets release 0.0.1 -m "implement some new feature" 70 | ``` 71 | 72 | This will create an annotated tag with 0.0.1 and run `git push --tags` 73 | 74 | ### Prerelease 75 | 76 | If you are not ready to release a new version yet, it is possible to create a prerelease version. 77 | 78 | Prerelease version is no visible to `install.sh` script and you can be sure that no one will get this version accidentiall. 79 | 80 | Also you do not need to revoke published version if it has some critical bugs. 81 | 82 | To create a prerelease version you need to append a `-rcN` suffix to next version, for example: 83 | 84 | ```bash 85 | lets release 0.0.1-rc1 -m "pre: implement some new feature" 86 | ``` 87 | 88 | This will create a `0.0.1-rc1` tag and push it to github. Github will create a prerelease version and build all the binaries. 89 | 90 | Once you are ready to release a new version, just create a normal release: 91 | 92 | ```bash 93 | lets release 0.0.1 -m "implement some new feature" 94 | ``` 95 | 96 | ## Versioning 97 | 98 | `lets` releases must be backward compatible. That means every new `lets` release must work with old configs. 99 | 100 | For situations like e.g. new functionality, there is a `version` in `lets.yaml` which specifies **minimum required** `lets` version. 101 | 102 | If `lets` version installed on the user machine is less than the one specified in config it will show and error and ask the user to upgrade `lets` version. 103 | -------------------------------------------------------------------------------- /docs/docs/env.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: env 3 | title: Environment 4 | --- 5 | 6 | ### Default environment variables 7 | 8 | `lets` has builtin environ variables which user can override before lets execution. E.g `LETS_DEBUG=1 lets test` 9 | 10 | * `LETS_DEBUG` - enable debug messages 11 | * `LETS_CONFIG` - changes default `lets.yaml` file path (e.g. LETS_CONFIG=lets.my.yaml) 12 | * `LETS_CONFIG_DIR` - changes path to dir where `lets.yaml` file placed 13 | * `NO_COLOR` - disables colored output. See https://no-color.org/ 14 | 15 | ### Environment variables available at command runtime 16 | 17 | * `LETS_COMMAND_NAME` - string name of launched command 18 | * `LETS_COMMAND_ARGS` - positional arguments for launched command, e.g. for `lets run --debug --config=test.ini` it will contain `--debug --config=test.ini` 19 | * `LETS_COMMAND_WORK_DIR` - absolute path to `work_dir` specified in command. 20 | * `LETS_CONFIG` - absolute path to lets config file. 21 | * `LETS_CONFIG_DIR` - absolute path to lets config file firectory. 22 | * `LETS_SHELL` - shell from config or command. 23 | * `LETSOPT_<>` - options parsed from command `options` (docopt string). E.g `lets run --env=prod --reload` will be `LETSOPT_ENV=prod` and `LETSOPT_RELOAD=true` 24 | * `LETSCLI_<>` - options which values is a options usage. E.g `lets run --env=prod --reload` will be `LETSCLI_ENV=--env=prod` and `LETSCLI_RELOAD=--reload` 25 | 26 | ### Override command env with -E flag 27 | 28 | You can override environment for command with `-E` flag: 29 | 30 | ```yaml 31 | shell: bash 32 | 33 | commands: 34 | say: 35 | env: 36 | NAME: Rick 37 | cmd: echo Hello ${NAME} 38 | ``` 39 | 40 | **`lets say`** - prints `Hello Rick` 41 | 42 | **`lets -E NAME=Morty say`** - prints `Hello Morty` 43 | 44 | Alternatively: 45 | 46 | **`lets --env NAME=Morty say`** - prints `Hello Morty` 47 | -------------------------------------------------------------------------------- /docs/docs/example_js.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: example_js 3 | title: Example for JavaScript/Node.js 4 | --- 5 | 6 | **`lets.yaml`** 7 | 8 | ```yaml 9 | shell: bash 10 | 11 | commands: 12 | run: 13 | description: Run node server 14 | cmd: npm run server 15 | 16 | webpack: 17 | description: Run webpack 18 | cmd: 19 | - npm 20 | - run 21 | - webpack 22 | 23 | tests: 24 | cmd: 25 | - npm 26 | - run 27 | - test 28 | ``` 29 | 30 | 31 | Examples of usage: 32 | 33 | - `lets run` - run server 34 | - `lets webpack -w` - cmd is an array so all arguments will be appended to that array 35 | - `lets test` - run all tests 36 | - `lets test src/server/__tests__` - run only tests in particular directory -------------------------------------------------------------------------------- /docs/docs/examples.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: examples 3 | title: Examples 4 | --- 5 | 6 | ## What are these examples ? 7 | 8 | While there is no such difference which project and which language is used with `lets`, in general we belive it would give a better understanding on how to do things with `lets` right if examples will be suited for different languages and tools. 9 | 10 | 11 | You always can find more examples on [project's github page](https://github.com/lets-cli/lets/tree/master/examples). -------------------------------------------------------------------------------- /docs/docs/ide_support.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: ide_support 3 | title: IDE/Text editors support 4 | --- 5 | 6 | ### Jet Brains plugin (official) 7 | 8 | Provides autocomplete and filetype support. 9 | 10 | - [Plugin site](https://plugins.jetbrains.com/plugin/14639-lets) 11 | - [Plugin repo](https://github.com/lets-cli/intellij-lets) 12 | 13 | ### VSCode plugin (official) 14 | 15 | Provides autocomplete and filetype support. 16 | 17 | - [Plugin site](https://marketplace.visualstudio.com/items?itemName=kindritskyimax.vscode-lets) 18 | - [Plugin repo](https://github.com/lets-cli/vscode-lets) 19 | 20 | ### Emacs plugin (community) 21 | 22 | Provides autocomplete and filetype support. 23 | 24 | - [Plugin repo](https://github.com/mpanarin/lets-mode) 25 | 26 | ### LSP 27 | 28 | `LSP` stands for `Language Server Protocol` 29 | 30 | Starting from `0.0.55` version lets comes with builtin `lsp` server under `lets self lsp` command. 31 | 32 | Lsp support includes: 33 | 34 | - [x] Goto definition 35 | - Navigate to definitions of mixins files 36 | - Navigate to definitions of command from `depends` 37 | - [x] Completion 38 | - Complete commands in depends 39 | - [ ] Diagnostics 40 | - [ ] Hover 41 | - [ ] Formatting 42 | - [ ] Signature help 43 | - [ ] Code action 44 | 45 | `lsp` server works with JSON Schema (see bellow). 46 | 47 | #### VSCode 48 | 49 | [VSCode plugin](#vscode-plugin-official) supports lsp out of the box, just make sure you have lets >= `0.0.55`. 50 | 51 | #### Neovim 52 | 53 | Neovim support for `lets self lsp` can be added manually: 54 | 55 | 1. Add new filetype: 56 | 57 | ```lua 58 | vim.filetype.add({ 59 | filename = { 60 | ["lets.yaml"] = "yaml.lets", 61 | }, 62 | }) 63 | ``` 64 | 65 | 2. In your `neovim/nvim-lspconfig` servers configuration: 66 | 67 | In order for `nvim-lspconfig` to recognize `lets lsp` we must define config for `lets_ls` (lets_ls is just a conventional name because we are not officially added to `neovim/nvim-lspconfig`) 68 | 69 | ```lua 70 | require("lspconfig.configs").lets_ls = { 71 | default_config = { 72 | cmd = { 73 | "lets self lsp", 74 | }, 75 | filetypes = { "yaml.lets" }, 76 | root_dir = util.root_pattern("lets.yaml"), 77 | settings = {}, 78 | }, 79 | } 80 | ``` 81 | 82 | 3. And then enable lets_ls in then servers section: 83 | 84 | ```lua 85 | return { 86 | "neovim/nvim-lspconfig", 87 | opts = { 88 | servers = { 89 | lets_ls = {}, 90 | pyright = {}, -- pyright here just as hint to where we should add lets_ls 91 | }, 92 | }, 93 | } 94 | ``` 95 | 96 | ### JSON Schema 97 | 98 | In order to get autocomplete and filetype support in any editor, you can use the JSON schema file provided by Lets. 99 | 100 | #### VSCode 101 | 102 | To use the JSON schema in VSCode, you can use the [YAML extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml). 103 | 104 | Add the following to your `settings.json`: 105 | 106 | ```json 107 | { 108 | "yaml.schemas": { 109 | "https://lets-cli.org/schema.json": [ 110 | "**/lets.yaml", 111 | "**/lets*.yaml", 112 | ] 113 | } 114 | } 115 | ``` 116 | 117 | #### Neovim 118 | 119 | To use the JSON schema in Neovim, you can use the `nvim-lspconfig` with `SchemaStore` plugin. 120 | 121 | In your `nvim-lspconfig` configuration, add the following: 122 | 123 | ```lua 124 | servers = { 125 | yamlls = { 126 | on_new_config = function(new_config) 127 | local yaml_schemas = require("schemastore").yaml.schemas({ 128 | extra = { 129 | { 130 | description = "Lets JSON schema", 131 | fileMatch = { "lets.yaml", "lets*.yaml" }, 132 | name = "lets.schema.json", 133 | url = "https://lets-cli.org/schema.json", 134 | }, 135 | }, 136 | }) 137 | new_config.settings.yaml.schemas = vim.tbl_deep_extend("force", new_config.settings.yaml.schemas or {}, yaml_schemas) 138 | end, 139 | }, 140 | } 141 | ``` 142 | 143 | -------------------------------------------------------------------------------- /docs/docs/installation.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | id: installation 3 | title: Installation 4 | sidebar_label: Installation 5 | --- 6 | 7 | 8 | ### Binary 9 | 10 | import Tabs from '@theme/Tabs'; 11 | import TabItem from '@theme/TabItem'; 12 | 13 | 20 | 21 | 22 | ```bash 23 | curl --proto '=https' --tlsv1.2 -sSf https://lets-cli.org/install.sh | sh -s -- -b ~/bin 24 | ``` 25 | 26 | This will install **latest** `lets` binary to `~/bin` directory. 27 | 28 | To be able to run `lets` from any place in system add `$HOME/bin` to your `PATH` 29 | 30 | Open one of these files 31 | 32 | ```bash 33 | vim ~/.profile # or vim ~/.bashrc or ~/.zshrc 34 | ``` 35 | 36 | Add the following line at the end of file, save file and restart the shell. 37 | 38 | ```bash 39 | export PATH=$PATH:$HOME/bin 40 | ``` 41 | 42 | You can change install location to any directory you want, probably to directory that is in your $PATH 43 | 44 | To install a specific version of `lets` (for example `v0.0.21`): 45 | 46 | ```bash 47 | curl --proto '=https' --tlsv1.2 -sSf https://lets-cli.org/install.sh | sh -s -- v0.0.21 48 | ``` 49 | 50 | To use `lets` globally in system you may want to install `lets` to `/usr/local/bin` 51 | 52 | > May require `sudo` 53 | 54 | ```bash 55 | curl --proto '=https' --tlsv1.2 -sSf https://lets-cli.org/install.sh | sh -s -- -b /usr/local/bin 56 | ``` 57 | 58 | 59 | 60 | 61 | Download the version you need for your platform from [Lets Releases](https://github.com/lets-cli/lets/releases). 62 | 63 | Once downloaded, the binary can be run from anywhere. 64 | 65 | Ideally, you should install it somewhere in your PATH for easy use. `/usr/local/bin` is the most probable location. 66 | 67 | 68 | 69 | 70 | 71 | ### Package managers 72 | 73 | 81 | 82 | 83 | You can get binary release from https://aur.archlinux.org/packages/lets-bin/ 84 | 85 | If you are using `yay` as AUR helper: 86 | 87 | ```bash 88 | yay -S lets-bin 89 | ``` 90 | 91 | Also you can get bleeding edge version from https://aur.archlinux.org/packages/lets-git/ 92 | 93 | ```bash 94 | yay -S lets-git 95 | ``` 96 | 97 | 98 | 99 | 100 | TODO 101 | 102 | 103 | 104 | 105 | ```bash 106 | brew tap lets-cli/tap 107 | ``` 108 | 109 | ```bash 110 | brew install lets-cli/tap/lets 111 | ``` 112 | 113 | 114 | 115 | 116 | ## Update 117 | 118 | 127 | 128 | 129 | Starting from version `0.0.30` lets has `--upgrade` flag which allows to do self-upgrades. 130 | 131 | It updates binary located at `which lets` 132 | 133 | 134 | ```bash 135 | lets --upgrade 136 | ``` 137 | 138 | If your `lets` version is below `0.0.30` - use shell script and specify the latest version. 139 | 140 | 141 | 142 | 143 | To update `lets` you can use shell script 144 | 145 | ``` 146 | curl --proto '=https' --tlsv1.2 -sSf https://lets-cli.org/install.sh | sh -s -- -b $(dirname $(which lets)) 147 | ``` 148 | 149 | Running script will update `lets` to **latest** version. 150 | 151 | 152 | 153 | 154 | You can download latest version from [Lets Releases](https://github.com/lets-cli/lets/releases). 155 | 156 | 157 | 158 | 159 | AUR repository always provides **latest** version. 160 | 161 | 162 | 163 | -------------------------------------------------------------------------------- /docs/docs/quick_start.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: quick_start 3 | title: Getting started with Lets 4 | sidebar_label: Quick start 5 | --- 6 | 7 | If you already have `lets.yaml` then just go to that directory and run `lets` to see all available commands. 8 | 9 | If you are starting from scratch and want to create a new `lets.yaml`, please, take a look at [Creating new config](#creating-new-config) section. 10 | 11 | ### Config lookup 12 | 13 | `lets` will be looking for a config starting from where you call `lets` and up to the `/`. 14 | 15 | For example: 16 | 17 | ```bash 18 | cd /home/me 19 | touch lets.yaml 20 | 21 | mkdir ./project 22 | cd ./project 23 | 24 | lets # it will use lets.yaml at /home/me/lets.yaml 25 | 26 | touch lets.yaml 27 | 28 | lets # it will use lets.yaml right here (at /home/me/project/lets.yaml) 29 | ``` 30 | 31 | ## Creating new config 32 | 33 | 1. Create `lets.yaml` file in your project directory 34 | 2. Add commands to `lets.yaml` config. [Config reference](config.md) 35 | 36 | ```yaml 37 | shell: /bin/sh 38 | 39 | commands: 40 | echo: 41 | description: Echo text 42 | cmd: | 43 | echo "Hello" 44 | echo "World" 45 | 46 | run: 47 | description: Run some command 48 | options: | 49 | Usage: lets run [--debug] [--level=] 50 | Options: 51 | --debug, -d Run with debug 52 | --level= Log level 53 | cmd: env 54 | ``` 55 | 56 | 3. Run commands 57 | 58 | ```bash 59 | lets echo # will print 60 | # Hello 61 | ``` 62 | 63 | ```bash 64 | lets run --debug --level=info # will print 65 | # LETSOPT_DEBUG=true 66 | # LETSOPT_LEVEL=info 67 | # LETSCLI_DEBUG=--debug 68 | # LETSCLI_LEVEL=--level info 69 | 70 | ``` 71 | 72 | ## Lets directory 73 | 74 | At first run `lets` will create `.lets` directory in the current directory. 75 | 76 | `lets` uses `.lets` to store some specific data such as checksums, etc. 77 | 78 | It's better to add `.lets` to your `.gitignore` end exclude it in your favorite ide. 79 | -------------------------------------------------------------------------------- /docs/docs/what_is_lets.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: what_is_lets 3 | title: What is lets ? 4 | sidebar_label: What is lets ? 5 | slug: / 6 | --- 7 | 8 | ### Introduction 9 | 10 | `Lets` is a task runner. 11 | 12 | You can think of it as a tool with a config where you can write tasks. 13 | 14 | The task is usually your set of cli commands which you want to group together and gave it a name. 15 | 16 | For example, if you want to run tests in your project you may need to run next commands: 17 | 18 | 19 | ```bash 20 | # spinup a database for tests 21 | docker-compose up postgres 22 | # apply database migrations 23 | docker-compose run --rm sql alembic upgrdade head 24 | # run some tets 25 | docker-compose run --rm app pytest -x "test_login" 26 | ``` 27 | 28 | This all can be represented in one task - for example `lets test` 29 | 30 | ```yaml 31 | command: 32 | test: 33 | description: Run integration tests 34 | cmd: | 35 | docker-compose up postgres 36 | docker-compose run --rm sql alembic upgrdade head 37 | docker-compose run --rm app pytest -x "test_login" 38 | ``` 39 | 40 | And execute - `lets test`. Now everyone in you team knows how to run tests. 41 | 42 | ### Why yet another task runner ? 43 | 44 | So is there are any of such tools out there ? 45 | 46 | Well, sure there are some. 47 | 48 | Many developers know such a tool called `make`. 49 | 50 | So why not `make` ? 51 | 52 | `make` is more like a build tool and was not intended to be used as a task runner (but usually used because of the lack of alternatives or because it is install on basicaly every developer's machine). 53 | 54 | `make` has some sort of things which are bad/hard/no convinient for developers which use task runners on a daily basis. 55 | 56 | Lets is a brand new task runner with a task-centric philosophy and created specifically to meet developers needs. 57 | 58 | ### Features 59 | 60 | - `yaml config` - human-readable, recognizable and convenient format for such configs (also used by kubernetes, ansible, and many others) 61 | - `arguments parsing` - using http://docopt.org 62 | - `global and per/command env` 63 | - `global and per/command dynamic env` - can be computed at runtime 64 | - `checksum` - a feature which helps to track file changes 65 | - `written in Go` - which means it is easy to read, write and test as well as contributing to project 66 | 67 | To see all features, [check out config documentation](config.md) -------------------------------------------------------------------------------- /docs/docusaurus.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | title: 'Lets', 3 | tagline: 'CLI task runner for developers - a better alternative to make', 4 | url: 'https://lets-cli.org', 5 | baseUrl: '/', 6 | favicon: 'img/favicon.ico', 7 | organizationName: 'lets-cli', // Usually your GitHub org/user name. 8 | projectName: 'lets', // Usually your repo name. 9 | themeConfig: { 10 | prism: { 11 | theme: require('prism-react-renderer/themes/vsDark'), 12 | }, 13 | navbar: { 14 | title: 'Lets', 15 | logo: { 16 | alt: 'Lets Logo', 17 | src: 'img/logo.png', 18 | }, 19 | items: [ 20 | {to: 'docs/quick_start', label: 'Docs', position: 'right'}, 21 | {to: 'docs/changelog', label: 'Changelog', position: 'right'}, 22 | {to: 'blog', label: 'Blog', position: 'right'}, 23 | { 24 | href: 'https://github.com/lets-cli/lets', 25 | label: 'GitHub', 26 | position: 'right', 27 | }, 28 | ], 29 | }, 30 | footer: { 31 | style: 'dark', 32 | links: [ 33 | { 34 | title: 'Docs', 35 | items: [ 36 | { 37 | label: 'Quick start', 38 | to: 'docs/quick_start', 39 | }, 40 | ], 41 | }, 42 | { 43 | title: 'Community', 44 | items: [ 45 | { 46 | label: 'Stack Overflow', 47 | href: 'https://stackoverflow.com/questions/tagged/lets-cli', 48 | }, 49 | ], 50 | }, 51 | { 52 | title: 'More', 53 | items: [ 54 | { 55 | label: 'Blog', 56 | to: 'blog', 57 | }, 58 | { 59 | label: 'GitHub', 60 | href: 'https://github.com/facebook/docusaurus', 61 | }, 62 | ], 63 | }, 64 | ], 65 | copyright: `Copyright © ${new Date().getFullYear()} Lets, Inc. Built with Docusaurus.`, 66 | }, 67 | algolia: { 68 | appId: "B314NWJQO4", 69 | apiKey: "3103c243857b4a1debe49df0c8206704", 70 | indexName: "lets-cli", 71 | } 72 | }, 73 | presets: [ 74 | [ 75 | '@docusaurus/preset-classic', 76 | { 77 | docs: { 78 | sidebarPath: require.resolve('./sidebars.js'), 79 | editUrl: 80 | 'https://github.com/lets-cli/lets/edit/master/docs/', 81 | }, 82 | theme: { 83 | customCss: require.resolve('./src/css/custom.css'), 84 | }, 85 | gtag: { 86 | trackingID: 'G-DLCLPWY8PL', 87 | anonymizeIP: true, 88 | }, 89 | }, 90 | ], 91 | ], 92 | }; 93 | -------------------------------------------------------------------------------- /docs/lets-architecture-diagram.drawio: -------------------------------------------------------------------------------- 1 | 5VnRcqM2FP0aP64HhC3gsXWStjPZ6W7TmXYfZXQBNQIxQsT2fn2FEWAZnPFOSHGmfkisoysQ59zjY/DC22T7XyQp0s+CAl8gh+4X3t0CIdcN1vpfjRwaBHu4ARLJqCnqgSf2HQzoGLRiFEqrUAnBFStsMBJ5DpGyMCKl2NllseD2WQuSwAB4iggfon8xqtIGDZDf478CS9L2zC4Om5mMtMXmSsqUULE7gbz7hbeRQqjmXbbfAK/Ja3lp1j1cmO02JiFX1yz4CiHJ8oDv3eyzj8TD96/p9pNRp1SH9oKB6us3QyFVKhKRE37foz9LUeUU6qM6etTXPApRaNDV4D+g1MGISSolNJSqjJvZ5pz1iS5eioFKUckIXtl/2xJEJqBeqVt1hOtOBZGBkge9TgInir3Y+yCmZZKurlv6RTC9Q+SY9kah0fbQqu8ssXPyQvYRm22ag/RS6Tcnu+qho4A/IKbZ/wvhlbmijchjljTcliAHYvdS1rrsUqbgqSBHxnfaz7ZsMeN8I7iQx7UeJRDEkcZLJcUznMzgKIBt3An9AlLB/nWph9K0C1Y2xYFmuAF2vR3d1mPpiRWxc1lOi/kfpdmfwzOaLnn426w/Dr7Vg+W6Hd7tTyfvDmY0odfQlV67IOjVXnuTOGjgAQ6qXB6IZvJctroxmf6ofyRb4F9EyRQTuZ7aCqVEZvPf1v7EWVLXqFo30/ptMNR1xMxHml5tOK11pTjLtT3afKpVoaRM+24Yq9BpUdTbzPZJHaxLsis9utQWrLuJlEVTGbN9fZihBSne4jUemjaOYxRF01gT28708RKhgTW9MWcGS99/J/3D/6k5V1eaM5jTnKvLAaUxRokSHzGjULi6tYxyV/P6oG/9b1bnv7sPgmtDyhmX9D9KKXcOeWKRKzPpoploD2el3ftI6XADcjWBPpdcwUhcbCXR0ObxN/33QZIMdkI+T5sZawjoaiwzArT1MJ4mM1ZrdHOZ4YzwnWUkpxr8o9LfTqcNZ3A11f4Y0SH2PYIvfus+N0apz8jy5M+jH/2J9HHtTO8eSJ3KE+KhPN0N6/T6DO9t7vOXt0kyBVOefR/QNegJU8FIH3vvxpM37OMUoueyymYna41ujSx/QNYdFJDTcnauzhvLc+bmahhIv5d1GrWfkjfWXPMTFg4IewIef6qKRBIKb0yTsxQ4z4qMUXr8Ajd4BAI4Gr0npH64dZxptMBnzy29cBlar5nDvn1UY4d9waF+BvZG738sZTre30ELPex/4Gke8/c/k3n3/wI= -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "docusaurus start", 7 | "build": "docusaurus build", 8 | "swizzle": "docusaurus swizzle", 9 | "deploy": "docusaurus deploy", 10 | "doc:deploy": "./deploy.sh" 11 | }, 12 | "dependencies": { 13 | "@docusaurus/core": "2.1.0", 14 | "@docusaurus/preset-classic": "2.1.0", 15 | "classnames": "^2.2.6", 16 | "react": "^17.0.2", 17 | "react-dom": "^17.0.2" 18 | }, 19 | "browserslist": { 20 | "production": [ 21 | ">0.2%", 22 | "not dead", 23 | "not op_mini all" 24 | ], 25 | "development": [ 26 | "last 1 chrome version", 27 | "last 1 firefox version", 28 | "last 1 safari version" 29 | ] 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /docs/sidebars.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mySidebar: [ 3 | { 4 | type: 'category', 5 | label: 'Introduction', 6 | collapsed: false, 7 | items: [ 8 | { 9 | type: 'doc', 10 | id: 'what_is_lets', 11 | }, 12 | { 13 | type: 'doc', 14 | id: 'installation', 15 | }, 16 | { 17 | type: 'doc', 18 | id: 'quick_start', 19 | }, 20 | { 21 | type: 'doc', 22 | id: 'completion', 23 | }, 24 | ], 25 | }, 26 | { 27 | type: 'category', 28 | label: 'Usage', 29 | items: [ 30 | { 31 | type: 'doc', 32 | id: 'basic_usage', 33 | }, 34 | { 35 | type: 'doc', 36 | id: 'advanced_usage', 37 | }, 38 | ], 39 | }, 40 | 'config', 41 | { 42 | type: 'category', 43 | label: 'API Reference', 44 | items: [ 45 | { 46 | type: 'doc', 47 | id: 'cli', 48 | }, 49 | { 50 | type: 'doc', 51 | id: 'env', 52 | }, 53 | ], 54 | }, 55 | { 56 | type: 'category', 57 | label: 'Examples', 58 | items: [ 59 | { 60 | type: 'doc', 61 | id: 'examples', 62 | }, 63 | { 64 | type: 'doc', 65 | id: 'example_js', 66 | }, 67 | ], 68 | }, 69 | 'best_practices', 70 | 'changelog', 71 | 'ide_support', 72 | 73 | { 74 | type: 'category', 75 | label: 'Development', 76 | items: [ 77 | { 78 | type: 'doc', 79 | id: 'architecture', 80 | }, 81 | { 82 | type: 'doc', 83 | id: 'development', 84 | }, 85 | { 86 | type: 'doc', 87 | id: 'contribute', 88 | }, 89 | ], 90 | }, 91 | ], 92 | }; 93 | -------------------------------------------------------------------------------- /docs/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | /** 3 | * Any CSS included here will be global. The classic template 4 | * bundles Infima by default. Infima is a CSS framework designed to 5 | * work well for content-centric websites. 6 | */ 7 | 8 | /* You can override the default Infima variables here. */ 9 | :root { 10 | --ifm-color-primary: rgb(29, 216, 216); 11 | --ifm-color-primary-dark: rgb(33, 175, 144); 12 | --ifm-color-primary-darker: rgb(31, 165, 136); 13 | --ifm-color-primary-darkest: rgb(12, 141, 113); 14 | --ifm-color-primary-light: rgb(70, 203, 174); 15 | --ifm-color-primary-lighter: rgb(102, 212, 189); 16 | --ifm-color-primary-lightest: rgb(146, 224, 208); 17 | --ifm-code-font-size: 95%; 18 | } 19 | 20 | .docusaurus-highlight-code-line { 21 | background-color: rgb(72, 77, 91); 22 | display: block; 23 | margin: 0 calc(-1 * var(--ifm-pre-padding)); 24 | padding: 0 var(--ifm-pre-padding); 25 | } 26 | -------------------------------------------------------------------------------- /docs/src/pages/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classnames from 'classnames'; 3 | import Layout from '@theme/Layout'; 4 | import Link from '@docusaurus/Link'; 5 | import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; 6 | import useBaseUrl from '@docusaurus/useBaseUrl'; 7 | import styles from './styles.module.css'; 8 | 9 | const features = [ 10 | { 11 | title: <>Easy to Use, 12 | imageUrl: 'img/check.svg', 13 | description: ( 14 | <> 15 | Lets was designed for developers. Its simple yet powerful task runner with lots o features that just work. 16 | 17 | ), 18 | }, 19 | { 20 | title: <>Simple syntax, 21 | imageUrl: 'img/doc.svg', 22 | description: ( 23 | <> 24 | Lets use yaml as a config format which gives a well known, human-readable syntax 25 | with all yaml features built-in. 26 | 27 | ), 28 | }, 29 | { 30 | title: <>Suitable for any projects, 31 | imageUrl: 'img/gear.svg', 32 | description: ( 33 | <> 34 | You can have a couple of tasks or a hundred of them in your project. 35 | Lets allow you to focus on coding instead of writing hard-to-reason-about Makefiles. 36 | 37 | ), 38 | }, 39 | ]; 40 | 41 | function Feature({imageUrl, title, description}) { 42 | const imgUrl = useBaseUrl(imageUrl); 43 | return ( 44 |
45 | {imgUrl && ( 46 |
47 | {title} 48 |
49 | )} 50 |

{title}

51 |

{description}

52 |
53 | ); 54 | } 55 | 56 | function Home() { 57 | const context = useDocusaurusContext(); 58 | const {siteConfig = {}} = context; 59 | return ( 60 | 63 |
64 |
65 |

{siteConfig.title}

66 |

{siteConfig.tagline}

67 |
68 | 74 | Get Started 75 | 76 |
77 |
78 |
79 |
80 | {features && features.length && ( 81 |
82 |
83 |
84 | {features.map((props, idx) => ( 85 | 86 | ))} 87 |
88 |
89 |
90 | )} 91 |
92 |
93 | ); 94 | } 95 | 96 | export default Home; 97 | -------------------------------------------------------------------------------- /docs/src/pages/styles.module.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | /** 3 | * CSS files with the .module.css suffix will be treated as CSS modules 4 | * and scoped locally. 5 | */ 6 | 7 | .heroBanner { 8 | padding: 4rem 0; 9 | text-align: center; 10 | position: relative; 11 | overflow: hidden; 12 | } 13 | 14 | @media screen and (max-width: 966px) { 15 | .heroBanner { 16 | padding: 2rem; 17 | } 18 | } 19 | 20 | .buttons { 21 | display: flex; 22 | align-items: center; 23 | justify-content: center; 24 | } 25 | 26 | .features { 27 | display: flex; 28 | align-items: center; 29 | padding: 2rem 0; 30 | width: 100%; 31 | } 32 | 33 | .featureImage { 34 | height: 100px; 35 | width: 100px; 36 | margin-bottom: 20px; 37 | } 38 | 39 | .featureTitle { 40 | text-align: center; 41 | } 42 | 43 | .getStarted { 44 | background: #fff; 45 | color: var(--ifm-link-color) !important; 46 | } 47 | 48 | .getStarted:hover { 49 | transform: scale(1.03); 50 | } -------------------------------------------------------------------------------- /docs/static/CNAME: -------------------------------------------------------------------------------- 1 | lets-cli.org -------------------------------------------------------------------------------- /docs/static/img/check.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 10 | 11 | 12 | 13 | 14 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /docs/static/img/doc.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 10 | 11 | 12 | 13 | 14 | 16 | 17 | 18 | 19 | 20 | 22 | 23 | 24 | 25 | 26 | 28 | 29 | 30 | 31 | 32 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /docs/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lets-cli/lets/e7ef7eedf11e985a67d48f1d06bd255a3a524798/docs/static/img/favicon.ico -------------------------------------------------------------------------------- /docs/static/img/gear.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/static/img/lets-architecture-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lets-cli/lets/e7ef7eedf11e985a67d48f1d06bd255a3a524798/docs/static/img/lets-architecture-diagram.png -------------------------------------------------------------------------------- /docs/static/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lets-cli/lets/e7ef7eedf11e985a67d48f1d06bd255a3a524798/docs/static/img/logo.png -------------------------------------------------------------------------------- /env/env.go: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | import ( 4 | "os" 5 | "strconv" 6 | ) 7 | 8 | const MaxDebugLevel = 2 9 | 10 | func IsDebug() bool { return DebugLevel() > 0 } 11 | 12 | type debug struct { 13 | level int 14 | ready bool 15 | } 16 | 17 | func (d *debug) set(level int) { 18 | d.level = level 19 | d.ready = true 20 | } 21 | 22 | var debugLevel = &debug{} 23 | 24 | // DebugLevel determines verbosity level of debug logs. 25 | // If LETS_DEBUG set to int - then verbosity is 1 or 2 26 | // If --debug or -d used multiple times - then verbosity is 1 or 2 27 | // If -dd used - then verbosity is 2. 28 | // When determined - set debug level globally. 29 | func SetDebugLevel(level int) int { 30 | if level == 0 { 31 | envValue := os.Getenv("LETS_DEBUG") 32 | 33 | envLevel, err := strconv.Atoi(envValue) 34 | level = envLevel 35 | 36 | if err != nil { 37 | // probably not integer, try just determine bool value 38 | debug, err := strconv.ParseBool(envValue) 39 | if err != nil || !debug { 40 | level = 0 41 | } else { 42 | level = 1 43 | } 44 | } 45 | } 46 | 47 | level = min(level, MaxDebugLevel) 48 | 49 | debugLevel.set(level) 50 | return level 51 | } 52 | 53 | func DebugLevel() int { 54 | if !debugLevel.ready { 55 | panic("must run SetDebugLevel first") 56 | } 57 | 58 | return debugLevel.level 59 | } 60 | -------------------------------------------------------------------------------- /examples/python/.gitignore: -------------------------------------------------------------------------------- 1 | .lets 2 | venv -------------------------------------------------------------------------------- /examples/python/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8-slim 2 | 3 | WORKDIR /app 4 | 5 | COPY requirements.txt . 6 | 7 | RUN python3 -m pip install -r requirements.txt 8 | -------------------------------------------------------------------------------- /examples/python/Readme.md: -------------------------------------------------------------------------------- 1 | # Example usage of `lets` in python project 2 | 3 | ## Get examples 4 | 5 | 1. Clone 6 | 7 | ```bash 8 | git clone git@github.com:lets-cli/lets.git 9 | 10 | cd lets/examples/python 11 | ``` 12 | 13 | 2. Run `lets` to see all available commands -------------------------------------------------------------------------------- /examples/python/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | base: &base 5 | image: server 6 | build: 7 | context: . 8 | dockerfile: Dockerfile 9 | working_dir: /app 10 | user: ${CURRENT_UID} 11 | environment: 12 | PYTHONUNBUFFERED: 1 13 | PYTHONPATH: . 14 | depends_on: 15 | - postgres 16 | volumes: 17 | - ./server:/app/server 18 | 19 | server: 20 | <<: *base 21 | ports: 22 | - '3000:3000' 23 | command: python3 -m server 24 | 25 | ishell: 26 | <<: *base 27 | command: ipython 28 | 29 | postgres: 30 | image: postgres:11-alpine 31 | environment: 32 | POSTGRES_USER: postgres 33 | POSTGRES_PASSWORD: postgres 34 | POSTGRES_DB: postgres 35 | -------------------------------------------------------------------------------- /examples/python/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | env: 4 | DOCKER_BUILDKIT: "1" 5 | COMPOSE_DOCKER_CLI_BUILD: "1" 6 | 7 | eval_env: 8 | CURRENT_UID: echo "`id -u`:`id -g`" 9 | CURRENT_USER_NAME: echo "`id -un`" 10 | DOCKER_GATEWAY: echo $(docker network inspect uaprom_default --format="{{(index .IPAM.Config 0).Gateway}}") 11 | 12 | commands: 13 | build-server: 14 | checksum: 15 | - requirements.txt 16 | - Dockerfile 17 | persist_checksum: true 18 | cmd: | 19 | if [[ "${LETS_CHECKSUM_CHANGED}" == "true" ]]; then 20 | docker build -t server . -f Dockerfile 21 | fi 22 | 23 | # App and services 24 | run: 25 | description: Run marker app 26 | depends: 27 | - build-server 28 | cmd: | 29 | docker compose up server 30 | 31 | postgres: 32 | description: Run postgres 33 | cmd: docker compose up postgres 34 | 35 | ishell: 36 | description: Run ipython shell 37 | depends: 38 | - build-server 39 | cmd: docker compose run --rm -T ishell 40 | 41 | init-venv: 42 | description: Run to init python virtual env in this repo 43 | cmd: | 44 | if [[ ! -d ./venv ]]; then 45 | python3.8 -m venv ./venv 46 | fi 47 | source ./venv/bin/activate 48 | python3.8 -m pip install -r ./requirements.txt 49 | -------------------------------------------------------------------------------- /examples/python/requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.7.4 2 | 3 | ipython==8.10.0 -------------------------------------------------------------------------------- /examples/python/server/__main__.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | 3 | 4 | if __name__ == '__main__': 5 | app = web.Application() 6 | web.run_app(app, host='0.0.0.0', port=3000) -------------------------------------------------------------------------------- /examples/python/server/__pycache__/__main__.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lets-cli/lets/e7ef7eedf11e985a67d48f1d06bd255a3a524798/examples/python/server/__pycache__/__main__.cpython-38.pyc -------------------------------------------------------------------------------- /executor/env.go: -------------------------------------------------------------------------------- 1 | package executor 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/lets-cli/lets/checksum" 9 | ) 10 | 11 | func makeEnvEntry(k, v string) string { 12 | return fmt.Sprintf("%s=%s", k, v) 13 | } 14 | 15 | func normalizeEnvKey(origKey string) string { 16 | key := strings.ReplaceAll(origKey, "-", "_") 17 | key = strings.ToUpper(key) 18 | 19 | return key 20 | } 21 | 22 | func convertEnvMapToList(envMap map[string]string) []string { 23 | var envList []string 24 | for name, value := range envMap { 25 | envList = append(envList, makeEnvEntry(name, value)) 26 | } 27 | 28 | return envList 29 | } 30 | 31 | func getChecksumEnvMap(checksumMap map[string]string) map[string]string { 32 | envMap := make(map[string]string) 33 | 34 | for name, value := range checksumMap { 35 | envKey := "LETS_CHECKSUM_" + normalizeEnvKey(name) 36 | if name == checksum.DefaultChecksumKey { 37 | envKey = "LETS_CHECKSUM" 38 | } 39 | envMap[envKey] = value 40 | } 41 | 42 | return envMap 43 | } 44 | 45 | func isChecksumChanged(persistedChecksum string, persistedChecksumExists bool, newChecksum string) bool { 46 | if !persistedChecksumExists { 47 | // We set true here because if there was no persisted checksum that means that its a brand new checksum. 48 | // Hence it was changed from none to some value. 49 | return true 50 | } 51 | 52 | // But if we have persisted checksum - we check for checksum change below. 53 | return persistedChecksum != newChecksum 54 | } 55 | 56 | // persistedChecksumMap can be empty, and if so, we set env var LETS_CHECKSUM_[NAME]_CHANGED to false for all checksums. 57 | func getChangedChecksumEnvMap( 58 | checksumMap map[string]string, 59 | persistedChecksumMap map[string]string, 60 | ) map[string]string { 61 | envMap := make(map[string]string) 62 | 63 | for checksumName, checksumValue := range checksumMap { 64 | normalizedKey := normalizeEnvKey(checksumName) 65 | 66 | envKey := fmt.Sprintf("LETS_CHECKSUM_%s_CHANGED", normalizedKey) 67 | if checksumName == checksum.DefaultChecksumKey { 68 | envKey = "LETS_CHECKSUM_CHANGED" 69 | } 70 | 71 | persistedChecksum, persistedChecksumExists := persistedChecksumMap[checksumName] 72 | 73 | checksumChanged := isChecksumChanged(persistedChecksum, persistedChecksumExists, checksumValue) 74 | 75 | envMap[envKey] = strconv.FormatBool(checksumChanged) 76 | } 77 | 78 | return envMap 79 | } 80 | 81 | func fmtEnv(env []string) string { 82 | buf := "" 83 | 84 | for _, entry := range env { 85 | buf = fmt.Sprintf("%s\n %s", buf, entry) 86 | } 87 | 88 | return buf 89 | } 90 | -------------------------------------------------------------------------------- /executor/env_test.go: -------------------------------------------------------------------------------- 1 | package executor 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestConvertEnvMapToList(t *testing.T) { 8 | t.Run("should convert map to list of key=val", func(t *testing.T) { 9 | env := make(map[string]string, 1) 10 | env["ONE"] = "1" 11 | envList := convertEnvMapToList(env) 12 | exp := "ONE=1" 13 | if envList[0] != exp { 14 | t.Errorf("failed to convert env map to list. \nexp: %s\ngot: %s", exp, envList[0]) 15 | } 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/lets-cli/lets 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/codeclysm/extract v2.2.0+incompatible 7 | github.com/coreos/go-semver v0.3.1 8 | github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 9 | github.com/fatih/color v1.16.0 10 | github.com/pkg/errors v0.9.1 11 | github.com/sirupsen/logrus v1.9.3 12 | github.com/spf13/cobra v1.8.0 13 | github.com/tliron/commonlog v0.2.8 14 | github.com/tliron/glsp v0.2.2 15 | github.com/tree-sitter-grammars/tree-sitter-yaml v0.7.0 16 | github.com/tree-sitter/go-tree-sitter v0.24.0 17 | golang.org/x/sync v0.3.0 18 | ) 19 | 20 | require ( 21 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 22 | github.com/gorilla/websocket v1.5.1 // indirect 23 | github.com/iancoleman/strcase v0.3.0 // indirect 24 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 25 | github.com/mattn/go-colorable v0.1.13 // indirect 26 | github.com/mattn/go-isatty v0.0.20 // indirect 27 | github.com/mattn/go-pointer v0.0.1 // indirect 28 | github.com/mattn/go-runewidth v0.0.14 // indirect 29 | github.com/muesli/termenv v0.15.2 // indirect 30 | github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect 31 | github.com/rivo/uniseg v0.2.0 // indirect 32 | github.com/sasha-s/go-deadlock v0.3.1 // indirect 33 | github.com/sourcegraph/jsonrpc2 v0.2.0 // indirect 34 | github.com/tliron/kutil v0.3.11 // indirect 35 | golang.org/x/crypto v0.15.0 // indirect 36 | golang.org/x/net v0.17.0 // indirect 37 | golang.org/x/term v0.14.0 // indirect 38 | ) 39 | 40 | require ( 41 | github.com/h2non/filetype v1.1.1 // indirect 42 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 43 | github.com/juju/errors v0.0.0-20200330140219-3fe23663418f // indirect 44 | github.com/juju/testing v0.0.0-20201216035041-2be42bba85f3 // indirect 45 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 46 | github.com/lithammer/dedent v1.1.0 47 | github.com/spf13/pflag v1.0.5 // indirect 48 | golang.org/x/sys v0.14.0 // indirect 49 | gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect 50 | gopkg.in/yaml.v3 v3.0.1 51 | ) 52 | 53 | replace github.com/docopt/docopt-go => github.com/kindermax/docopt.go v0.7.1 54 | -------------------------------------------------------------------------------- /lets.build.yaml: -------------------------------------------------------------------------------- 1 | env: 2 | DOCKER_BUILDKIT: "1" 3 | 4 | commands: 5 | build-lets-image: 6 | description: Build lets docker image 7 | cmd: docker build -t lets -f Dockerfile --target builder . 8 | 9 | build-lint-image: 10 | description: Build lets lint docker image 11 | cmd: docker build -t lets-lint -f Dockerfile --target linter . 12 | -------------------------------------------------------------------------------- /lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | mixins: 4 | - lets.build.yaml 5 | - -lets.my.yaml 6 | 7 | env: 8 | CURRENT_UID: 9 | sh: echo "`id -u`:`id -g`" 10 | 11 | commands: 12 | release: 13 | description: | 14 | Create tag and push 15 | 16 | If -rcN (e.g 1.0.0-rc1) sufix used in version, it will be considered as a prerelease 17 | options: | 18 | Usage: lets release --message= 19 | 20 | Options: 21 | Set version (e.g. 1.0.0) 22 | --message=, -m Release message 23 | 24 | Example: 25 | lets release 1.0.0 -m "Release 1.0.0" 26 | lets release 1.0.0-rc1 -m "Prerelease 1.0.0-rc1" 27 | cmd: | 28 | if [[ "${LETSOPT_VERSION}" != *-rc* ]] && [[ -z "$(grep "\[${LETSOPT_VERSION}\]" docs/docs/changelog.md)" ]]; then 29 | echo "Version ${LETSOPT_VERSION} not found in changelog. Update docs/docs/changelog.md and try again." 30 | exit 1 31 | fi 32 | 33 | git tag -a v${LETSOPT_VERSION} -m "${LETSOPT_MESSAGE}" 34 | git push --tags 35 | 36 | test-unit: 37 | description: Run unit tests 38 | depends: [build-lets-image] 39 | cmd: 40 | - docker 41 | - compose 42 | - run 43 | - --rm 44 | - test 45 | 46 | test-bats: 47 | description: Run bats tests 48 | depends: [build-lets-image] 49 | options: | 50 | Usage: lets test-bats [] [--opts=] 51 | Example: 52 | lets test-bats config_version.bats 53 | lets test-bats config_version.bats --opts="-f " 54 | cmd: docker compose run --rm test-bats 55 | 56 | test-completions: 57 | ref: test-bats 58 | args: zsh_completion.bats_ 59 | description: | 60 | Run completions tests 61 | This tests are separate because it hangs on Github Actions 62 | 63 | test: 64 | description: Run unit and bats tests 65 | depends: 66 | - test-unit 67 | - test-bats 68 | - test-completions 69 | 70 | coverage: 71 | description: Run tests for lets 72 | options: | 73 | Usage: lets coverage [--html] 74 | Options: --html 75 | cmd: | 76 | if [[ -n ${LETSOPT_HTML} ]]; then 77 | go tool cover -html=coverage.out 78 | else 79 | go tool cover -func=coverage.out 80 | fi 81 | 82 | lint: 83 | description: Run golint-ci 84 | depends: [build-lint-image] 85 | cmd: 86 | - docker compose run --rm lint 87 | 88 | fmt: 89 | description: Run sfmt 90 | cmd: go fmt ./... 91 | 92 | build-and-install: 93 | description: Build and install lets-dev version from source code 94 | options: | 95 | Usage: lets build-and-install [--path=] [--bin=] 96 | Options: 97 | --path=, -p Custom executable path 98 | --bin= Binary name (default: lets-dev) 99 | Example: 100 | lets build-and-install 101 | lets build-and-install -p ~/bin 102 | lets build-and-install -p ~/bin --bin=my-lets 103 | cmd: | 104 | VERSION=$(git describe) 105 | PATH2LETSDEV="/usr/local/bin" 106 | BIN="${LETSOPT_BIN:-lets-dev}" 107 | 108 | if [[ -n ${LETSOPT_PATH} ]]; then 109 | PATH2LETSDEV=$LETSOPT_PATH 110 | fi 111 | 112 | go build -ldflags="-X main.version=${VERSION:1}-dev" -o "${BIN}" *.go && \ 113 | sudo mv ./${BIN} $PATH2LETSDEV/${BIN} && \ 114 | echo " - binary ${BIN} version ${VERSION} successfully installed in ${PATH2LETSDEV}" 115 | 116 | build: 117 | description: Build lets from source code 118 | options: | 119 | Usage: lets build [] 120 | cmd: | 121 | VERSION=$(git describe) 122 | BIN=${LETSOPT_BIN:-lets} 123 | 124 | go build -ldflags="-X main.version=${VERSION:1}-dev" -o ${BIN} *.go && \ 125 | echo " - binary './${BIN}' (version ${VERSION}) successfully build" 126 | 127 | publish-docs: 128 | work_dir: docs 129 | cmd: npm run doc:deploy 130 | 131 | run-docs: 132 | work_dir: docs 133 | cmd: npm start 134 | -------------------------------------------------------------------------------- /logging/formatter.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "strings" 7 | 8 | log "github.com/sirupsen/logrus" 9 | ) 10 | 11 | // LogRepresenter is an interface for objects that can format themselves for 12 | // logging. 13 | type LogRepresenter interface { 14 | Repr() string 15 | } 16 | 17 | // Formatter formats a log entry in a human readable way. 18 | type Formatter struct{} 19 | 20 | // Format implements the log.Formatter interface. 21 | func (f *Formatter) Format(entry *log.Entry) ([]byte, error) { 22 | buff := &bytes.Buffer{} 23 | buff.WriteString(writeData(entry.Data)) 24 | buff.WriteString(entry.Message) 25 | buff.WriteString("\n") 26 | 27 | return buff.Bytes(), nil 28 | } 29 | 30 | func writeData(fields log.Fields) string { 31 | var buff []string 32 | 33 | for key, value := range fields { 34 | switch value := value.(type) { 35 | case LogRepresenter: 36 | buff = append(buff, value.Repr()) 37 | default: 38 | buff = append(buff, fmt.Sprintf("%v=%v", key, value)) 39 | } 40 | } 41 | 42 | if len(buff) > 0 { 43 | buff = append(buff, "") 44 | } 45 | 46 | return strings.Join(buff, " ") 47 | } 48 | -------------------------------------------------------------------------------- /logging/log.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/fatih/color" 8 | log "github.com/sirupsen/logrus" 9 | ) 10 | 11 | // Log is the main application logger. 12 | // InitLogging for logrus. 13 | func InitLogging( 14 | stdWriter io.Writer, 15 | errWriter io.Writer, 16 | ) { 17 | log.SetOutput(io.Discard) 18 | 19 | log.SetLevel(log.InfoLevel) 20 | 21 | log.AddHook(&WriterHook{ 22 | Writer: stdWriter, 23 | LogLevels: []log.Level{ 24 | log.InfoLevel, 25 | log.DebugLevel, 26 | log.WarnLevel, 27 | }, 28 | }) 29 | 30 | log.AddHook(&WriterHook{ 31 | Writer: errWriter, 32 | LogLevels: []log.Level{ 33 | log.PanicLevel, 34 | log.FatalLevel, 35 | log.ErrorLevel, 36 | }, 37 | }) 38 | 39 | log.SetFormatter(&Formatter{}) 40 | } 41 | 42 | // ExecLogger is used in Executor. 43 | // If adds command chain in message like this: 44 | // lets: [foo=>bar] message. 45 | type ExecLogger struct { 46 | log *log.Logger 47 | // command name 48 | name string 49 | // lets: [a=>b] 50 | prefix string 51 | cache map[string]*ExecLogger 52 | } 53 | 54 | func NewExecLogger() *ExecLogger { 55 | return &ExecLogger{ 56 | log: log.StandardLogger(), 57 | prefix: color.BlueString("lets:"), 58 | cache: make(map[string]*ExecLogger), 59 | } 60 | } 61 | 62 | func (l *ExecLogger) Child(name string) *ExecLogger { 63 | if _, ok := l.cache[name]; ok { 64 | return l.cache[name] 65 | } 66 | 67 | if l.name != "" { 68 | name = fmt.Sprintf("%s => %s", l.name, name) 69 | } 70 | 71 | l.cache[name] = &ExecLogger{ 72 | log: l.log, 73 | name: name, 74 | prefix: color.BlueString("lets: %s", color.GreenString("[%s]", name)), 75 | cache: make(map[string]*ExecLogger), 76 | } 77 | 78 | return l.cache[name] 79 | } 80 | 81 | func (l *ExecLogger) Info(format string, a ...interface{}) { 82 | format = fmt.Sprintf("%s %s", l.prefix, color.BlueString(format)) 83 | l.log.Logf(log.InfoLevel, format, a...) 84 | } 85 | 86 | func (l *ExecLogger) Debug(format string, a ...interface{}) { 87 | format = fmt.Sprintf("%s %s", l.prefix, color.BlueString(format)) 88 | l.log.Logf(log.DebugLevel, format, a...) 89 | } 90 | -------------------------------------------------------------------------------- /logging/log_test.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | log "github.com/sirupsen/logrus" 8 | ) 9 | 10 | func TestLoggingToStd(t *testing.T) { 11 | t.Run("should write log to correct std descriptor", func(t *testing.T) { 12 | stdOutMsg := "Log in std out" 13 | stdErrMsg := "Log in std err" 14 | 15 | var stdBuff bytes.Buffer 16 | 17 | var errBuff bytes.Buffer 18 | 19 | InitLogging(&stdBuff, &errBuff) 20 | 21 | log.Info(stdOutMsg) 22 | log.Error(stdErrMsg) 23 | 24 | // coz log adds line break for output 25 | if stdBuff.String() != stdOutMsg+"\n" { 26 | t.Errorf("stdBuff != stdOutMsg plz check your init stdWriter") 27 | } 28 | 29 | if errBuff.String() != stdErrMsg+"\n" { 30 | t.Errorf("errBuff != stdErrMsg plz check your init errWriter") 31 | } 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /logging/writerhook.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "io" 5 | 6 | log "github.com/sirupsen/logrus" 7 | ) 8 | 9 | // WriterHook struct for routing std depending on lvl. 10 | type WriterHook struct { 11 | Writer io.Writer 12 | LogLevels []log.Level 13 | } 14 | 15 | // Fire method prosees entry for Writer. 16 | func (hook *WriterHook) Fire(entry *log.Entry) error { 17 | line, err := entry.String() 18 | if err != nil { 19 | return err 20 | } 21 | 22 | _, err = hook.Writer.Write([]byte(line)) 23 | 24 | return err 25 | } 26 | 27 | // Levels geter for list of lvls. 28 | func (hook *WriterHook) Levels() []log.Level { 29 | return hook.LogLevels 30 | } 31 | -------------------------------------------------------------------------------- /lsp/server.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/tliron/commonlog" 7 | _ "github.com/tliron/commonlog/simple" 8 | lsp "github.com/tliron/glsp/protocol_3_16" 9 | "github.com/tliron/glsp/server" 10 | ) 11 | 12 | const lsName = "lets_ls" 13 | 14 | var handler lsp.Handler 15 | 16 | type lspServer struct { 17 | version string 18 | server *server.Server 19 | storage *storage 20 | log commonlog.Logger 21 | } 22 | 23 | func (s *lspServer) Run() error { 24 | return s.server.RunStdio() 25 | } 26 | 27 | func Run(ctx context.Context, version string) error { 28 | commonlog.Configure(1, nil) 29 | logger := commonlog.GetLogger(lsName) 30 | logger.Infof("Lets LSP server starting %s", version) 31 | 32 | handler = lsp.Handler{} 33 | 34 | glspServer := server.NewServer(&handler, lsName, false) 35 | glspServer.Context = ctx 36 | 37 | lspServer := &lspServer{ 38 | version: version, 39 | server: glspServer, 40 | storage: newStorage(), 41 | log: logger, 42 | } 43 | 44 | handler.Initialize = lspServer.initialize 45 | handler.Initialized = lspServer.initialized 46 | handler.Shutdown = lspServer.shutdown 47 | handler.SetTrace = lspServer.setTrace 48 | handler.TextDocumentDidOpen = lspServer.textDocumentDidOpen 49 | handler.TextDocumentDidChange = lspServer.textDocumentDidChange 50 | handler.TextDocumentDefinition = lspServer.textDocumentDefinition 51 | handler.TextDocumentCompletion = lspServer.textDocumentCompletion 52 | 53 | return lspServer.Run() 54 | } 55 | -------------------------------------------------------------------------------- /lsp/storage.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | type storage struct { 4 | documents map[string]*string 5 | } 6 | 7 | func newStorage() *storage { 8 | return &storage{ 9 | documents: make(map[string]*string), 10 | } 11 | } 12 | 13 | func (s *storage) GetDocument(uri string) *string { 14 | return s.documents[uri] 15 | } 16 | 17 | func (s *storage) AddDocument(uri string, text string) { 18 | s.documents[uri] = &text 19 | } 20 | -------------------------------------------------------------------------------- /lsp/utils.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | ) 7 | 8 | // UriToPath converts a file:// URI to a path. 9 | func uriToPath(uri string) string { 10 | if strings.HasPrefix(uri, "file://") { 11 | return uri[7:] 12 | } 13 | return uri 14 | } 15 | 16 | // pathToURI converts a path to a file:// URI. 17 | func pathToURI(path string) string { 18 | if strings.HasPrefix(path, "file://") { 19 | return path 20 | } 21 | return "file://" + path 22 | } 23 | 24 | func getCanonicalPath(path string) string { 25 | path = filepath.Clean(path) 26 | 27 | resolvedPath, err := filepath.EvalSymlinks(path) 28 | if err == nil { 29 | path = resolvedPath 30 | } 31 | 32 | return path 33 | } 34 | 35 | func normalizePath(pathOrURI string) string { 36 | path := uriToPath(pathOrURI) 37 | return getCanonicalPath(path) 38 | } 39 | 40 | func replacePathFilename(path string, filename string) string { 41 | return filepath.Join(filepath.Dir(path), filename) 42 | } 43 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lets", 3 | "lockfileVersion": 2, 4 | "requires": true, 5 | "packages": {} 6 | } 7 | -------------------------------------------------------------------------------- /set/set.go: -------------------------------------------------------------------------------- 1 | package set 2 | 3 | type Set[T comparable] map[T]struct{} 4 | 5 | func (s Set[T]) Add(values ...T) { 6 | for _, value := range values { 7 | s[value] = struct{}{} 8 | } 9 | } 10 | 11 | func (s Set[T]) ToList() []T { 12 | values := make([]T, 0, len(s)) 13 | for k := range s { 14 | values = append(values, k) 15 | } 16 | 17 | return values 18 | } 19 | 20 | func (s Set[T]) Remove(value T) { 21 | delete(s, value) 22 | } 23 | 24 | func (s Set[T]) Contains(value T) bool { 25 | _, c := s[value] 26 | 27 | return c 28 | } 29 | 30 | func NewSet[T comparable](values ...T) Set[T] { 31 | set := make(Set[T]) 32 | set.Add(values...) 33 | 34 | return set 35 | } 36 | -------------------------------------------------------------------------------- /set/set_test.go: -------------------------------------------------------------------------------- 1 | package set 2 | 3 | import ( 4 | "reflect" 5 | "sort" 6 | "testing" 7 | ) 8 | 9 | func TestSet(t *testing.T) { 10 | t.Run("add string to set", func(t *testing.T) { 11 | set := NewSet[string]() 12 | 13 | set.Add("a") 14 | set.Add("b") 15 | set.Add("a") 16 | set.Add("c") 17 | 18 | values := set.ToList() 19 | sort.Strings(values) 20 | if !reflect.DeepEqual(values, []string{"a", "b", "c"}) { 21 | t.Errorf("set must contain only unique elements, got: %s", values) 22 | } 23 | }) 24 | t.Run("add many strings at once to set", func(t *testing.T) { 25 | set := NewSet[string]() 26 | 27 | set.Add("a", "b", "c") 28 | set.Add("c") 29 | 30 | values := set.ToList() 31 | sort.Strings(values) 32 | if !reflect.DeepEqual(values, []string{"a", "b", "c"}) { 33 | t.Errorf("set must contain only unique elements, got: %s", values) 34 | } 35 | }) 36 | 37 | t.Run("remove string from set", func(t *testing.T) { 38 | set := NewSet[string]() 39 | 40 | set.Add("a", "b", "c") 41 | set.Remove("c") 42 | 43 | values := set.ToList() 44 | sort.Strings(values) 45 | if !reflect.DeepEqual(values, []string{"a", "b"}) { 46 | t.Errorf("set contains element which must be deleted, got: %s", values) 47 | } 48 | }) 49 | 50 | t.Run("remove string from set", func(t *testing.T) { 51 | set := NewSet[string]() 52 | 53 | set.Add("a", "b", "c") 54 | 55 | if !set.Contains("c") { 56 | t.Errorf("set must contain element which was added, got: %s", set.ToList()) 57 | } 58 | }) 59 | } 60 | 61 | func TestIntSet(t *testing.T) { 62 | t.Run("add int to set", func(t *testing.T) { 63 | set := NewSet[int]() 64 | 65 | set.Add(1) 66 | set.Add(2) 67 | set.Add(2) 68 | 69 | values := set.ToList() 70 | sort.Ints(values) 71 | if !reflect.DeepEqual(values, []int{1, 2}) { 72 | t.Errorf("set must contain only unique elements, got: %v", values) 73 | } 74 | }) 75 | } 76 | -------------------------------------------------------------------------------- /test/args.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | // MockArgs mocks os.Args with values passed to thi func. 8 | func MockArgs(args []string) { 9 | os.Args = append([]string{"lets"}, args...) 10 | } 11 | -------------------------------------------------------------------------------- /test/temp_file.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "log" 5 | "os" 6 | ) 7 | 8 | func CreateTempFile(dir string, name string) *os.File { 9 | file, err := os.CreateTemp(dir, name) 10 | if err != nil { 11 | log.Fatal(err) 12 | } 13 | 14 | return file 15 | } 16 | -------------------------------------------------------------------------------- /tests/command_after.bats: -------------------------------------------------------------------------------- 1 | load test_helpers 2 | 3 | setup() { 4 | load "${BATS_UTILS_PATH}/bats-support/load.bash" 5 | load "${BATS_UTILS_PATH}/bats-assert/load.bash" 6 | cd ./tests/command_after 7 | } 8 | 9 | @test "command_after: should run after script if cmd string" { 10 | run lets cmd-with-after 11 | assert_success 12 | assert_line --index 0 "Main" 13 | assert_line --index 1 "After" 14 | } 15 | 16 | @test "command_after: should run after script if cmd as map" { 17 | run lets cmd-as-map-with-after 18 | assert_success 19 | assert_line --index 0 "Main" 20 | assert_line --index 1 "After" 21 | } 22 | 23 | @test "command_after: should not shadow exit code from cmd" { 24 | run lets failure 25 | 26 | [[ $status = 113 ]] 27 | assert_line --index 0 "After" 28 | } 29 | 30 | @test "command_after: should not shadow exit code from cmd-as-map" { 31 | run lets failure-as-map 32 | 33 | [[ $status = 113 ]] 34 | assert_line --index 0 "After" 35 | } 36 | -------------------------------------------------------------------------------- /tests/command_after/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | commands: 4 | cmd-with-after: 5 | description: Test after script 6 | cmd: echo "Main" 7 | after: echo "After" 8 | 9 | cmd-as-map-with-after: 10 | description: Test after script with cmd-as-map 11 | cmd: 12 | echo: echo "Main" 13 | after: echo "After" 14 | 15 | failure: 16 | description: Test after script with cmd-as-map 17 | cmd: exit 113 18 | after: echo "After" 19 | 20 | failure-as-map: 21 | description: Test after script with cmd-as-map 22 | cmd: 23 | fail: exit 113 24 | after: echo "After" 25 | -------------------------------------------------------------------------------- /tests/command_checksum.bats: -------------------------------------------------------------------------------- 1 | load test_helpers 2 | 3 | setup() { 4 | load "${BATS_UTILS_PATH}/bats-support/load.bash" 5 | load "${BATS_UTILS_PATH}/bats-assert/load.bash" 6 | cd ./tests/command_checksum 7 | } 8 | 9 | ALL_CHECKSUM="be48892c650a32df361202a3662f31e5eac2b83c" 10 | FOO_CHECKSUM="833330f14e30e3ce1907f1e126e1ea4db1ec349f" 11 | BAR_CHECKSUM="7917368d518c031517855672acf2ef82b9cb6836" 12 | 13 | CHECKSUM_FROM_FOO_AND_BAR_CHECKSUMS="b778d48759ad4e6e9a755bd595d23eeaa2f7ff65" 14 | 15 | @test "command_checksum: should calculate checksum as list of files" { 16 | run lets as-list-of-files 17 | assert_success 18 | assert_line --index 0 ${ALL_CHECKSUM} 19 | } 20 | 21 | @test "command_checksum: should calculate checksum as list of globs" { 22 | run lets as-list-of-globs 23 | assert_success 24 | assert_line --index 0 ${ALL_CHECKSUM} 25 | } 26 | 27 | @test "command_checksum: should calculate checksum as map of list of files" { 28 | run lets as-map-of-list-of-files 29 | assert_success 30 | assert_line --index 0 "LETS_CHECKSUM_FOO=${FOO_CHECKSUM}" 31 | assert_line --index 1 "LETS_CHECKSUM_BAR=${BAR_CHECKSUM}" 32 | assert_line --index 2 "LETS_CHECKSUM=${CHECKSUM_FROM_FOO_AND_BAR_CHECKSUMS}" 33 | } 34 | 35 | @test "command_checksum: should calculate checksum as map of list of globs" { 36 | run lets as-map-of-list-of-globs 37 | assert_success 38 | assert_line --index 0 "LETS_CHECKSUM_FOO=${FOO_CHECKSUM}" 39 | assert_line --index 1 "LETS_CHECKSUM_BAR=${BAR_CHECKSUM}" 40 | assert_line --index 2 "LETS_CHECKSUM=${CHECKSUM_FROM_FOO_AND_BAR_CHECKSUMS}" 41 | } 42 | 43 | @test "command_checksum: checksum from named key in map must be same as from list if files are the same" { 44 | run lets as-map-all-in-one 45 | assert_success 46 | assert_line --index 0 "LETS_CHECKSUM_ALL=${ALL_CHECKSUM}" 47 | assert_line --index 1 "LETS_CHECKSUM=794b73672fd1259d6fc742cb86713e769d723920" 48 | } 49 | 50 | 51 | @test "command_checksum: should calculate checksum from sub-dir" { 52 | cd ./subdir 53 | run lets as-list-of-files 54 | assert_success 55 | assert_line --index 0 ${ALL_CHECKSUM} 56 | } 57 | -------------------------------------------------------------------------------- /tests/command_checksum/bar_1.txt: -------------------------------------------------------------------------------- 1 | bar1 -------------------------------------------------------------------------------- /tests/command_checksum/foo_1.txt: -------------------------------------------------------------------------------- 1 | foo1 -------------------------------------------------------------------------------- /tests/command_checksum/foo_2.txt: -------------------------------------------------------------------------------- 1 | foo2 -------------------------------------------------------------------------------- /tests/command_checksum/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | commands: 4 | as-list-of-files: 5 | description: Test checksum 6 | checksum: 7 | - foo_1.txt 8 | - foo_2.txt 9 | - bar_1.txt 10 | cmd: echo "${LETS_CHECKSUM}" 11 | 12 | as-list-of-globs: 13 | description: Test checksum 14 | checksum: 15 | - foo*.txt 16 | - bar_1.txt 17 | cmd: echo "${LETS_CHECKSUM}" 18 | 19 | as-map-of-list-of-files: 20 | description: Test checksum 21 | checksum: 22 | foo: 23 | - foo_1.txt 24 | - foo_2.txt 25 | bar: 26 | - bar_1.txt 27 | cmd: | 28 | echo LETS_CHECKSUM_FOO="${LETS_CHECKSUM_FOO}" 29 | echo LETS_CHECKSUM_BAR="${LETS_CHECKSUM_BAR}" 30 | echo LETS_CHECKSUM="${LETS_CHECKSUM}" 31 | 32 | as-map-of-list-of-globs: 33 | description: Test checksum 34 | checksum: 35 | foo: 36 | - foo*.txt 37 | bar: 38 | - bar_1.txt 39 | cmd: | 40 | echo LETS_CHECKSUM_FOO="${LETS_CHECKSUM_FOO}" 41 | echo LETS_CHECKSUM_BAR="${LETS_CHECKSUM_BAR}" 42 | echo LETS_CHECKSUM="${LETS_CHECKSUM}" 43 | 44 | as-map-all-in-one: 45 | description: Test checksum 46 | checksum: 47 | all: 48 | - foo*.txt 49 | - bar_1.txt 50 | cmd: | 51 | echo LETS_CHECKSUM_ALL="${LETS_CHECKSUM_ALL}" 52 | echo LETS_CHECKSUM="${LETS_CHECKSUM}" -------------------------------------------------------------------------------- /tests/command_checksum/subdir/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lets-cli/lets/e7ef7eedf11e985a67d48f1d06bd255a3a524798/tests/command_checksum/subdir/.gitkeep -------------------------------------------------------------------------------- /tests/command_cmd.bats: -------------------------------------------------------------------------------- 1 | load test_helpers 2 | 3 | setup() { 4 | load "${BATS_UTILS_PATH}/bats-support/load.bash" 5 | load "${BATS_UTILS_PATH}/bats-assert/load.bash" 6 | cd ./tests/command_cmd 7 | } 8 | 9 | @test "command_cmd: should run as string" { 10 | run lets cmd-as-string 11 | assert_success 12 | assert_line --index 0 "Main" 13 | } 14 | 15 | @test "command_cmd: should run as multiline string" { 16 | run lets cmd-as-multiline-string 17 | assert_success 18 | assert_line --index 0 "Main 1 line" 19 | assert_line --index 1 "Main 2 line" 20 | } 21 | 22 | @test "command_cmd: should run as array" { 23 | run lets cmd-as-array Hello 24 | 25 | assert_success 26 | assert_line --index 0 "Hello" 27 | } 28 | 29 | @test "command_cmd: should run as map" { 30 | run lets cmd-as-map 31 | assert_success 32 | 33 | # there is no guarantee in which order cmds will finish, so we sort output on our own 34 | sort_array lines 35 | assert_line --index 0 "1" 36 | assert_line --index 1 "2" 37 | } 38 | 39 | @test "command_cmd: cmd-as-map must exit with error if any of cmd exits with error" { 40 | run lets cmd-as-map-error 41 | 42 | assert_failure 43 | # as there is no guarantee in which order cmds runs 44 | # we can not guarantee that all commands will run and complete. 45 | # But error message must be in the output. 46 | assert_output --partial "failed to run command 'cmd-as-map-error': exit status 2" 47 | } 48 | 49 | @test "command_cmd: cmd-as-map must propagate env" { 50 | run lets cmd-as-map-env-propagated 51 | assert_success 52 | 53 | # there is no guarantee in which order cmds will finish, so we sort output on our own 54 | sort_array lines 55 | 56 | assert_line --index 0 "1 hello" 57 | assert_line --index 1 "2 hello" 58 | } 59 | 60 | @test "command_cmd: cmd-as-map run with --only" { 61 | run lets --only two cmd-as-map 62 | 63 | assert_success 64 | assert_line --index 0 "2" 65 | } 66 | 67 | @test "command_cmd: cmd-as-map run with --exclude" { 68 | run lets --exclude one cmd-as-map 69 | 70 | assert_success 71 | assert_line --index 0 "2" 72 | } 73 | 74 | @test "command_cmd: cmd-as-map run with --only and command own flags" { 75 | run lets --only two cmd-as-map-with-options --hello 76 | 77 | assert_success 78 | assert_line --index 0 "2 --hello" 79 | } 80 | 81 | @test "command_cmd: short syntax" { 82 | run lets short 83 | 84 | assert_success 85 | assert_line --index 0 "Hello from short" 86 | } -------------------------------------------------------------------------------- /tests/command_cmd/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | commands: 4 | cmd-as-string: 5 | description: Test cmd as string 6 | cmd: echo "Main" 7 | 8 | cmd-as-multiline-string: 9 | description: Test cmd as multiline string 10 | cmd: | 11 | echo "Main 1 line" 12 | echo "Main 2 line" 13 | 14 | cmd-as-array: 15 | description: Test cmd as array 16 | cmd: 17 | - echo 18 | 19 | cmd-as-map: 20 | description: Test cmd as map 21 | cmd: 22 | one: echo "1" 23 | two: echo "2" 24 | 25 | cmd-as-map-with-options: 26 | description: Test cmd as map with options 27 | options: | 28 | Usage: lets cmd-as-map-with-options [--hello] 29 | cmd: 30 | one: echo "1 ${LETSCLI_HELLO}" 31 | two: echo "2 ${LETSCLI_HELLO}" 32 | 33 | cmd-as-map-error: 34 | description: Test cmd as map with error 35 | cmd: 36 | one: echo "1" 37 | two: exit 2 38 | 39 | cmd-as-map-env-propagated: 40 | description: Test cmd as map - env propagated to all commands 41 | env: 42 | TEST_ENV: hello 43 | cmd: 44 | one: echo "1 ${TEST_ENV}" 45 | two: echo "2 ${TEST_ENV}" 46 | 47 | short: echo Hello from short 48 | -------------------------------------------------------------------------------- /tests/command_depends.bats: -------------------------------------------------------------------------------- 1 | load test_helpers 2 | 3 | setup() { 4 | load "${BATS_UTILS_PATH}/bats-support/load.bash" 5 | load "${BATS_UTILS_PATH}/bats-assert/load.bash" 6 | cd ./tests/command_depends 7 | } 8 | 9 | @test "command_depends: should run all depends commands before main command" { 10 | run lets run-with-depends 11 | assert_success 12 | assert_line --index 0 "Hello World with level INFO" 13 | assert_line --index 1 "Bar" 14 | assert_line --index 2 "Main" 15 | } 16 | 17 | @test "command_depends: should override args" { 18 | run lets override-args 19 | assert_success 20 | assert_line --index 0 "Hello Developer with level INFO" 21 | assert_line --index 1 "Bar" 22 | assert_line --index 2 "Override args" 23 | } 24 | 25 | @test "command_depends: should override env" { 26 | run lets override-env 27 | assert_success 28 | assert_line --index 0 "Hello World with level DEBUG" 29 | assert_line --index 1 "Override env" 30 | } 31 | 32 | @test "command_depends: ref works in depends" { 33 | # checks that original command does not overrides ref to original 34 | # command. The order in depends is essential to test behavior. 35 | run lets with-ref-in-depends 36 | assert_success 37 | assert_line --index 0 "Hello World with level INFO" 38 | # World -> Developer by ref.args 39 | # INFO -> DEBUG by depends[1].env.INFO 40 | assert_line --index 1 "Hello Developer with level DEBUG" 41 | # World -> Bar (because dep args has more priority over ref args) 42 | assert_line --index 2 "Hello Bar with level INFO" 43 | assert_line --index 3 "I have ref in depends" 44 | } 45 | 46 | 47 | @test "command_depends: disallow parallel cmd in depends" { 48 | LETS_CONFIG=lets-parallel-in-depends.yaml run lets parallel-in-depends 49 | assert_failure 50 | assert_line --index 0 "lets: config error: command 'parallel-in-depends' depends on command 'parallel', but parallel cmd is not allowed in depends yet" 51 | } -------------------------------------------------------------------------------- /tests/command_depends/lets-parallel-in-depends.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | commands: 4 | parallel: 5 | cmd: 6 | foo: echo foo 7 | bar: echo bar 8 | 9 | parallel-in-depends: 10 | depends: [parallel] -------------------------------------------------------------------------------- /tests/command_depends/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | commands: 4 | run-with-depends: 5 | description: Test command depends 6 | depends: 7 | - greet 8 | - bar 9 | cmd: | 10 | echo "Main" 11 | 12 | override-args: 13 | description: Test override args 14 | depends: 15 | - name: greet 16 | args: Developer 17 | - bar 18 | cmd: echo "Override args" 19 | 20 | override-env: 21 | description: Test override env 22 | depends: 23 | - name: greet 24 | env: 25 | LEVEL: DEBUG 26 | cmd: echo "Override env" 27 | 28 | greet: 29 | options: | 30 | Usage: lets greet [] 31 | env: 32 | LEVEL: INFO 33 | cmd: echo Hello ${LETSOPT_NAME:-World} with level ${LEVEL} 34 | 35 | greet-dev: 36 | ref: greet 37 | args: Developer 38 | 39 | greet-foo: 40 | ref: greet 41 | args: Foo 42 | 43 | bar: 44 | cmd: echo Bar 45 | 46 | with-ref-in-depends: 47 | depends: 48 | - greet 49 | - name: greet-dev 50 | env: 51 | LEVEL: DEBUG 52 | - name: greet-foo 53 | args: Bar 54 | cmd: echo I have ref in depends 55 | -------------------------------------------------------------------------------- /tests/command_docopt_cmd_placeholder.bats: -------------------------------------------------------------------------------- 1 | load test_helpers 2 | 3 | setup() { 4 | load "${BATS_UTILS_PATH}/bats-support/load.bash" 5 | load "${BATS_UTILS_PATH}/bats-assert/load.bash" 6 | cd ./tests/command_docopt_cmd_placeholder 7 | } 8 | 9 | @test "command_docopt_cmd_placeholder: should run with docopt from yaml alias" { 10 | # We can use yaml alias syntax to prevent repetition of docsopt description 11 | # The placeholder string is \$\{LETS_COMMAND_NAME\} for now 12 | run lets cmd-1 posarg --config=some_path 13 | assert_success 14 | assert_line --index 0 "posarg" 15 | assert_line --index 1 "some_path" 16 | } 17 | 18 | @test "command_docopt_cmd_placeholder: should fail with docopt from yaml alias wo placeholder" { 19 | run lets cmd-2 posarg --config=some_path 20 | 21 | assert_failure 22 | assert_line --index 0 --partial "no such option" 23 | } 24 | -------------------------------------------------------------------------------- /tests/command_docopt_cmd_placeholder/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | commands: 4 | cmd-template: &cmd-template 5 | options: | 6 | Usage: lets ${LETS_COMMAND_NAME} [] [--config=] 7 | Options: 8 | , Some positional 9 | --config= -c Custom config 10 | cmd: echo "Do some stuff" 11 | 12 | cmd-1: 13 | <<: *cmd-template 14 | cmd: | 15 | echo $LETSOPT_POSARG 16 | echo $LETSOPT_CONFIG 17 | 18 | cmd: &cmd 19 | options: | 20 | Usage: lets cmd [] [--config=] 21 | Options: 22 | , Some positional 23 | --config= -c Custom config 24 | cmd: echo "Do some stuff" 25 | 26 | cmd-2: 27 | <<: *cmd 28 | cmd: | 29 | echo $LETSOPT_POSARG 30 | echo $LETSOPT_CONFIG 31 | -------------------------------------------------------------------------------- /tests/command_env.bats: -------------------------------------------------------------------------------- 1 | load test_helpers 2 | 3 | setup() { 4 | load "${BATS_UTILS_PATH}/bats-support/load.bash" 5 | load "${BATS_UTILS_PATH}/bats-assert/load.bash" 6 | cd ./tests/command_env 7 | } 8 | 9 | @test "command_env: should provide env to command" { 10 | run lets env 11 | assert_success 12 | assert_line --index 0 "ONE=1" 13 | assert_line --index 1 "TWO=two" 14 | assert_line --index 2 "BAR=Bar" 15 | assert_line --index 3 "FOO=bb1da47569d9fbe3b5f2216fdbd4c9b040ccb5c1" 16 | } 17 | 18 | @test "command_env: should merge env with aliased map" { 19 | run lets -c lets.aliased-env.yaml env 20 | assert_success 21 | assert_line --index 0 "ONE=1" 22 | assert_line --index 1 "FOO=BAR" 23 | } 24 | -------------------------------------------------------------------------------- /tests/command_env/foo.txt: -------------------------------------------------------------------------------- 1 | 123 -------------------------------------------------------------------------------- /tests/command_env/lets.aliased-env.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | x-default-env: &default-env 4 | FOO: 5 | sh: echo "BAR" 6 | 7 | commands: 8 | env: 9 | env: 10 | ONE: "1" 11 | FOO: 12 | sh: echo "hello" 13 | <<: *default-env 14 | cmd: | 15 | echo ONE=${ONE} 16 | echo FOO=${FOO} 17 | -------------------------------------------------------------------------------- /tests/command_env/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | commands: 4 | env: 5 | description: Test command env 6 | env: 7 | ONE: "1" 8 | TWO: two 9 | BAR: 10 | sh: echo Bar 11 | FOO: 12 | checksum: [foo.txt] 13 | cmd: | 14 | echo ONE=${ONE} 15 | echo TWO=${TWO} 16 | echo BAR=${BAR} 17 | echo FOO=${FOO} 18 | -------------------------------------------------------------------------------- /tests/command_eval_env.bats: -------------------------------------------------------------------------------- 1 | load test_helpers 2 | 3 | setup() { 4 | load "${BATS_UTILS_PATH}/bats-support/load.bash" 5 | load "${BATS_UTILS_PATH}/bats-assert/load.bash" 6 | cd ./tests/command_eval_env 7 | } 8 | 9 | @test "command_eval_env: should compute and provide env to command" { 10 | run lets eval-env 11 | assert_success 12 | assert_line --index 0 "ONE=1" 13 | assert_line --index 1 "TWO=two" 14 | assert_line --index 2 "COMPUTED=Computed env" 15 | } 16 | -------------------------------------------------------------------------------- /tests/command_eval_env/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | commands: 4 | eval-env: 5 | description: Test command env 6 | env: 7 | ONE: "1" 8 | TWO: two 9 | eval_env: 10 | COMPUTED: echo "Computed env" 11 | cmd: | 12 | echo ONE=${ONE} 13 | echo TWO=${TWO} 14 | echo COMPUTED=${COMPUTED} 15 | -------------------------------------------------------------------------------- /tests/command_help.bats: -------------------------------------------------------------------------------- 1 | load test_helpers 2 | 3 | setup() { 4 | load "${BATS_UTILS_PATH}/bats-support/load.bash" 5 | load "${BATS_UTILS_PATH}/bats-assert/load.bash" 6 | cd ./tests/command_help 7 | } 8 | 9 | 10 | TEST_HELP_MESSAGE=$(cat <] 17 | EOF 18 | ) 19 | 20 | @test "command_help: help contains description and options" { 21 | run lets help test 22 | assert_success 23 | assert_output "${TEST_HELP_MESSAGE}" 24 | } 25 | 26 | 27 | TEST2_HELP_MESSAGE=$(cat <] 31 | EOF 32 | ) 33 | 34 | @test "command_help: must add new line between description and options" { 35 | run lets help test2 36 | assert_success 37 | assert_output "${TEST2_HELP_MESSAGE}" 38 | } -------------------------------------------------------------------------------- /tests/command_help/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | commands: 4 | test: 5 | description: | 6 | Run tests 7 | Unit tests are essention for success. 8 | 9 | Example: lets test 10 | options: | 11 | Usage: lets test [] 12 | cmd: echo "Tests" 13 | 14 | test2: 15 | description: Run tests 16 | options: | 17 | Usage: lets test2 [] 18 | cmd: echo "Tests" 19 | -------------------------------------------------------------------------------- /tests/command_name.bats: -------------------------------------------------------------------------------- 1 | setup() { 2 | load "${BATS_UTILS_PATH}/bats-support/load.bash" 3 | load "${BATS_UTILS_PATH}/bats-assert/load.bash" 4 | cd ./tests/command_name 5 | 6 | } 7 | 8 | @test "command name: can be y o yes" { 9 | run lets yes 10 | assert_success 11 | assert_line --index 0 "Hi from yes" 12 | } 13 | -------------------------------------------------------------------------------- /tests/command_name/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | commands: 4 | y: 5 | cmd: echo Hi from y 6 | 7 | yes: 8 | cmd: echo Hi from yes -------------------------------------------------------------------------------- /tests/command_options/echoArgs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | for i; do 3 | echo $i 4 | done 5 | -------------------------------------------------------------------------------- /tests/command_options/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | commands: 4 | test-options: 5 | description: Test options 6 | options: | 7 | Usage: 8 | lets test-options [--kv-opt=] [--bool-opt] [--attr=...] [...] 9 | 10 | Options: 11 | ... Positional args in the end 12 | --bool-opt, -b Boolean opt 13 | --kv-opt=, -K Key value opt 14 | --attr=... Repeated kv args 15 | cmd: | 16 | echo "Flags command" 17 | echo LETSOPT_KV_OPT=${LETSOPT_KV_OPT} 18 | echo LETSOPT_BOOL_OPT=${LETSOPT_BOOL_OPT} 19 | echo LETSOPT_ARGS=${LETSOPT_ARGS} 20 | echo LETSOPT_ATTR=${LETSOPT_ATTR} 21 | echo LETSCLI_KV_OPT=${LETSCLI_KV_OPT} 22 | echo LETSCLI_BOOL_OPT=${LETSCLI_BOOL_OPT} 23 | echo LETSCLI_ARGS=${LETSCLI_ARGS} 24 | echo LETSCLI_ATTR=${LETSCLI_ATTR} 25 | 26 | options-wrong-usage: 27 | options: | 28 | Usage: lets options-wrong-usage-xxx 29 | cmd: echo "Wrong" 30 | 31 | test-proxy-options: 32 | description: Test passthrough options (lets must append them to ./echoArgs.sh) 33 | cmd: 34 | - ./echoArgs.sh 35 | 36 | say: 37 | options: | 38 | Usage: lets say 39 | cmd: echo "Hi ${LETSOPT_SAY}" -------------------------------------------------------------------------------- /tests/command_persist_checksum/foo_1.txt: -------------------------------------------------------------------------------- 1 | foo1 -------------------------------------------------------------------------------- /tests/command_persist_checksum/foo_2.txt: -------------------------------------------------------------------------------- 1 | foo2 -------------------------------------------------------------------------------- /tests/command_persist_checksum/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | commands: 4 | persist-checksum: 5 | description: Test checksum 6 | persist_checksum: true 7 | checksum: 8 | - foo_*.txt 9 | cmd: | 10 | echo LETS_CHECKSUM=${LETS_CHECKSUM} 11 | echo LETS_CHECKSUM_CHANGED=${LETS_CHECKSUM_CHANGED} 12 | 13 | with-error-code-1: 14 | persist_checksum: true 15 | checksum: 16 | - foo_*.txt 17 | cmd: | 18 | echo LETS_CHECKSUM=${LETS_CHECKSUM} 19 | echo LETS_CHECKSUM_CHANGED=${LETS_CHECKSUM_CHANGED} 20 | exit 1 21 | 22 | persist-checksum-for-cmd-as-map: 23 | description: Persist checksum for cmd-as-map 24 | persist_checksum: true 25 | checksum: 26 | - foo_*.txt 27 | cmd: 28 | checksum: echo 1 LETS_CHECKSUM=${LETS_CHECKSUM} 29 | checksum_changed: echo 2 LETS_CHECKSUM_CHANGED=${LETS_CHECKSUM_CHANGED} -------------------------------------------------------------------------------- /tests/command_persist_checksum/use_persist_without_checksum/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | commands: 4 | use-persist-without-checksum: 5 | persist_checksum: true 6 | cmd: echo I'll fail -------------------------------------------------------------------------------- /tests/command_ref.bats: -------------------------------------------------------------------------------- 1 | setup() { 2 | load "${BATS_UTILS_PATH}/bats-support/load.bash" 3 | load "${BATS_UTILS_PATH}/bats-assert/load.bash" 4 | cd ./tests/command_ref 5 | 6 | } 7 | 8 | @test "command ref: run existing command with args from ref" { 9 | run lets hello-world 10 | assert_success 11 | assert_line --index 0 "Hello World" 12 | } 13 | 14 | @test "command ref: run existing command with args as list from ref" { 15 | run lets hello-list 16 | assert_success 17 | assert_line --index 0 "Hello Fellow friend" 18 | } 19 | 20 | @test "command ref: ref points to non-existing command" { 21 | run lets -c lets.no-command.yaml hi 22 | assert_failure 23 | assert_line --index 0 "lets: config error: failed to parse lets.no-command.yaml: ref 'hi' points to command 'hello' which is not exist" 24 | } 25 | -------------------------------------------------------------------------------- /tests/command_ref/lets.mixin.yaml: -------------------------------------------------------------------------------- 1 | commands: 2 | hello: 3 | cmd: echo Hello $@ 4 | -------------------------------------------------------------------------------- /tests/command_ref/lets.no-command.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | commands: 3 | hi: 4 | ref: hello -------------------------------------------------------------------------------- /tests/command_ref/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | mixins: 4 | - lets.mixin.yaml 5 | 6 | commands: 7 | hello-world: 8 | ref: hello 9 | args: World 10 | 11 | hello-list: 12 | ref: hello 13 | args: [Fellow, friend] 14 | -------------------------------------------------------------------------------- /tests/command_shell.bats: -------------------------------------------------------------------------------- 1 | load test_helpers 2 | 3 | setup() { 4 | load "${BATS_UTILS_PATH}/bats-support/load.bash" 5 | load "${BATS_UTILS_PATH}/bats-assert/load.bash" 6 | cd ./tests/command_shell 7 | } 8 | 9 | @test "command_shell: should run command using shell specified in command" { 10 | run lets show-shell 11 | assert_success 12 | assert_line --index 0 "/bin/sh" 13 | } 14 | -------------------------------------------------------------------------------- /tests/command_shell/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | commands: 4 | show-shell: 5 | shell: /bin/sh 6 | cmd: echo "$LETS_SHELL" 7 | -------------------------------------------------------------------------------- /tests/command_work_dir.bats: -------------------------------------------------------------------------------- 1 | load test_helpers 2 | 3 | setup() { 4 | load "${BATS_UTILS_PATH}/bats-support/load.bash" 5 | load "${BATS_UTILS_PATH}/bats-assert/load.bash" 6 | cd ./tests/command_work_dir 7 | } 8 | 9 | @test "command_work_dir: should run command in work_dir" { 10 | run lets print-file 11 | assert_success 12 | assert_line --index 0 "hi there" 13 | } 14 | -------------------------------------------------------------------------------- /tests/command_work_dir/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | commands: 4 | print-file: 5 | work_dir: project 6 | cmd: cat text.txt 7 | -------------------------------------------------------------------------------- /tests/command_work_dir/project/text.txt: -------------------------------------------------------------------------------- 1 | hi there -------------------------------------------------------------------------------- /tests/commands_required.bats: -------------------------------------------------------------------------------- 1 | setup() { 2 | load "${BATS_UTILS_PATH}/bats-support/load.bash" 3 | load "${BATS_UTILS_PATH}/bats-assert/load.bash" 4 | cd ./tests/commands_required 5 | } 6 | 7 | @test "commands_required: fail if no commands in lets config" { 8 | run lets 9 | assert_failure 10 | assert_line --index 0 "lets: config error: 'commands' can not be empty" 11 | } 12 | -------------------------------------------------------------------------------- /tests/commands_required/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash -------------------------------------------------------------------------------- /tests/completion.bats: -------------------------------------------------------------------------------- 1 | load test_helpers 2 | 3 | setup() { 4 | load "${BATS_UTILS_PATH}/bats-support/load.bash" 5 | load "${BATS_UTILS_PATH}/bats-assert/load.bash" 6 | cd ./tests/completion 7 | cleanup 8 | } 9 | 10 | @test "completion: should return completion if no lets.yaml" { 11 | cd ./no_lets_file 12 | cleanup 13 | 14 | LETS_CONFIG_DIR="no_lets_file" run lets completion 15 | assert_success 16 | assert_line --index 0 "Generates completion scripts for bash, zsh" 17 | [[ ! -d .lets ]] 18 | } 19 | 20 | @test "completion: should return completion if lets.yaml exists" { 21 | run lets completion 22 | assert_success 23 | assert_line --index 0 "Generates completion scripts for bash, zsh" 24 | [[ -d .lets ]] 25 | } 26 | 27 | @test "completion: should return list of commands" { 28 | run lets completion --commands 29 | assert_success 30 | assert_line --index 0 "bar" 31 | assert_line --index 1 "foo" 32 | } 33 | 34 | @test "completion: should return verbose list of commands" { 35 | run lets completion --commands --verbose 36 | assert_success 37 | assert_line --index 0 "bar:Print bar" 38 | assert_line --index 1 "foo:Print foo" 39 | } 40 | 41 | @test "completion: should return list of options for command" { 42 | run lets completion --options bar 43 | assert_success 44 | assert_line --index 0 "--debug" 45 | assert_line --index 1 "--env" 46 | } 47 | 48 | @test "completion: should return verbose list of options for command" { 49 | run lets completion --options bar --verbose 50 | assert_success 51 | assert_line --index 0 "--debug[Run with debug]" 52 | assert_line --index 1 "--env[Set env]" 53 | } 54 | -------------------------------------------------------------------------------- /tests/completion/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | commands: 4 | foo: 5 | description: Print foo 6 | cmd: echo "Foo" 7 | 8 | bar: 9 | description: Print bar 10 | options: | 11 | Usage: lets bar [--debug] [--env=] 12 | Options: 13 | --debug Run with debug 14 | --env=, -e Set env 15 | cmd: echo "Bar" -------------------------------------------------------------------------------- /tests/completion/no_lets_file/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lets-cli/lets/e7ef7eedf11e985a67d48f1d06bd255a3a524798/tests/completion/no_lets_file/.gitkeep -------------------------------------------------------------------------------- /tests/config_version.bats: -------------------------------------------------------------------------------- 1 | load test_helpers 2 | 3 | TEST_VERSION=0.0.2 4 | 5 | setup() { 6 | load "${BATS_UTILS_PATH}/bats-support/load.bash" 7 | load "${BATS_UTILS_PATH}/bats-assert/load.bash" 8 | # NOTICE to test this functionality properly we building lets with specified version ${TEST_VERSION} 9 | go build -ldflags="-X main.version=${TEST_VERSION}" -o ./tests/config_version/lets *.go 10 | cd ./tests/config_version 11 | } 12 | 13 | teardown() { 14 | rm -f ./lets 15 | } 16 | 17 | @test "config_version: if config version lower than lets version - its ok" { 18 | LETS_CONFIG=lets-with-version-0.0.1.yaml run ./lets 19 | 20 | assert_success 21 | assert_line --index 0 "A CLI task runner" 22 | } 23 | 24 | @test "config_version: if config version greater than lets version - fail - require upgrade" { 25 | LETS_CONFIG=lets-with-version-0.0.3.yaml run ./lets 26 | 27 | assert_failure 28 | assert_line --index 0 "lets: config error: config version '0.0.3' is not compatible with 'lets' version '0.0.2'. Please upgrade 'lets' to '0.0.3' using 'lets --upgrade' command or following documentation at https://lets-cli.org/docs/installation'" 29 | } 30 | 31 | @test "config_version: no version specified" { 32 | LETS_CONFIG=lets-without-version.yaml run ./lets 33 | assert_success 34 | assert_line --index 0 "A CLI task runner" 35 | } 36 | -------------------------------------------------------------------------------- /tests/config_version/lets-with-version-0.0.1.yaml: -------------------------------------------------------------------------------- 1 | version: '0.0.1' 2 | 3 | shell: bash 4 | 5 | commands: 6 | foo: 7 | description: Print foo 8 | cmd: echo "Foo" 9 | -------------------------------------------------------------------------------- /tests/config_version/lets-with-version-0.0.3.yaml: -------------------------------------------------------------------------------- 1 | version: '0.0.3' 2 | 3 | shell: bash 4 | 5 | commands: 6 | foo: 7 | description: Print foo 8 | cmd: echo "Foo" 9 | -------------------------------------------------------------------------------- /tests/config_version/lets-without-version.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | commands: 4 | foo: 5 | description: Print foo 6 | cmd: echo "Foo" 7 | -------------------------------------------------------------------------------- /tests/default_env.bats: -------------------------------------------------------------------------------- 1 | setup() { 2 | load "${BATS_UTILS_PATH}/bats-support/load.bash" 3 | load "${BATS_UTILS_PATH}/bats-assert/load.bash" 4 | cd ./tests/default_env 5 | TEST_DIR=$(pwd) 6 | } 7 | 8 | 9 | @test "LETS_COMMAND_NAME: contains command name" { 10 | run lets print-command-name-from-env 11 | assert_success 12 | assert_line --index 0 "print-command-name-from-env" 13 | } 14 | 15 | @test "LETS_COMMAND_ARGS: contains all positional args" { 16 | run lets print-command-args-from-env --foo --bar=x y 17 | 18 | assert_success 19 | assert_line --index 0 "--foo --bar=x y" 20 | } 21 | 22 | @test "\$@: contains all positional args" { 23 | run lets print-shell-args --foo --bar=x y 24 | 25 | assert_success 26 | assert_line --index 0 "--foo --bar=x y" 27 | } 28 | 29 | 30 | @test "LETS_CONFIG: contains config filename" { 31 | run lets print-env LETS_CONFIG 32 | 33 | assert_success 34 | assert_line --index 0 "LETS_CONFIG=lets.yaml" 35 | } 36 | 37 | @test "LETS_CONFIG_DIR: contains config dir" { 38 | run lets print-env LETS_CONFIG_DIR 39 | 40 | assert_success 41 | assert_line --index 0 "LETS_CONFIG_DIR=${TEST_DIR}" 42 | } 43 | 44 | @test "LETS_CONFIG_DIR: specified, overrides config dir" { 45 | LETS_CONFIG_DIR=./a run lets print-env LETS_CONFIG_DIR 46 | 47 | assert_success 48 | assert_line --index 0 "LETS_CONFIG_DIR=${TEST_DIR}/a" 49 | } 50 | 51 | @test "LETS_COMMAND_WORK_DIR: contains work_dir path if specified for command (in dir with lets config)" { 52 | cd ./a 53 | run lets print-workdir 54 | 55 | assert_success 56 | assert_line --index 0 "LETS_COMMAND_WORK_DIR=${TEST_DIR}/a/b" 57 | } 58 | 59 | 60 | @test "LETS_COMMAND_WORK_DIR: fail if LETS_CONFIG_DIR specified and no work_dir exists in LETS_CONFIG_DIR path" { 61 | LETS_CONFIG_DIR=./a run lets print-workdir 62 | 63 | assert_failure 64 | assert_line --index 0 "failed to run command 'print-workdir': chdir ${TEST_DIR}/b: no such file or directory" 65 | } -------------------------------------------------------------------------------- /tests/default_env/a/b/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lets-cli/lets/e7ef7eedf11e985a67d48f1d06bd255a3a524798/tests/default_env/a/b/.gitkeep -------------------------------------------------------------------------------- /tests/default_env/a/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | commands: 4 | print-env: 5 | options: | 6 | Usage: lets print-env 7 | cmd: echo ${LETSOPT_ENV}=`printenv ${LETSOPT_ENV}` 8 | 9 | print-workdir: 10 | work_dir: b 11 | cmd: echo LETS_COMMAND_WORK_DIR=${LETS_COMMAND_WORK_DIR} -------------------------------------------------------------------------------- /tests/default_env/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | commands: 4 | print-command-name-from-env: 5 | cmd: echo ${LETS_COMMAND_NAME} 6 | 7 | print-command-args-from-env: 8 | cmd: echo ${LETS_COMMAND_ARGS} 9 | 10 | print-shell-args: 11 | cmd: echo $@ 12 | 13 | print-env: 14 | options: | 15 | Usage: lets print-env 16 | cmd: echo ${LETSOPT_ENV}=`printenv ${LETSOPT_ENV}` -------------------------------------------------------------------------------- /tests/find_config.bats: -------------------------------------------------------------------------------- 1 | load test_helpers 2 | 3 | setup() { 4 | load "${BATS_UTILS_PATH}/bats-support/load.bash" 5 | load "${BATS_UTILS_PATH}/bats-assert/load.bash" 6 | cd ./tests/find_config 7 | find . -type d -name ".lets" -delete 8 | cleanup 9 | } 10 | 11 | @test "find_config: should find lets.yaml in parent dir" { 12 | cd a/b 13 | run lets foo 14 | assert_success 15 | assert_line --index 0 "foo" 16 | } 17 | 18 | @test "find_config: .lets must be created in the same dir where lets.yaml placed" { 19 | cd a/b 20 | run lets foo 21 | assert_success 22 | 23 | [[ ! -d .lets ]] 24 | [[ -d ../../.lets ]] 25 | } 26 | 27 | @test "find_config: LETS_CONFIG changes which config file to read" { 28 | LETS_CONFIG=lets1.yaml run lets hi 29 | 30 | assert_success 31 | assert_line --index 0 "Hi from lets1.yaml" 32 | } 33 | 34 | @test "find_config: --config changes which config file to read" { 35 | # also check that root --config and subcommand --config works together 36 | run lets --config lets1.yaml hi --config=xxx 37 | 38 | assert_success 39 | assert_line --index 0 "Hi from lets1.yaml" 40 | assert_line --index 1 "Option --config=xxx" 41 | } 42 | 43 | @test "find_config: subcommand --config must not change which config file to read" { 44 | run lets hi --config=xxx 45 | 46 | assert_success 47 | assert_line --index 0 "Hi from lets.yaml" 48 | assert_line --index 1 "Option --config=xxx" 49 | } -------------------------------------------------------------------------------- /tests/find_config/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lets-cli/lets/e7ef7eedf11e985a67d48f1d06bd255a3a524798/tests/find_config/.gitkeep -------------------------------------------------------------------------------- /tests/find_config/a/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lets-cli/lets/e7ef7eedf11e985a67d48f1d06bd255a3a524798/tests/find_config/a/.gitkeep -------------------------------------------------------------------------------- /tests/find_config/a/b/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lets-cli/lets/e7ef7eedf11e985a67d48f1d06bd255a3a524798/tests/find_config/a/b/.gitkeep -------------------------------------------------------------------------------- /tests/find_config/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | commands: 4 | foo: 5 | description: Print foo 6 | cmd: echo "foo" 7 | 8 | hi: 9 | description: Print config filename 10 | options: | 11 | Usage: lets hi [--config=] 12 | cmd: | 13 | echo Hi from "${LETS_CONFIG}" 14 | echo Option --config=${LETSOPT_CONFIG} 15 | -------------------------------------------------------------------------------- /tests/find_config/lets1.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | commands: 4 | hi: 5 | description: Print config filename 6 | options: | 7 | Usage: lets hi [--config=] 8 | cmd: | 9 | echo Hi from "${LETS_CONFIG}" 10 | echo Option --config=${LETSOPT_CONFIG} 11 | -------------------------------------------------------------------------------- /tests/global_before.bats: -------------------------------------------------------------------------------- 1 | load test_helpers 2 | 3 | setup() { 4 | load "${BATS_UTILS_PATH}/bats-support/load.bash" 5 | load "${BATS_UTILS_PATH}/bats-assert/load.bash" 6 | cd ./tests/global_before 7 | } 8 | 9 | @test "global_before: should insert before script for each cmd" { 10 | run lets hello 11 | assert_success 12 | assert_line --index 0 "Hello" 13 | } 14 | 15 | @test "global_before: should merge before scripts from mixins" { 16 | run lets world 17 | assert_success 18 | assert_line --index 0 "World" 19 | } 20 | -------------------------------------------------------------------------------- /tests/global_before/lets.mix.yaml: -------------------------------------------------------------------------------- 1 | before: | 2 | function say_hello() { 3 | echo Hello 4 | } 5 | -------------------------------------------------------------------------------- /tests/global_before/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | mixins: 4 | - lets.mix.yaml 5 | 6 | before: | 7 | function say_world() { 8 | echo World 9 | } 10 | 11 | 12 | commands: 13 | hello: 14 | description: echo Hello 15 | cmd: say_hello 16 | 17 | world: 18 | description: echo World 19 | cmd: say_world 20 | -------------------------------------------------------------------------------- /tests/global_env.bats: -------------------------------------------------------------------------------- 1 | load test_helpers 2 | 3 | setup() { 4 | load "${BATS_UTILS_PATH}/bats-support/load.bash" 5 | load "${BATS_UTILS_PATH}/bats-assert/load.bash" 6 | cd ./tests/global_env 7 | } 8 | 9 | @test "global_env: should provide env to command" { 10 | run lets global-env 11 | assert_success 12 | assert_line --index 0 "INT=1" 13 | assert_line --index 1 "STR=hi" 14 | assert_line --index 2 "STR_INT=1" 15 | assert_line --index 3 "BOOL=true" 16 | assert_line --index 4 "ORIGINAL=b" 17 | assert_line --index 5 "BAR=Bar" 18 | assert_line --index 6 "FOO=bb1da47569d9fbe3b5f2216fdbd4c9b040ccb5c1" 19 | } 20 | 21 | @test "global_env: should merge env with aliased map" { 22 | run lets -c lets.aliased-env.yaml env 23 | assert_success 24 | assert_line --index 0 "ONE=1" 25 | assert_line --index 1 "FOO=BAR" 26 | } 27 | -------------------------------------------------------------------------------- /tests/global_env/foo.txt: -------------------------------------------------------------------------------- 1 | 123 -------------------------------------------------------------------------------- /tests/global_env/lets.aliased-env.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | x-default-env: &default-env 4 | FOO: BAR 5 | env: 6 | ONE: "1" 7 | FOO: BAZ 8 | <<: *default-env 9 | 10 | commands: 11 | env: 12 | cmd: | 13 | echo ONE=${ONE} 14 | echo FOO=${FOO} 15 | -------------------------------------------------------------------------------- /tests/global_env/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | env: 4 | INT: 1 5 | STR: "hi" 6 | STR_INT: "1" 7 | BOOL: true 8 | ORIGINAL: "a" 9 | BAR: 10 | sh: echo Bar 11 | FOO: 12 | checksum: [foo.txt] 13 | 14 | commands: 15 | global-env: 16 | description: Test global env 17 | env: 18 | ORIGINAL: "b" 19 | cmd: | 20 | echo INT=${INT} 21 | echo STR=${STR} 22 | echo STR_INT=${STR_INT} 23 | echo BOOL=${BOOL} 24 | echo ORIGINAL=${ORIGINAL} 25 | echo BAR=${BAR} 26 | echo FOO=${FOO} 27 | -------------------------------------------------------------------------------- /tests/global_eval_env.bats: -------------------------------------------------------------------------------- 1 | load test_helpers 2 | 3 | setup() { 4 | load "${BATS_UTILS_PATH}/bats-support/load.bash" 5 | load "${BATS_UTILS_PATH}/bats-assert/load.bash" 6 | cd ./tests/global_eval_env 7 | } 8 | 9 | @test "global_eval_env: should compute env from eval_env and provide env to command" { 10 | run lets global-eval_env 11 | assert_success 12 | assert_line --index 0 "ONE=1" 13 | assert_line --index 1 "TWO=2" 14 | } 15 | -------------------------------------------------------------------------------- /tests/global_eval_env/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | eval_env: 4 | TWO: echo "2" 5 | 6 | commands: 7 | global-eval_env: 8 | description: Test global env 9 | env: 10 | ONE: "1" 11 | cmd: | 12 | echo ONE=${ONE} 13 | echo TWO=${TWO} 14 | -------------------------------------------------------------------------------- /tests/help.bats: -------------------------------------------------------------------------------- 1 | load test_helpers 2 | 3 | setup() { 4 | load "${BATS_UTILS_PATH}/bats-support/load.bash" 5 | load "${BATS_UTILS_PATH}/bats-assert/load.bash" 6 | cd ./tests/help 7 | } 8 | 9 | HELP_MESSAGE=$(cat <