├── tests ├── command_env │ ├── foo.txt │ ├── lets.aliased-env.yaml │ └── lets.yaml ├── find_config │ ├── .gitkeep │ ├── a │ │ ├── .gitkeep │ │ └── b │ │ │ └── .gitkeep │ ├── lets1.yaml │ └── lets.yaml ├── global_env │ ├── foo.txt │ ├── lets.aliased-env.yaml │ └── lets.yaml ├── no_lets_file │ ├── .gitkeep │ └── broken_lets.yaml ├── default_env │ ├── a │ │ ├── b │ │ │ └── .gitkeep │ │ └── lets.yaml │ └── lets.yaml ├── command_checksum │ ├── bar_1.txt │ ├── foo_1.txt │ ├── foo_2.txt │ ├── subdir │ │ └── .gitkeep │ └── lets.yaml ├── completion │ ├── no_lets_file │ │ └── .gitkeep │ └── lets.yaml ├── command_persist_checksum │ ├── foo_1.txt │ ├── foo_2.txt │ ├── use_persist_without_checksum │ │ └── lets.yaml │ └── lets.yaml ├── command_work_dir │ ├── project │ │ └── text.txt │ └── lets.yaml ├── commands_required │ └── lets.yaml ├── init │ └── exists │ │ └── lets.yaml ├── command_ref │ ├── lets.mixin.yaml │ ├── lets.no-command.yaml │ └── lets.yaml ├── command_options │ ├── echoArgs.sh │ └── lets.yaml ├── global_before │ ├── lets.mix.yaml │ └── lets.yaml ├── mixins │ ├── lets.yaml │ └── lets.mix.yaml ├── command_shell │ └── lets.yaml ├── command_name │ └── lets.yaml ├── config_version │ ├── lets-without-version.yaml │ ├── lets-with-version-0.0.1.yaml │ └── lets-with-version-0.0.3.yaml ├── command_depends │ ├── lets-parallel-in-depends.yaml │ └── lets.yaml ├── help │ └── lets.yaml ├── no_depends │ └── lets.yaml ├── global_eval_env │ └── lets.yaml ├── root_flags │ ├── lets.yaml │ └── lets1.yaml ├── mixins.bats ├── command_eval_env │ └── lets.yaml ├── command_name.bats ├── test_helpers.bash ├── zsh_completion │ ├── lets.yaml │ └── completion_helper.sh ├── command_work_dir.bats ├── commands_required.bats ├── command_shell.bats ├── command_help │ └── lets.yaml ├── global_eval_env.bats ├── override_env │ └── lets.yaml ├── version.bats ├── command_eval_env.bats ├── global_before.bats ├── no_depends.bats ├── command_after │ └── lets.yaml ├── command_env.bats ├── command_ref.bats ├── command_docopt_cmd_placeholder │ └── lets.yaml ├── init.bats ├── global_env.bats ├── command_help.bats ├── command_docopt_cmd_placeholder.bats ├── command_after.bats ├── zsh_completion.bats_ ├── command_cmd │ └── lets.yaml ├── config_version.bats ├── find_config.bats ├── override_env.bats ├── completion.bats ├── no_lets_file.bats ├── command_depends.bats ├── default_env.bats ├── command_checksum.bats ├── command_cmd.bats ├── help.bats └── root_flags.bats ├── docs ├── static │ ├── CNAME │ └── img │ │ ├── logo.png │ │ ├── favicon.ico │ │ ├── lets-architecture-diagram.png │ │ ├── check.svg │ │ ├── doc.svg │ │ └── gear.svg ├── README.md ├── docs │ ├── contribute.md │ ├── completion.md │ ├── examples.md │ ├── example_js.md │ ├── cli.md │ ├── basic_usage.md │ ├── env.md │ ├── quick_start.md │ ├── what_is_lets.md │ ├── development.md │ ├── architecture.md │ ├── ide_support.md │ ├── best_practices.md │ └── installation.mdx ├── .gitignore ├── blog │ └── 2020-05-24-history-of-lets.md ├── package.json ├── src │ ├── css │ │ └── custom.css │ └── pages │ │ ├── styles.module.css │ │ └── index.js ├── lets-architecture-diagram.drawio ├── sidebars.js └── docusaurus.config.js ├── examples └── python │ ├── .gitignore │ ├── requirements.txt │ ├── Dockerfile │ ├── server │ ├── __pycache__ │ │ └── __main__.cpython-38.pyc │ └── __main__.py │ ├── Readme.md │ ├── docker-compose.yaml │ └── lets.yaml ├── test ├── args.go └── temp_file.go ├── config ├── find_test.go ├── load_test.go ├── workdir.go ├── config │ ├── version.go │ ├── clone.go │ ├── checksum.go │ ├── ref.go │ ├── config_test.go │ ├── cmd.go │ ├── mixin_test.go │ ├── deps.go │ ├── cmd_test.go │ └── mixin.go ├── load.go ├── path │ └── path.go ├── find.go ├── validate.go └── validate_test.go ├── lets.build.yaml ├── util ├── file.go ├── version.go └── dir.go ├── lsp ├── storage.go ├── utils.go └── server.go ├── .github ├── dependabot.yml └── workflows │ ├── docs.yml │ ├── test.yaml │ └── release.yaml ├── executor ├── env_test.go └── env.go ├── .run ├── lets.run.xml ├── lets --version.run.xml └── lets print-env.run.xml ├── cmd ├── lsp.go ├── self.go ├── root_test.go └── root.go ├── README.md ├── .gitignore ├── logging ├── writerhook.go ├── log_test.go ├── formatter.go └── log.go ├── set ├── set.go └── set_test.go ├── .vscode └── launch.json ├── Dockerfile ├── docker-compose.yml ├── .golangci.yaml ├── LICENSE ├── env └── env.go ├── workdir └── workdir.go ├── go.mod ├── .goreleaser.yml ├── upgrade ├── upgrade.go ├── upgrade_test.go └── registry │ └── registry.go ├── docopt └── docopts.go ├── lets.yaml └── checksum └── checksum_test.go /tests/command_env/foo.txt: -------------------------------------------------------------------------------- 1 | 123 -------------------------------------------------------------------------------- /tests/find_config/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/find_config/a/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/global_env/foo.txt: -------------------------------------------------------------------------------- 1 | 123 -------------------------------------------------------------------------------- /tests/no_lets_file/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/static/CNAME: -------------------------------------------------------------------------------- 1 | lets-cli.org -------------------------------------------------------------------------------- /tests/default_env/a/b/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/find_config/a/b/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /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/subdir/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/completion/no_lets_file/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/python/.gitignore: -------------------------------------------------------------------------------- 1 | .lets 2 | venv -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Updating website docs 2 | 3 | -------------------------------------------------------------------------------- /tests/command_persist_checksum/foo_1.txt: -------------------------------------------------------------------------------- 1 | foo1 -------------------------------------------------------------------------------- /tests/command_persist_checksum/foo_2.txt: -------------------------------------------------------------------------------- 1 | foo2 -------------------------------------------------------------------------------- /tests/command_work_dir/project/text.txt: -------------------------------------------------------------------------------- 1 | hi there -------------------------------------------------------------------------------- /tests/commands_required/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash -------------------------------------------------------------------------------- /tests/no_lets_file/broken_lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | commands: 3 | 1 -------------------------------------------------------------------------------- /examples/python/requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.7.4 2 | 3 | ipython==8.10.0 -------------------------------------------------------------------------------- /tests/init/exists/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | commands: 3 | hi: 4 | cmd: echo Hi -------------------------------------------------------------------------------- /tests/command_ref/lets.mixin.yaml: -------------------------------------------------------------------------------- 1 | commands: 2 | hello: 3 | cmd: echo Hello $@ 4 | -------------------------------------------------------------------------------- /tests/command_options/echoArgs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | for i; do 3 | echo $i 4 | done 5 | -------------------------------------------------------------------------------- /tests/command_ref/lets.no-command.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | commands: 3 | hi: 4 | ref: hello -------------------------------------------------------------------------------- /docs/static/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lets-cli/lets/HEAD/docs/static/img/logo.png -------------------------------------------------------------------------------- /docs/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lets-cli/lets/HEAD/docs/static/img/favicon.ico -------------------------------------------------------------------------------- /tests/global_before/lets.mix.yaml: -------------------------------------------------------------------------------- 1 | before: | 2 | function say_hello() { 3 | echo Hello 4 | } 5 | -------------------------------------------------------------------------------- /tests/mixins/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | mixins: 4 | - lets.mix.yaml 5 | - -lets.no.yaml 6 | -------------------------------------------------------------------------------- /tests/mixins/lets.mix.yaml: -------------------------------------------------------------------------------- 1 | commands: 2 | hello-from-minix: 3 | description: Print Hello 4 | cmd: echo "Hello" 5 | -------------------------------------------------------------------------------- /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/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | commands: 4 | print-file: 5 | work_dir: project 6 | cmd: cat text.txt 7 | -------------------------------------------------------------------------------- /docs/static/img/lets-architecture-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lets-cli/lets/HEAD/docs/static/img/lets-architecture-diagram.png -------------------------------------------------------------------------------- /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/config_version/lets-without-version.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | commands: 4 | foo: 5 | description: Print foo 6 | cmd: echo "Foo" 7 | -------------------------------------------------------------------------------- /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/server/__pycache__/__main__.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lets-cli/lets/HEAD/examples/python/server/__pycache__/__main__.cpython-38.pyc -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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) -------------------------------------------------------------------------------- /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_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] -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/help/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | commands: 4 | _x: 5 | description: Hidden x 6 | cmd: echo "x" 7 | 8 | foo: 9 | description: Print foo 10 | cmd: echo "Foo" 11 | 12 | bar: 13 | description: Print bar 14 | cmd: echo "Bar" -------------------------------------------------------------------------------- /tests/no_depends/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | env: 4 | NAME: "John" 5 | 6 | commands: 7 | ping: 8 | cmd: echo Ping 9 | pong: 10 | cmd: echo Pong 11 | ping-pong: 12 | depends: 13 | - ping 14 | - pong 15 | cmd: echo Done 16 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /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/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/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/root_flags/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | commands: 4 | foo: 5 | options: | 6 | Usage: lets foo [--debug] [--config=] [--only=] 7 | cmd: | 8 | echo DEBUG=${LETSOPT_DEBUG} 9 | echo CONFIG=${LETSOPT_CONFIG} 10 | echo ONLY=${LETSOPT_ONLY} 11 | -------------------------------------------------------------------------------- /tests/root_flags/lets1.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | commands: 4 | bar: 5 | options: | 6 | Usage: lets bar [--debug] [--config=] [--only=] 7 | cmd: | 8 | echo DEBUG=${LETSOPT_DEBUG} 9 | echo CONFIG=${LETSOPT_CONFIG} 10 | echo ONLY=${LETSOPT_ONLY} 11 | -------------------------------------------------------------------------------- /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} -------------------------------------------------------------------------------- /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_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 | -------------------------------------------------------------------------------- /tests/mixins.bats: -------------------------------------------------------------------------------- 1 | setup() { 2 | load "${BATS_UTILS_PATH}/bats-support/load.bash" 3 | load "${BATS_UTILS_PATH}/bats-assert/load.bash" 4 | cd ./tests/mixins 5 | } 6 | 7 | @test "mixins: mixins works" { 8 | run lets hello-from-minix 9 | assert_success 10 | assert_line --index 0 "Hello" 11 | } 12 | -------------------------------------------------------------------------------- /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_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_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/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/test_helpers.bash: -------------------------------------------------------------------------------- 1 | cleanup() { 2 | rm -rf .lets 3 | } 4 | 5 | # Usage: 6 | 7 | # my_array=(2,4,1) 8 | # sort_array my_array 9 | # printf "%s" "${my_array[@]}" # -- will print 1 2 4 10 | sort_array() { 11 | local -n array_to_sort=$1 12 | IFS=$'\n' array_to_sort=($(sort <<<"${array_to_sort[*]}")) 13 | unset IFS 14 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/zsh_completion/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | commands: 4 | run: 5 | description: Run application 6 | options: | 7 | usage: lets run [--debug] [--env=] 8 | options: 9 | -d, -debug Run in debug mode 10 | --env= Run with env 11 | cmd: echo Run debug=${LETSOPT_DEBUG} env=${LETSOPT_ENV} -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /util/file.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "os" 4 | 5 | // FileExists checks if a file exists and is not a directory before we 6 | // try using it to prevent further errors. 7 | func FileExists(filename string) bool { 8 | info, err := os.Stat(filename) 9 | if os.IsNotExist(err) { 10 | return false 11 | } 12 | 13 | return !info.IsDir() 14 | } 15 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /util/version.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/coreos/go-semver/semver" 7 | ) 8 | 9 | func ParseVersion(version string) (*semver.Version, error) { 10 | v, err := semver.NewVersion(version) 11 | if err != nil { 12 | return nil, fmt.Errorf("can not create semver version from %s: %w", version, err) 13 | } 14 | 15 | return v, nil 16 | } 17 | -------------------------------------------------------------------------------- /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/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/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/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}` -------------------------------------------------------------------------------- /util/dir.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | func SafeCreateDir(dirPath string) error { 9 | if err := os.Mkdir(dirPath, 0o755); err != nil { 10 | if os.IsExist(err) { 11 | // its ok if we already have a dir, just return 12 | return nil 13 | } 14 | 15 | return fmt.Errorf("failed to create %s dir: %w", dirPath, err) 16 | } 17 | 18 | return nil 19 | } 20 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/override_env/lets.yaml: -------------------------------------------------------------------------------- 1 | shell: bash 2 | 3 | env: 4 | NAME: "John" 5 | 6 | commands: 7 | say_hello_global_env: 8 | description: Say hello 9 | cmd: echo "Hello ${NAME}" 10 | 11 | say_hello_command_env: 12 | description: Say hello 13 | env: 14 | NAME: Rick 15 | cmd: echo "Hello ${NAME}" 16 | 17 | say_command: 18 | description: Say command name 19 | cmd: echo $LETS_COMMAND_NAME 20 | 21 | print-foo: 22 | cmd: echo FOO=${FOO} -------------------------------------------------------------------------------- /tests/version.bats: -------------------------------------------------------------------------------- 1 | setup() { 2 | load "${BATS_UTILS_PATH}/bats-support/load.bash" 3 | load "${BATS_UTILS_PATH}/bats-assert/load.bash" 4 | } 5 | 6 | @test "version: show lets version for -v" { 7 | run lets -v 8 | assert_success 9 | assert_line --index 0 "lets version 0.0.0-dev" 10 | } 11 | 12 | @test "version: show lets version for --version" { 13 | run lets --version 14 | assert_success 15 | assert_line --index 0 "lets version 0.0.0-dev" 16 | } 17 | -------------------------------------------------------------------------------- /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). -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.run/lets.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 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_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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/no_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/no_depends 7 | } 8 | 9 | @test "no_depends: should skip depends for running command" { 10 | run lets ping-pong 11 | assert_success 12 | assert_line --index 0 "Ping" 13 | assert_line --index 1 "Pong" 14 | assert_line --index 2 "Done" 15 | 16 | run lets --no-depends ping-pong 17 | assert_success 18 | assert_line --index 0 "Done" 19 | } 20 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | } -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_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/init.bats: -------------------------------------------------------------------------------- 1 | setup() { 2 | load "${BATS_UTILS_PATH}/bats-support/load.bash" 3 | load "${BATS_UTILS_PATH}/bats-assert/load.bash" 4 | cd ./tests/init 5 | rm -f lets.yaml 6 | } 7 | 8 | @test "--init: init config if not exist" { 9 | [[ ! -f lets.yaml ]] 10 | run lets --init 11 | assert_success 12 | [[ -f lets.yaml ]] 13 | assert_line --index 0 "lets.yaml created in the current directory" 14 | run lets hello bro 15 | assert_success 16 | assert_line --index 0 "Hello, bro!" 17 | } 18 | 19 | @test "--init: do not init config if already exist" { 20 | cd ./exists 21 | [[ -f lets.yaml ]] 22 | run lets --init 23 | assert_failure 24 | assert_output --partial "lets.yaml already exists in" 25 | 26 | run lets hi 27 | assert_success 28 | assert_line --index 0 "Hi" 29 | } 30 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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_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_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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/zsh_completion.bats_: -------------------------------------------------------------------------------- 1 | load test_helpers 2 | load "${BATS_UTILS_PATH}/bats-support/load.bash" 3 | load "${BATS_UTILS_PATH}/bats-assert/load.bash" 4 | 5 | setup() { 6 | cd ./tests/zsh_completion 7 | cleanup 8 | } 9 | 10 | @test "zsh_completion: should complete run command" { 11 | run ./completion_helper.sh "lets r" 12 | 13 | assert_success 14 | assert_output "run" 15 | } 16 | 17 | @test "zsh_completion: should complete run command options" { 18 | run ./completion_helper.sh "lets run --" 19 | 20 | assert_success 21 | assert_output <] [--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}" -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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}" -------------------------------------------------------------------------------- /workdir/workdir.go: -------------------------------------------------------------------------------- 1 | package workdir 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/lithammer/dedent" 10 | log "github.com/sirupsen/logrus" 11 | ) 12 | 13 | const dotLetsDir = ".lets" 14 | 15 | func getDefaltLetsConfig(version string) string { 16 | template := dedent.Dedent(fmt.Sprintf(` 17 | version: "%s" 18 | shell: bash 19 | commands: 20 | hello: 21 | description: Say hello 22 | options: | 23 | Usage: lets hello [] 24 | Examples: 25 | lets hello 26 | lets hello Friend 27 | cmd: echo Hello, "${LETSOPT_NAME:-world}"! 28 | `, version)) 29 | return strings.TrimLeft(template, "\n") 30 | } 31 | 32 | func GetDotLetsDir(workDir string) (string, error) { 33 | return filepath.Abs(filepath.Join(workDir, dotLetsDir)) 34 | } 35 | 36 | // InitLetsFile creates lets.yaml int the current dir. 37 | func InitLetsFile(workDir string, version string) error { 38 | configfile := filepath.Join(workDir, "lets.yaml") 39 | 40 | if _, err := os.Stat(configfile); err == nil { 41 | return fmt.Errorf("lets.yaml already exists in %s", workDir) 42 | } 43 | 44 | output := getDefaltLetsConfig(version) 45 | //#nosec G306 46 | if err := os.WriteFile(configfile, []byte(output), 0o644); err != nil { 47 | return err 48 | } 49 | 50 | log.Println("lets.yaml created in the current directory") 51 | 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/override_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/override_env 7 | } 8 | 9 | @test "override_env: should use default global env for running command" { 10 | run lets say_hello_global_env 11 | assert_success 12 | assert_line --index 0 "Hello John" 13 | } 14 | 15 | @test "override_env: should use default command env for running command" { 16 | run lets say_hello_command_env 17 | assert_success 18 | assert_line --index 0 "Hello Rick" 19 | } 20 | 21 | @test "override_env: should override global env for running command with -E" { 22 | run lets -E NAME=Morty say_hello_global_env 23 | assert_success 24 | assert_line --index 0 "Hello Morty" 25 | } 26 | 27 | @test "override_env: should override command env for running command with -E" { 28 | run lets -E NAME=Morty say_hello_command_env 29 | assert_success 30 | assert_line --index 0 "Hello Morty" 31 | } 32 | 33 | @test "override_env: should override command env for running command with --env" { 34 | run lets --env NAME=Morty say_hello_command_env 35 | assert_success 36 | assert_line --index 0 "Hello Morty" 37 | } 38 | 39 | @test "override_env: should set env variable for command with -E even if there is no either global or command env var" { 40 | run lets -E FOO=BAR print-foo 41 | assert_success 42 | assert_line --index 0 "FOO=BAR" 43 | } 44 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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= -------------------------------------------------------------------------------- /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/no_lets_file.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/no_lets_file 7 | cleanup 8 | } 9 | 10 | NOT_EXISTED_LETS_FILE="lets-not-existed.yaml" 11 | 12 | @test "no_lets_file: should not create .lets dir" { 13 | LETS_CONFIG=${NOT_EXISTED_LETS_FILE} run lets 14 | 15 | assert_failure 16 | [[ ! -d .lets ]] 17 | } 18 | 19 | @test "no_lets_file: when wrong config specified with LETS_CONFIG - show find config error" { 20 | LETS_CONFIG=${NOT_EXISTED_LETS_FILE} run lets 21 | 22 | assert_failure 23 | assert_output --partial "lets: config error: file does not exist: ${NOT_EXISTED_LETS_FILE}" 24 | } 25 | 26 | @test "no_lets_file: show config read error (broken config)" { 27 | LETS_CONFIG=broken_lets.yaml run lets 28 | 29 | assert_failure 30 | 31 | assert_line --index 0 "lets: config error: failed to parse broken_lets.yaml: yaml: unmarshal errors:" 32 | assert_line --index 1 " line 3: cannot unmarshal !!int \`1\` into config.Commands" 33 | } 34 | 35 | @test "no_lets_file: show help for 'lets help' even if no config file" { 36 | LETS_CONFIG=${NOT_EXISTED_LETS_FILE} run lets help 37 | 38 | assert_success 39 | assert_line --index 0 "A CLI task runner" 40 | } 41 | 42 | @test "no_lets_file: show help for 'lets --help' even if no config file" { 43 | LETS_CONFIG=${NOT_EXISTED_LETS_FILE} run lets --help 44 | assert_success 45 | assert_line --index 0 "A CLI task runner" 46 | } 47 | 48 | @test "no_lets_file: show help for 'lets -h' even if no config file" { 49 | LETS_CONFIG=${NOT_EXISTED_LETS_FILE} run lets -h 50 | assert_success 51 | assert_line --index 0 "A CLI task runner" 52 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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) -------------------------------------------------------------------------------- /tests/zsh_completion/completion_helper.sh: -------------------------------------------------------------------------------- 1 | #! /bin/zsh 2 | autoload -Uz compinit && compinit 3 | 4 | eval "$(echo "$(lets completion -s zsh)" | sed 's/#compdef/compdef/')" 5 | 6 | comptest () { 7 | # Gather all matching completions in this array. 8 | # -U discards duplicates. 9 | typeset -aU completions=() 10 | 11 | # Override the builtin compadd command. 12 | compadd () { 13 | # Gather all matching completions for this call in $reply. 14 | # Note that this call overwrites the specified array. 15 | # Therefore we cannot use $completions directly. 16 | builtin compadd -O reply "$@" 17 | 18 | completions+=("$reply[@]") # Collect them. 19 | builtin compadd "$@" # Run the actual command. 20 | } 21 | 22 | # Bind a custom widget to TAB. 23 | bindkey "^I" complete-word 24 | zle -C {,,}complete-word 25 | complete-word () { 26 | # Make the completion system believe we're on a normal 27 | # command line, not in vared. 28 | unset 'compstate[vared]' 29 | 30 | _main_complete "$@" # Generate completions. 31 | 32 | # Print out our completions. 33 | # Use of ^B and ^C as delimiters here is arbitrary. 34 | # Just use something that won't normally be printed. 35 | print -n $'\C-B' 36 | print -nlr -- "$completions[@]" # Print one per line. 37 | print -n $'\C-C' 38 | exit 39 | } 40 | 41 | vared -c tmp 42 | } 43 | 44 | generate_completions() { 45 | zmodload zsh/zpty # Load the pseudo terminal module. 46 | zpty {,}comptest lets # Create a new pty and run our function in it. 47 | 48 | # Simulate a command being typed, ending with TAB to get completions. 49 | printf $'%s\t' $1 | zpty -w comptest 50 | 51 | # Read up to the first delimiter. Discard all of this. 52 | zpty -r comptest REPLY $'*\C-B' 53 | 54 | zpty -r comptest REPLY $'*\C-C' # Read up to the second delimiter. 55 | 56 | # Print out the results. 57 | print -r -- "${REPLY%$'\C-C'}" # Trim off the ^C, just in case. 58 | 59 | zpty -d comptest # Delete the pty. 60 | } 61 | 62 | # Example usage. 63 | # source ./completion_helper.sh 64 | # generate_completions "lets r" 65 | generate_completions "$@" 66 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /config/config/mixin_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestNormalizeContentType(t *testing.T) { 8 | tests := []struct { 9 | name string 10 | contentType string 11 | expectedResult string 12 | }{ 13 | { 14 | name: "simple content type", 15 | contentType: "text/plain", 16 | expectedResult: "text/plain", 17 | }, 18 | { 19 | name: "content type with charset parameter", 20 | contentType: "text/yaml; charset=utf-8", 21 | expectedResult: "text/yaml", 22 | }, 23 | { 24 | name: "content type with multiple parameters", 25 | contentType: "application/yaml; charset=utf-8; boundary=something", 26 | expectedResult: "application/yaml", 27 | }, 28 | { 29 | name: "content type with quoted parameters", 30 | contentType: `text/x-yaml; charset="utf-8"`, 31 | expectedResult: "text/x-yaml", 32 | }, 33 | { 34 | name: "invalid content type", 35 | contentType: "invalid/content/type; malformed", 36 | expectedResult: "invalid/content/type; malformed", 37 | }, 38 | { 39 | name: "empty content type", 40 | contentType: "", 41 | expectedResult: "", 42 | }, 43 | { 44 | name: "content type with spaces", 45 | contentType: "text/plain ; charset=utf-8", 46 | expectedResult: "text/plain", 47 | }, 48 | } 49 | 50 | for _, tt := range tests { 51 | t.Run(tt.name, func(t *testing.T) { 52 | result := normalizeContentType(tt.contentType) 53 | if result != tt.expectedResult { 54 | t.Errorf("normalizeContentType(%q) = %q, want %q", tt.contentType, result, tt.expectedResult) 55 | } 56 | }) 57 | } 58 | } 59 | 60 | func TestAllowedContentTypes(t *testing.T) { 61 | // Test that our normalization works with the allowed content types 62 | testCases := []string{ 63 | "text/plain", 64 | "text/yaml", 65 | "text/x-yaml", 66 | "application/yaml", 67 | "application/x-yaml", 68 | } 69 | 70 | for _, contentType := range testCases { 71 | // Test without parameters 72 | if !allowedContentTypes.Contains(contentType) { 73 | t.Errorf("allowedContentTypes should contain %q", contentType) 74 | } 75 | 76 | // Test with charset parameter 77 | withCharset := contentType + "; charset=utf-8" 78 | normalized := normalizeContentType(withCharset) 79 | if !allowedContentTypes.Contains(normalized) { 80 | t.Errorf("normalized content type %q should be allowed (original: %q)", normalized, withCharset) 81 | } 82 | } 83 | 84 | // Test that disallowed content types are rejected 85 | disallowedTypes := []string{ 86 | "application/json", 87 | "text/html", 88 | "application/xml", 89 | } 90 | 91 | for _, contentType := range disallowedTypes { 92 | if allowedContentTypes.Contains(contentType) { 93 | t.Errorf("allowedContentTypes should not contain %q", contentType) 94 | } 95 | 96 | // Test with parameters 97 | withCharset := contentType + "; charset=utf-8" 98 | normalized := normalizeContentType(withCharset) 99 | if allowedContentTypes.Contains(normalized) { 100 | t.Errorf("normalized content type %q should not be allowed (original: %q)", normalized, withCharset) 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /docs/static/img/gear.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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. Define `lets_ls` lsp config 66 | 67 | Requires `neovim >= 0.11.2` 68 | 69 | ```lua 70 | vim.lsp.config.lets_ls = { 71 | cmd = { "lets", "self", "lsp" }, 72 | filetypes = { "yaml.lets" }, 73 | root_markers = { "lets.yaml" }, 74 | } 75 | 76 | vim.lsp.enable("lets_ls") 77 | ``` 78 | 79 | ### JSON Schema 80 | 81 | In order to get autocomplete and filetype support in any editor, you can use the JSON schema file provided by Lets. 82 | 83 | #### VSCode 84 | 85 | To use the JSON schema in VSCode, you can use the [YAML extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml). 86 | 87 | Add the following to your `settings.json`: 88 | 89 | ```json 90 | { 91 | "yaml.schemas": { 92 | "https://lets-cli.org/schema.json": [ 93 | "**/lets.yaml", 94 | "**/lets*.yaml", 95 | ] 96 | } 97 | } 98 | ``` 99 | 100 | #### Neovim 101 | 102 | To use the JSON schema in Neovim, you can use the `nvim-lspconfig` with `SchemaStore` plugin. 103 | 104 | In your `nvim-lspconfig` configuration, add the following: 105 | 106 | ```lua 107 | servers = { 108 | yamlls = { 109 | on_new_config = function(new_config) 110 | local yaml_schemas = require("schemastore").yaml.schemas({ 111 | extra = { 112 | { 113 | description = "Lets JSON schema", 114 | fileMatch = { "lets.yaml", "lets*.yaml" }, 115 | name = "lets.schema.json", 116 | url = "https://lets-cli.org/schema.json", 117 | }, 118 | }, 119 | }) 120 | new_config.settings.yaml.schemas = vim.tbl_deep_extend("force", new_config.settings.yaml.schemas or {}, yaml_schemas) 121 | end, 122 | }, 123 | } 124 | ``` 125 | 126 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /upgrade/upgrade.go: -------------------------------------------------------------------------------- 1 | package upgrade 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "path" 8 | "runtime" 9 | 10 | "github.com/lets-cli/lets/upgrade/registry" 11 | log "github.com/sirupsen/logrus" 12 | ) 13 | 14 | type Upgrader interface { 15 | Upgrade() error 16 | } 17 | 18 | type BinaryUpgrader struct { 19 | registry registry.RepoRegistry 20 | currentVersion string 21 | binaryPath string 22 | downloadPath string 23 | backupPath string 24 | } 25 | 26 | func NewBinaryUpgrader(reg registry.RepoRegistry, currentVersion string) (*BinaryUpgrader, error) { 27 | executablePath, err := binaryPath() 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | return &BinaryUpgrader{ 33 | registry: reg, 34 | currentVersion: currentVersion, 35 | // TODO rewrite all paths with home dir 36 | binaryPath: executablePath, 37 | downloadPath: path.Join(os.TempDir(), "lets.download"), 38 | backupPath: path.Join(os.TempDir(), "lets.backup"), 39 | }, nil 40 | } 41 | 42 | func (up *BinaryUpgrader) Upgrade() error { 43 | latestVersion, err := up.registry.GetLatestRelease() 44 | if err != nil { 45 | return fmt.Errorf("failed to get latest release version: %w", err) 46 | } 47 | 48 | if up.currentVersion == latestVersion { 49 | log.Printf("Lets is up-to-date") 50 | 51 | return nil 52 | } 53 | 54 | packageName, err := up.registry.GetPackageName(runtime.GOOS, runtime.GOARCH) 55 | if err != nil { 56 | return fmt.Errorf("failed to get package name: %w", err) 57 | } 58 | 59 | log.Printf("Downloading latest release %s...", latestVersion) 60 | 61 | err = up.registry.DownloadReleaseBinary( 62 | packageName, 63 | latestVersion, 64 | up.downloadPath, 65 | ) 66 | if err != nil { 67 | return fmt.Errorf("failed to download release %s version %s: %w", packageName, latestVersion, err) 68 | } 69 | 70 | err = backupExecutable(up.binaryPath, up.backupPath) 71 | if err != nil { 72 | return err 73 | } 74 | 75 | err = replaceBinaries(up.downloadPath, up.binaryPath, up.backupPath) 76 | if err != nil { 77 | return err 78 | } 79 | 80 | log.Printf("Upgraded to version %s", latestVersion) 81 | 82 | return nil 83 | } 84 | 85 | func binaryPath() (string, error) { 86 | // TODO after implementing $HOME/.lets/bin, deny upgrading in other places 87 | return os.Executable() 88 | } 89 | 90 | func backupExecutable(executablePath string, backupPath string) error { 91 | errFmt := func(err error) error { 92 | return fmt.Errorf("failed to backup current lets binary: %w", err) 93 | } 94 | 95 | executableFile, err := os.Open(executablePath) 96 | if err != nil { 97 | return errFmt(err) 98 | } 99 | 100 | err = os.RemoveAll(backupPath) 101 | if err != nil { 102 | return errFmt(err) 103 | } 104 | 105 | backupFile, err := os.Create(backupPath) 106 | if err != nil { 107 | return errFmt(err) 108 | } 109 | 110 | _, err = io.Copy(backupFile, executableFile) 111 | if err != nil { 112 | return errFmt(err) 113 | } 114 | 115 | return nil 116 | } 117 | 118 | func replaceBinaries(downloadPath string, executablePath string, backupPath string) error { 119 | defer os.RemoveAll(downloadPath) 120 | defer os.RemoveAll(backupPath) 121 | 122 | err := os.Rename(downloadPath, executablePath) 123 | if err != nil { 124 | // restore original file from backup 125 | err := os.Rename(backupPath, executablePath) 126 | 127 | return fmt.Errorf("failed to update lets binary: %w", err) 128 | } 129 | 130 | return nil 131 | } 132 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 <