From 85011692289fb002a842a4c8ed8db4c672feb069 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Tue, 29 Apr 2025 21:24:14 +0300 Subject: [PATCH] support top level custom keywords starting with "x-" * "x-" prefixed top level keywords are now supported. This allows alias this data in env * env can now alias "x-" prefixed maps which will be merged with "env" * improve docs - sidebar now has better nesting * improve docs - add conditional "init" explanation * add bats and unit tests --- Dockerfile | 8 +- config/config/config.go | 24 +++++ config/config/config_test.go | 51 ++++++++++ config/config/env.go | 11 +++ docs/docs/best_practices.md | 27 +++++- docs/docs/changelog.md | 3 + docs/docs/config.md | 107 ++++++++++++++++----- docs/sidebars.js | 119 ++++++++++++++++++------ tests/command_env.bats | 7 ++ tests/command_env/lets.aliased-env.yaml | 16 ++++ tests/commands_required/lets.yaml | 3 +- tests/global_env.bats | 7 ++ tests/global_env/lets.aliased-env.yaml | 14 +++ 13 files changed, 339 insertions(+), 58 deletions(-) create mode 100644 tests/command_env/lets.aliased-env.yaml create mode 100644 tests/global_env/lets.aliased-env.yaml diff --git a/Dockerfile b/Dockerfile index 36c065f0..e8edc35d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ -FROM golang:1.24-bookworm as builder +FROM golang:1.24-bookworm AS builder -ENV GOPROXY https://proxy.golang.org -ENV CGO_ENABLED 1 +ENV GOPROXY=https://proxy.golang.org +ENV CGO_ENABLED=1 # disable all compiler errors ENV CGO_CFLAGS=-w @@ -26,6 +26,6 @@ COPY go.sum . RUN go mod download -FROM golangci/golangci-lint:v1.64.7-alpine as linter +FROM golangci/golangci-lint:v1.64.7-alpine AS linter RUN mkdir -p /.cache && chmod -R 777 /.cache diff --git a/config/config/config.go b/config/config/config.go index aa804250..dc7c9aa3 100644 --- a/config/config/config.go +++ b/config/config/config.go @@ -9,10 +9,22 @@ import ( "strings" "github.com/lets-cli/lets/config/path" + "github.com/lets-cli/lets/set" "github.com/lets-cli/lets/util" "gopkg.in/yaml.v3" ) +var keywords = set.NewSet[string]( + "version", + "shell", + "env", + "eval_env", + "init", + "before", + "mixins", + "commands", +) + // Config is a struct for loaded config file. type Config struct { // absolute path to work dir - where config is placed @@ -37,6 +49,18 @@ type Config struct { } func (c *Config) UnmarshalYAML(unmarshal func(interface{}) error) error { + var raw map[string]interface{} + if err := unmarshal(&raw); err != nil { + return err + } + + // check if config has unsupported keywords + for key := range raw { + if !keywords.Contains(key) && !strings.HasPrefix(key, "x-") { + return fmt.Errorf("keyword '%s' not supported", key) + } + } + var config struct { Version Version Mixins []*Mixin diff --git a/config/config/config_test.go b/config/config/config_test.go index 71d645ac..ab21f755 100644 --- a/config/config/config_test.go +++ b/config/config/config_test.go @@ -2,6 +2,7 @@ package config import ( "bytes" + "maps" "testing" "github.com/lithammer/dedent" @@ -37,4 +38,54 @@ func TestParseConfig(t *testing.T) { } }) + t.Run("parse env with alias", func(t *testing.T) { + text := dedent.Dedent(` + shell: bash + + x-default-env: &default-env + HELLO: WORLD + + env: + <<: *default-env + FOO: BAR + + commands: + hello: + cmd: [echo, Hello] + `) + cfg := ConfigFixture(t, text) + + env := cfg.Env.Dump() + expected := map[string]string{ + "FOO": "BAR", + "HELLO": "WORLD", + } + if !maps.Equal(env, expected) { + t.Errorf("wrong output. \nexpect %s \ngot: %s", expected, env) + } + }) + + t.Run("invalid alias name - does not start with x-", func(t *testing.T) { + text := dedent.Dedent(` + shell: bash + + default-env: &default-env + HELLO: WORLD + + env: + <<: *default-env + FOO: BAR + + commands: + hello: + cmd: [echo, Hello] + `) + + buf := bytes.NewBufferString(text) + c := NewConfig(".", ".", ".") + err := yaml.NewDecoder(buf).Decode(&c) + if err.Error() != "keyword 'default-env' not supported" { + t.Errorf("config must not allow custom keywords") + } + }) } diff --git a/config/config/env.go b/config/config/env.go index 44a8f1b2..a9dc9e8f 100644 --- a/config/config/env.go +++ b/config/config/env.go @@ -36,6 +36,17 @@ func (e *Envs) UnmarshalYAML(node *yaml.Node) error { keyNode := node.Content[i] valueNode := node.Content[i+1] + // handle <<: *aliased case + if keyNode.Tag == "!!merge" { + aliasedEnv := &Envs{} + err := aliasedEnv.UnmarshalYAML(valueNode.Alias) + if err != nil { + return errors.New("lets: can not parse aliased env") + } + e.Merge(aliasedEnv) + continue + } + envAsStr := "" if err := valueNode.Decode(&envAsStr); err == nil { diff --git a/docs/docs/best_practices.md b/docs/docs/best_practices.md index 5b176745..25c90e9e 100644 --- a/docs/docs/best_practices.md +++ b/docs/docs/best_practices.md @@ -5,7 +5,7 @@ title: Best practices ### Naming conventions -Prefer single word over plural. +Prefer single word over plural. It is better to leverage semantics of `lets` as an intention to do something. For example it is natural saying `lets test` or `lets build` something. @@ -106,3 +106,28 @@ As you can see, we execute `build` command each time we execute `run` command (` `persist_checksum` will save calculated checksum to `.lets` directory and all subsequent calls of `build` command will read checksum from disk, calculate new checksum, and compare them. If `package.json` will change - we will rebuild the image. + + +### Initialize project using `init` + +You can use `init` keyword to write a script that will do some initialization on lets startup, like creating some dirs, configs or installing project dependencies. + +By default, `init` runs each time the `lets` program is executed. + +You can make `init` conditional, by simply creating a file and checking if it exists at the start of `init` script. + +Example: + +``` +shell: bash + +init: | + if [[ ! -f .lets/init_done ]]; then + echo "calling init script" + touch .lets/init_done + fi +``` + +In this example we are checking for `.lets/init_done` file existence. If it does not exist, we will call init script and create `init_done` file as a marker of successfull init script invocation. + +We are using `.lets` dir here because this dir will be created by `lets` itself and is generally a good place to create such files, but you are free to create files with any name and in any directory you want. diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index 48d6691e..b5a16751 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -6,6 +6,9 @@ title: Changelog ## [Unreleased](https://github.com/lets-cli/lets/releases/tag/v0.0.X) * `[Dependency]` update go to `1.24` +* `[Added]` support custom top-level keywords that start with `x-` +* `[Added]` check for invalid top-level keywords during config parsing +* `[Added]` support YAML aliases in `env` - env will be merged aliases mapping ## [0.0.55](https://github.com/lets-cli/lets/releases/tag/v0.0.55) diff --git a/docs/docs/config.md b/docs/docs/config.md index d5cd1827..517b940b 100644 --- a/docs/docs/config.md +++ b/docs/docs/config.md @@ -3,25 +3,36 @@ id: config title: Config reference --- -* [shell](#shell) -* [mixins](#mixins) -* [env](#global-env) -* [eval_env](#global-eval_env) -* [init](#global-init) -* [before](#global-before) -* [commands](#commands) - * [description](#description) - * [cmd](#cmd) - * [work_dir](#work_dir) - * [after](#after) - * [depends](#depends) - * [options](#options) - * [env](#env) - * [eval_env](#eval_env) - * [checksum](#checksum) - * [persist_checksum](#persist_checksum) - * [ref](#ref) - * [args](#args) +- [Top-level directives:](#top-level-directives) + - [Version](#version) + - [Shell](#shell) + - [Global env](#global-env) + - [Global eval\_env](#global-eval_env) + - [Global before](#global-before) + - [Global init](#global-init) + - [Conditional init](#conditional-init) + - [Mixins](#mixins) + - [Ignored mixins](#ignored-mixins) + - [Remote mixins `(experimental)`](#remote-mixins-experimental) + - [Commands](#commands) +- [Command directives:](#command-directives) + - [Short syntax](#short-syntax) + - [`cmd`](#cmd) + - [`description`](#description) + - [`work_dir`](#work_dir) + - [`shell`](#shell-1) + - [`after`](#after) + - [`depends`](#depends) + - [Override arguments in depends command](#override-arguments-in-depends-command) + - [`options`](#options) + - [`env`](#env) + - [`eval_env`](#eval_env) + - [`checksum`](#checksum) + - [`persist_checksum`](#persist_checksum) + - [`ref`](#ref) + - [`args`](#args) +- [Aliasing:](#aliasing) + - [Env aliasing](#env-aliasing) ## Top-level directives: @@ -139,11 +150,19 @@ commands: `type: string` -Specify init script which will be executed only once. It is execured right before first command call. +Specify init script which will be executed only once during each lets invocation. It is execured right before first command call. -`init` script is a good place for some intialization that sould be done once, for example, +> Main difference from `before` is that `before` called before each command invocation (including commands specified in depends) -create docker network, check if some directory exist, clear caches, etc. +`init` script is a good place for some initialization that should be done once at lets startup, for example: + +* create docker network +* check if some directory exist +* clear caches, +* install dependencies +* etc. + +Example usage: ```yaml shell: bash @@ -171,6 +190,29 @@ From before Bar ``` +#### Conditional init + +If you need to make sure that code in `init` is called once with some condition, +you can for example create a file at the end of `init` script and check if this +file exists at the beginning of `init` script. + +Example: + +``` +shell: bash + +init: | + if [[ ! -f .lets/init_done ]]; then + echo "calling init script" + touch .lets/init_done + fi +``` + +In this example we are checking for `.lets/init_done` file existence. If it does not exist, we will call init script and create `init_done` file as a marker of successfull init script invocation. + +We are using `.lets` dir here because this dir will be created by `lets` itself and is generally a good place to create such files, but you are free to create files with any name and in any directory you want. + + ### Mixins `key: mixins` @@ -828,3 +870,24 @@ commands: `args` is used only with [ref](#ref) and allows to set additional positional args to referenced command. See [ref](#ref) example. + +## Aliasing: + +Lets supports YAML aliasing in various places in the config + +### Env aliasing + +You can define any mapping and alias it in `env` configuration: + +```yaml +shell: bash + +.default-env: &default-env + FOO: BAR + +env: + <<: *default-env + HELLO: WORLD +``` + +This will merge `env` and `.default-env`. Any environment variables declarations after `<<: ` will override variables defined in aliased map. \ No newline at end of file diff --git a/docs/sidebars.js b/docs/sidebars.js index c7d47141..8447a3a4 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -1,31 +1,92 @@ module.exports = { - someSidebar: { - "Introduction": [ - 'what_is_lets', - 'installation', - 'quick_start', - 'completion', - ], - "Usage": [ - 'basic_usage', - 'advanced_usage', - ], - "Config format": ['config'], - "Api Reference": [ - 'cli', - 'env', - ], - "Examples": [ - 'examples', - 'example_js', - ], - "Best practices": ['best_practices'], - Changelog: ['changelog'], - "IDE support": ['ide_support'], - Development: [ - 'architecture', - 'development', - 'contribute' - ], - }, + mySidebar: [ + { + type: 'category', + label: 'Introduction', + collapsed: false, + items: [ + { + type: 'doc', + id: 'what_is_lets', + }, + { + type: 'doc', + id: 'installation', + }, + { + type: 'doc', + id: 'quick_start', + }, + { + type: 'doc', + id: 'completion', + }, + ], + }, + { + type: 'category', + label: 'Usage', + items: [ + { + type: 'doc', + id: 'basic_usage', + }, + { + type: 'doc', + id: 'advanced_usage', + }, + ], + }, + 'config', + { + type: 'category', + label: 'API Reference', + items: [ + { + type: 'doc', + id: 'cli', + }, + { + type: 'doc', + id: 'env', + }, + ], + }, + { + type: 'category', + label: 'Examples', + items: [ + { + type: 'doc', + id: 'examples', + }, + { + type: 'doc', + id: 'example_js', + }, + ], + }, + 'best_practices', + 'changelog', + 'ide_support', + + { + type: 'category', + label: 'Development', + items: [ + { + type: 'doc', + id: 'architecture', + }, + { + type: 'doc', + id: 'development', + }, + { + type: 'doc', + id: 'contribute', + }, + ], + }, + ], }; diff --git a/tests/command_env.bats b/tests/command_env.bats index 36df419b..ff469800 100644 --- a/tests/command_env.bats +++ b/tests/command_env.bats @@ -14,3 +14,10 @@ setup() { assert_line --index 2 "BAR=Bar" assert_line --index 3 "FOO=bb1da47569d9fbe3b5f2216fdbd4c9b040ccb5c1" } + +@test "command_env: should merge env with aliased map" { + run lets -c lets.aliased-env.yaml env + assert_success + assert_line --index 0 "ONE=1" + assert_line --index 1 "FOO=BAR" +} diff --git a/tests/command_env/lets.aliased-env.yaml b/tests/command_env/lets.aliased-env.yaml new file mode 100644 index 00000000..421ea880 --- /dev/null +++ b/tests/command_env/lets.aliased-env.yaml @@ -0,0 +1,16 @@ +shell: bash + +x-default-env: &default-env + FOO: + sh: echo "BAR" + +commands: + env: + env: + ONE: "1" + FOO: + sh: echo "hello" + <<: *default-env + cmd: | + echo ONE=${ONE} + echo FOO=${FOO} diff --git a/tests/commands_required/lets.yaml b/tests/commands_required/lets.yaml index 180e11d4..56950f46 100644 --- a/tests/commands_required/lets.yaml +++ b/tests/commands_required/lets.yaml @@ -1,2 +1 @@ -shell: bash -commands_typo: \ No newline at end of file +shell: bash \ No newline at end of file diff --git a/tests/global_env.bats b/tests/global_env.bats index f7495bc7..d36237aa 100644 --- a/tests/global_env.bats +++ b/tests/global_env.bats @@ -17,3 +17,10 @@ setup() { assert_line --index 5 "BAR=Bar" assert_line --index 6 "FOO=bb1da47569d9fbe3b5f2216fdbd4c9b040ccb5c1" } + +@test "global_env: should merge env with aliased map" { + run lets -c lets.aliased-env.yaml env + assert_success + assert_line --index 0 "ONE=1" + assert_line --index 1 "FOO=BAR" +} diff --git a/tests/global_env/lets.aliased-env.yaml b/tests/global_env/lets.aliased-env.yaml new file mode 100644 index 00000000..952c0c88 --- /dev/null +++ b/tests/global_env/lets.aliased-env.yaml @@ -0,0 +1,14 @@ +shell: bash + +x-default-env: &default-env + FOO: BAR +env: + ONE: "1" + FOO: BAZ + <<: *default-env + +commands: + env: + cmd: | + echo ONE=${ONE} + echo FOO=${FOO}