Skip to content

Commit 8501169

Browse files
committed
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
1 parent 638b85d commit 8501169

13 files changed

Lines changed: 339 additions & 58 deletions

File tree

Dockerfile

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
FROM golang:1.24-bookworm as builder
1+
FROM golang:1.24-bookworm AS builder
22

3-
ENV GOPROXY https://proxy.golang.org
4-
ENV CGO_ENABLED 1
3+
ENV GOPROXY=https://proxy.golang.org
4+
ENV CGO_ENABLED=1
55
# disable all compiler errors
66
ENV CGO_CFLAGS=-w
77

@@ -26,6 +26,6 @@ COPY go.sum .
2626

2727
RUN go mod download
2828

29-
FROM golangci/golangci-lint:v1.64.7-alpine as linter
29+
FROM golangci/golangci-lint:v1.64.7-alpine AS linter
3030

3131
RUN mkdir -p /.cache && chmod -R 777 /.cache

config/config/config.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,22 @@ import (
99
"strings"
1010

1111
"github.com/lets-cli/lets/config/path"
12+
"github.com/lets-cli/lets/set"
1213
"github.com/lets-cli/lets/util"
1314
"gopkg.in/yaml.v3"
1415
)
1516

17+
var keywords = set.NewSet[string](
18+
"version",
19+
"shell",
20+
"env",
21+
"eval_env",
22+
"init",
23+
"before",
24+
"mixins",
25+
"commands",
26+
)
27+
1628
// Config is a struct for loaded config file.
1729
type Config struct {
1830
// absolute path to work dir - where config is placed
@@ -37,6 +49,18 @@ type Config struct {
3749
}
3850

3951
func (c *Config) UnmarshalYAML(unmarshal func(interface{}) error) error {
52+
var raw map[string]interface{}
53+
if err := unmarshal(&raw); err != nil {
54+
return err
55+
}
56+
57+
// check if config has unsupported keywords
58+
for key := range raw {
59+
if !keywords.Contains(key) && !strings.HasPrefix(key, "x-") {
60+
return fmt.Errorf("keyword '%s' not supported", key)
61+
}
62+
}
63+
4064
var config struct {
4165
Version Version
4266
Mixins []*Mixin

config/config/config_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package config
22

33
import (
44
"bytes"
5+
"maps"
56
"testing"
67

78
"github.com/lithammer/dedent"
@@ -37,4 +38,54 @@ func TestParseConfig(t *testing.T) {
3738
}
3839
})
3940

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+
})
4091
}

config/config/env.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@ func (e *Envs) UnmarshalYAML(node *yaml.Node) error {
3636
keyNode := node.Content[i]
3737
valueNode := node.Content[i+1]
3838

39+
// handle <<: *aliased case
40+
if keyNode.Tag == "!!merge" {
41+
aliasedEnv := &Envs{}
42+
err := aliasedEnv.UnmarshalYAML(valueNode.Alias)
43+
if err != nil {
44+
return errors.New("lets: can not parse aliased env")
45+
}
46+
e.Merge(aliasedEnv)
47+
continue
48+
}
49+
3950
envAsStr := ""
4051

4152
if err := valueNode.Decode(&envAsStr); err == nil {

docs/docs/best_practices.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ title: Best practices
55

66
### Naming conventions
77

8-
Prefer single word over plural.
8+
Prefer single word over plural.
99

1010
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.
1111

@@ -106,3 +106,28 @@ As you can see, we execute `build` command each time we execute `run` command (`
106106

107107
`persist_checksum` will save calculated checksum to `.lets` directory and all subsequent calls of `build` command will
108108
read checksum from disk, calculate new checksum, and compare them. If `package.json` will change - we will rebuild the image.
109+
110+
111+
### Initialize project using `init`
112+
113+
You can use `init` keyword to write a script that will do some initialization on lets startup, like creating some dirs, configs or installing project dependencies.
114+
115+
By default, `init` runs each time the `lets` program is executed.
116+
117+
You can make `init` conditional, by simply creating a file and checking if it exists at the start of `init` script.
118+
119+
Example:
120+
121+
```
122+
shell: bash
123+
124+
init: |
125+
if [[ ! -f .lets/init_done ]]; then
126+
echo "calling init script"
127+
touch .lets/init_done
128+
fi
129+
```
130+
131+
In this example we are checking for `.lets/init_done` file existence. If it does not exist, we will call init script and create `init_done` file as a marker of successfull init script invocation.
132+
133+
We are using `.lets` dir here because this dir will be created by `lets` itself and is generally a good place to create such files, but you are free to create files with any name and in any directory you want.

docs/docs/changelog.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ title: Changelog
66
## [Unreleased](https://github.com/lets-cli/lets/releases/tag/v0.0.X)
77

88
* `[Dependency]` update go to `1.24`
9+
* `[Added]` support custom top-level keywords that start with `x-`
10+
* `[Added]` check for invalid top-level keywords during config parsing
11+
* `[Added]` support YAML aliases in `env` - env will be merged aliases mapping
912

1013
## [0.0.55](https://github.com/lets-cli/lets/releases/tag/v0.0.55)
1114

docs/docs/config.md

Lines changed: 85 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,36 @@ id: config
33
title: Config reference
44
---
55

6-
* [shell](#shell)
7-
* [mixins](#mixins)
8-
* [env](#global-env)
9-
* [eval_env](#global-eval_env)
10-
* [init](#global-init)
11-
* [before](#global-before)
12-
* [commands](#commands)
13-
* [description](#description)
14-
* [cmd](#cmd)
15-
* [work_dir](#work_dir)
16-
* [after](#after)
17-
* [depends](#depends)
18-
* [options](#options)
19-
* [env](#env)
20-
* [eval_env](#eval_env)
21-
* [checksum](#checksum)
22-
* [persist_checksum](#persist_checksum)
23-
* [ref](#ref)
24-
* [args](#args)
6+
- [Top-level directives:](#top-level-directives)
7+
- [Version](#version)
8+
- [Shell](#shell)
9+
- [Global env](#global-env)
10+
- [Global eval\_env](#global-eval_env)
11+
- [Global before](#global-before)
12+
- [Global init](#global-init)
13+
- [Conditional init](#conditional-init)
14+
- [Mixins](#mixins)
15+
- [Ignored mixins](#ignored-mixins)
16+
- [Remote mixins `(experimental)`](#remote-mixins-experimental)
17+
- [Commands](#commands)
18+
- [Command directives:](#command-directives)
19+
- [Short syntax](#short-syntax)
20+
- [`cmd`](#cmd)
21+
- [`description`](#description)
22+
- [`work_dir`](#work_dir)
23+
- [`shell`](#shell-1)
24+
- [`after`](#after)
25+
- [`depends`](#depends)
26+
- [Override arguments in depends command](#override-arguments-in-depends-command)
27+
- [`options`](#options)
28+
- [`env`](#env)
29+
- [`eval_env`](#eval_env)
30+
- [`checksum`](#checksum)
31+
- [`persist_checksum`](#persist_checksum)
32+
- [`ref`](#ref)
33+
- [`args`](#args)
34+
- [Aliasing:](#aliasing)
35+
- [Env aliasing](#env-aliasing)
2536

2637

2738
## Top-level directives:
@@ -139,11 +150,19 @@ commands:
139150

140151
`type: string`
141152

142-
Specify init script which will be executed only once. It is execured right before first command call.
153+
Specify init script which will be executed only once during each lets invocation. It is execured right before first command call.
143154

144-
`init` script is a good place for some intialization that sould be done once, for example,
155+
> Main difference from `before` is that `before` called before each command invocation (including commands specified in depends)
145156

146-
create docker network, check if some directory exist, clear caches, etc.
157+
`init` script is a good place for some initialization that should be done once at lets startup, for example:
158+
159+
* create docker network
160+
* check if some directory exist
161+
* clear caches,
162+
* install dependencies
163+
* etc.
164+
165+
Example usage:
147166

148167
```yaml
149168
shell: bash
@@ -171,6 +190,29 @@ From before
171190
Bar
172191
```
173192

193+
#### Conditional init
194+
195+
If you need to make sure that code in `init` is called once with some condition,
196+
you can for example create a file at the end of `init` script and check if this
197+
file exists at the beginning of `init` script.
198+
199+
Example:
200+
201+
```
202+
shell: bash
203+
204+
init: |
205+
if [[ ! -f .lets/init_done ]]; then
206+
echo "calling init script"
207+
touch .lets/init_done
208+
fi
209+
```
210+
211+
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.
212+
213+
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.
214+
215+
174216
### Mixins
175217

176218
`key: mixins`
@@ -828,3 +870,24 @@ commands:
828870

829871
`args` is used only with [ref](#ref) and allows to set additional positional args to referenced command. See [ref](#ref) example.
830872

873+
874+
## Aliasing:
875+
876+
Lets supports YAML aliasing in various places in the config
877+
878+
### Env aliasing
879+
880+
You can define any mapping and alias it in `env` configuration:
881+
882+
```yaml
883+
shell: bash
884+
885+
.default-env: &default-env
886+
FOO: BAR
887+
888+
env:
889+
<<: *default-env
890+
HELLO: WORLD
891+
```
892+
893+
This will merge `env` and `.default-env`. Any environment variables declarations after `<<: ` will override variables defined in aliased map.

0 commit comments

Comments
 (0)