Skip to content

Commit b91368c

Browse files
authored
feat: Add support for INCLUDE.ENV (#66)
Adds support for INCLUDE.ENV [?!] <file-pattern> * Variables are immediately available, as if they had been defined in the same place in the Runfile * Variables are not automatically exported, even if the .env file uses the 'export' keyword * By default, no errors are generated if .env files are not found feat: Explicitly make single-file INCLUDE optional * Use 'INCLUDE?' to explicitly make single-file includes optional feat: Explicitly make .env includes required * Use 'INCLUDE.ENV!' to explicitly make .env includes required chore: Adds config.IncludeEnvCycleMap to avoid including the same .env file multiple times
1 parent a5753df commit b91368c

File tree

8 files changed

+291
-36
lines changed

8 files changed

+291
-36
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Runfile
1313
runfile.sh
1414
*.run
1515
*.rf
16+
*.env
1617

1718
# ==============================================================================
1819
# Build Assets

README.md

+92-20
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ In run, the entire script is executed within a single sub-shell.
5656
- [Command-Line Options](#command-line-options)
5757
- [Making Options Required](#making-options-required)
5858
- [Providing A Default Option Value](#providing-a-default-option-value)
59-
- [Boolean (Flag) Options](#boolean-flag-options)
59+
- [Boolean (Flag) Options](#boolean--flag--options)
6060
- [Getting `-h` & `--help` For Free](#getting--h----help-for-free)
6161
- [Passing Options Directly Through to the Command Script](#passing-options-directly-through-to-the-command-script)
6262
- [Run Tool Help](#run-tool-help)
@@ -65,7 +65,7 @@ In run, the entire script is executed within a single sub-shell.
6565
- [Via Environment Variables](#via-environment-variables)
6666
- [`$RUNFILE`](#runfile-1)
6767
- [Using Direnv](#using-direnv-to-auto-configure-runfile)
68-
- [`$RUNFILE_ROOTS`](#runfile_roots)
68+
- [`$RUNFILE_ROOTS`](#runfileroots)
6969
- [Runfile Variables](#runfile-variables)
7070
- [Local By Default](#local-by-default)
7171
- [Exporting Variables](#exporting-variables)
@@ -87,17 +87,19 @@ In run, the entire script is executed within a single sub-shell.
8787
- [Simple Export](#simple-export)
8888
- [Export With Name](#export-with-name)
8989
- [Assertions](#assertions)
90-
- [Includes](#includes)
90+
- [Includes](#includes---runfiles)
9191
- [File Globbing](#file-globbing)
9292
- [Working Directory](#working-directory)
93-
- [File(s) Not Found](#files-not-found)
93+
- [Required vs Optional](#file--s--not-found)
9494
- [Avoiding Include Loops](#avoiding-include-loops)
9595
- [Overriding Commands](#overriding-commands)
9696
- [Cannot Re-Register Command In Same Runfile](#cannot-re-register-command-in-same-runfile)
9797
- [Overrides Are Case-Insensitive](#overrides-are-case-insensitive)
9898
- [First Registered Command Defines Case For Help](#first-registered-command-defines-case-for-help)
9999
- [First Registered Command Defines Default Documentation](#first-registered-command-defines-default-documentation)
100100
- [Commands Are Listed In The Order They Are Registered](#commands-are-listed-in-the-order-they-are-registered)
101+
- [Includes - .ENV](#includes---env)
102+
- [Required vs Optional](#file--s--not-found-1)
101103
- [Invoking Other Commands & Runfiles](#invoking-other-commands--runfiles)
102104
- [RUN / RUN.AFTER / RUN.ENV Actions](#run--runafter--runenv-actions)
103105
- [.RUN / .RUNFILE Attributes](#run--runfile-attributes)
@@ -884,15 +886,14 @@ Their names start with `.` to avoid colliding with [runfile variables](#runfile-
884886

885887
Following is the list of Run's attributes:
886888

887-
| Attribute | Description
888-
|----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------
889-
| `.SHELL` | Contains the shell command that will be used to execute command scripts. See [Script Shells](#script-shells) for more details.
890-
| `.RUN` | Contains the absolute path of the run binary currently in use. Useful for [Invoking Other Commands & Runfiles](#invoking-other-commands--runfiles).
891-
| `.RUNFILE` | Contains the absolute path of the **primary** Runfile.
892-
| `.RUNFILE.DIR` | Contains the absolute path of the parent folder of the **primary** runfile.
893-
| `.SELF` | Contains the absolute path of the **current** (primary or included) runfile.
894-
| `.SELF.DIR` | Contains the absolute path of the parent folder of the **current** runfile.
895-
889+
| Attribute | Description |
890+
|----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------|
891+
| `.SHELL` | Contains the shell command that will be used to execute command scripts. See [Script Shells](#script-shells) for more details. |
892+
| `.RUN` | Contains the absolute path of the run binary currently in use. Useful for [Invoking Other Commands & Runfiles](#invoking-other-commands--runfiles). |
893+
| `.RUNFILE` | Contains the absolute path of the **primary** Runfile. |
894+
| `.RUNFILE.DIR` | Contains the absolute path of the parent folder of the **primary** runfile. |
895+
| `.SELF` | Contains the absolute path of the **current** (primary or included) runfile. |
896+
| `.SELF.DIR` | Contains the absolute path of the parent folder of the **current** runfile. |
896897

897898
#### Exporting Attributes
898899

@@ -1039,12 +1040,12 @@ Hello, Everybody
10391040

10401041
*Note:* Assertions apply only to commands and are only checked when a command is invoked. Any globally-defined assertions will apply to ALL commands defined after the assertion.
10411042

1042-
------------
1043-
### Includes
1043+
-----------------------
1044+
### Includes - Runfiles
10441045

1045-
Includes let you organize commands across multiple Runfiles.
1046+
Includes let you organize and configure commands across multiple Runfiles.
10461047

1047-
Includes have the following syntax:
1048+
You can include other Runfiles using the following syntax:
10481049
```
10491050
INCLUDE <file pattern> | "<file pattern>" | '<file pattern>'
10501051
```
@@ -1110,19 +1111,27 @@ Include names / glob-patterns are resolved relative to the Primary runfile's con
11101111

11111112
#### File(s) Not Found
11121113

1113-
##### OK For Glob
1114+
##### Default: OK For Glob
11141115

11151116
When using a globbing pattern, Run considers it OK if the pattern results in no files being found.
11161117

11171118
This makes it possible to support features like an optional Runfile include directory, or the ability to start a project folder with no includes but have them automatically picked up as you add them.
11181119

11191120
_Runfile_
1121+
```
1122+
INCLUDE maybe_some_runfiles/Runfile-* # OK if no files found
1123+
```
11201124

1125+
##### Force Error If No Files Found
1126+
1127+
To force an error if no files are found when using a globbing pattern, use `!` :
1128+
1129+
_Runfile_
11211130
```
1122-
INCLUDE maybe_some_runfiles/Runfile-* # OK if not no files found
1131+
INCLUDE ! maybe_some_runfiles/Runfile-* # ERROR if no files found
11231132
```
11241133

1125-
##### BAD For Single File
1134+
##### Default: BAD For Single File
11261135

11271136
When using a single filename (no globbing), Run considers it an error if the include file is not found.
11281137

@@ -1138,6 +1147,15 @@ $ run list
11381147
run: include runfile not found: 'Runfile-must-exist'
11391148
```
11401149

1150+
##### Skip Error If File Not Found
1151+
1152+
To skip generating an error if no file is found when using a single filename, use `?` :
1153+
1154+
_Runfile_
1155+
```
1156+
INCLUDE ? Runfile-might-exist # OK if file not found
1157+
```
1158+
11411159
#### Avoiding Include Loops
11421160

11431161
Run keeps track of already-included runfiles and will silently avoid including the same runfile multiple times.
@@ -1348,6 +1366,60 @@ Commands:
13481366

13491367
Notice that `command2` is still shown _between_ `command1` and `command3`, matching the order in which it was originally registered.
13501368

1369+
-------------------
1370+
### Includes - .ENV
1371+
1372+
`.env` files allow users to manage runfile configuration without modifying the Runfile directly.
1373+
1374+
Your Runfile can include .env files using the following syntax:
1375+
```
1376+
INCLUDE.ENV <file pattern> | "<file pattern>" | '<file pattern>'
1377+
```
1378+
1379+
Simple example:
1380+
1381+
_Runfile.env_
1382+
```
1383+
HELLO=Newman
1384+
```
1385+
1386+
_Runfile_
1387+
```
1388+
INCLUDE.ENV Runfile.env
1389+
1390+
##
1391+
# export HELLO
1392+
hello:
1393+
echo "Hello, ${HELLO:-World}"
1394+
```
1395+
1396+
_output_
1397+
```
1398+
$ run hello
1399+
1400+
Hello, Newman
1401+
```
1402+
1403+
*Notes:*
1404+
* Variables are immediately available, as if they had been defined in the same place in the Runfile.
1405+
* Variables are not automatically exported.
1406+
* Run uses the [subosito/gotenv](https://github.com/subosito/gotenv) library to parse command output
1407+
* `#` comments are supported and will be safely ignored
1408+
* `export` keyword is optional and is (currently) ignored - This may be addressed in a future release
1409+
* Simple variable references in assignments are supported, **but** variables defined _within_ your Runfile are not (currently) accessible - This may be addressed in a future release
1410+
* Visit the [gotenv project page](https://github.com/subosito/gotenv) to learn more about which `.env` features are supported
1411+
1412+
#### File(s) Not Found
1413+
1414+
By default, Run considers it OK no .env file is found (using either a single filename or a globbing pattern).
1415+
1416+
To force an error if no file(s) are found, use `!`:
1417+
1418+
_Runfile_
1419+
```
1420+
INCLUDE.ENV ! Runfile-might-not-exist.env # ERROR if no file(s) found
1421+
```
1422+
13511423
--------------------------------------
13521424
### Invoking Other Commands & Runfiles
13531425

internal/ast/ast.go

+120-7
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
package ast
22

33
import (
4+
"bytes"
45
"fmt"
56
"log"
67
"os"
78
"path/filepath"
89
"strings"
910

1011
"github.com/goreleaser/fileglob"
12+
"github.com/subosito/gotenv"
1113
"github.com/tekwizely/run/internal/config"
1214
"github.com/tekwizely/run/internal/exec"
1315
"github.com/tekwizely/run/internal/runfile"
@@ -202,7 +204,9 @@ func (a *ScopeAssert) Apply(s *runfile.Scope) {
202204
// ScopeInclude includes other runfiles.
203205
//
204206
type ScopeInclude struct {
205-
FilePattern ScopeValueNode
207+
FilePattern ScopeValueNode
208+
MissingSingleOk bool
209+
MissingMatchersOk bool
206210
}
207211

208212
// Apply applies the node to the scope.
@@ -226,10 +230,16 @@ func (a *ScopeInclude) Apply(r *runfile.Runfile) {
226230
if fileglob.ContainsMatchers(filePattern) {
227231
if files, err = fileglob.Glob(filePattern, fileglob.MaybeRootFS); err != nil {
228232
panic(fmt.Errorf("processing include pattern '%s': %s", filePattern, err))
229-
// OK for fileglob to result in 0 files, but notify user
230-
//
231-
} else if config.ShowNotices && len(files) == 0 {
232-
log.Printf("NOTICE: include pattern resulted in no matches: %s", filePattern)
233+
} else if len(files) == 0 {
234+
if a.MissingMatchersOk {
235+
// OK for fileglob to result in 0 files, but notify user
236+
//
237+
if config.ShowNotices {
238+
log.Printf("NOTICE: include pattern resulted in no matches: %s", filePattern)
239+
}
240+
} else {
241+
panic(fmt.Errorf("include pattern resulted in no matches: %s", filePattern))
242+
}
233243
}
234244
} else {
235245
// Specific (not-glob) filename expected to exist - Checked in loop below
@@ -290,9 +300,16 @@ func (a *ScopeInclude) Apply(r *runfile.Runfile) {
290300
//
291301
// We're about to panic, assume prior defer will restore values before exiting
292302
//
293-
294303
if err == nil {
295-
panic(fmt.Errorf("include runfile not found: '%s'", filename))
304+
if a.MissingSingleOk {
305+
// OK if file missing, but notify user
306+
//
307+
if config.ShowNotices {
308+
log.Printf("NOTICE: include runfile not found: '%s'", filename)
309+
}
310+
} else {
311+
panic(fmt.Errorf("include runfile not found: '%s'", filename))
312+
}
296313
} else {
297314
// If path error, just show the wrapped error
298315
//
@@ -306,6 +323,102 @@ func (a *ScopeInclude) Apply(r *runfile.Runfile) {
306323
}
307324
}
308325

326+
// ScopeIncludeEnv includes .env files.
327+
//
328+
type ScopeIncludeEnv struct {
329+
FilePattern ScopeValueNode
330+
MissingSingleOk bool
331+
MissingMatchersOk bool
332+
}
333+
334+
// Apply applies the node to the scope.
335+
//
336+
func (a *ScopeIncludeEnv) Apply(r *runfile.Runfile) {
337+
filePattern := a.FilePattern.Apply(r.Scope)
338+
// We want the absolute file paths for include tracking
339+
// If pattern is not absolute, assume its relative to config.RunfileAbsDir
340+
//
341+
if !filepath.IsAbs(filePattern) {
342+
filePattern = filepath.Join(config.RunfileAbsDir, filePattern)
343+
}
344+
// Skip fileglob if pattern does not look like a glob.
345+
// By checking this ourselves, we hope to gain more control over error reporting,
346+
// as fileglob currently (as of v1.3.0) conceals the fs.ErrorNotExist condition.
347+
//
348+
var files []string
349+
if fileglob.ContainsMatchers(filePattern) {
350+
var err error
351+
if files, err = fileglob.Glob(filePattern, fileglob.MaybeRootFS); err != nil {
352+
panic(fmt.Errorf("processing include.env pattern '%s': %s", filePattern, err))
353+
} else if len(files) == 0 {
354+
if a.MissingMatchersOk {
355+
// OK for fileglob to result in 0 files, but notify user
356+
//
357+
if config.ShowNotices {
358+
log.Printf("NOTICE: include.env pattern resulted in no matches: %s", filePattern)
359+
}
360+
} else {
361+
panic(fmt.Errorf("include.env pattern resulted in no matches: %s", filePattern))
362+
}
363+
}
364+
} else {
365+
// Specific (not-glob) filename expected to exist - Checked in loop below
366+
//
367+
files = []string{filePattern}
368+
}
369+
// NOTE: filenames assumed to be absolute
370+
// TODO Sort list (path aware) ?
371+
//
372+
for _, filename := range files {
373+
// Have we included this file already?
374+
//
375+
if _, exists := config.IncludeEnvCycleMap[filename]; exists {
376+
// Treat as a notice since we safely avoided the (possibly) infinite loop
377+
//
378+
if config.ShowNotices {
379+
log.Printf("NOTICE: env file already included: '%s' - Skipping", filename)
380+
}
381+
} else {
382+
fileBytes, exists, err := util.ReadFileIfExists(filename)
383+
if exists {
384+
// Mark file included
385+
//
386+
config.IncludeEnvCycleMap[filename] = struct{}{}
387+
// Parse the file
388+
//
389+
dotEnv, err := gotenv.StrictParse(bytes.NewReader(fileBytes))
390+
if err != nil {
391+
panic(fmt.Errorf("include.env file '%s': %s", filename, err.Error()))
392+
}
393+
// No values exported by default
394+
//
395+
for k, v := range dotEnv {
396+
r.Scope.PutVar(k, v)
397+
}
398+
} else {
399+
if err == nil {
400+
if a.MissingSingleOk {
401+
// OK if file missing, but notify user
402+
//
403+
if config.ShowNotices {
404+
log.Printf("NOTICE: include.env file not found: '%s'", filename)
405+
}
406+
} else {
407+
panic(fmt.Errorf("include.env file not found: '%s'", filename))
408+
}
409+
} else {
410+
// If path error, just show the wrapped error
411+
//
412+
if pathErr, ok := err.(*os.PathError); ok {
413+
err = pathErr.Unwrap()
414+
}
415+
panic(fmt.Errorf("include.env file '%s': %s", filename, err.Error()))
416+
}
417+
}
418+
}
419+
}
420+
}
421+
309422
// ScopeBracketString wraps a bracketed string.
310423
//
311424
type ScopeBracketString struct {

internal/config/config.go

+5-1
Original file line numberDiff line numberDiff line change
@@ -113,10 +113,14 @@ var CurrentRunfileAbs string
113113
//
114114
var CurrentRunfileAbsDir string
115115

116-
// IncludeCycleMap tracks included Runfiles two avoid infinite loops. Key = abs file paths of included Runfile
116+
// IncludeCycleMap tracks included Runfiles to avoid infinite loops. Key = abs file paths of included Runfile
117117
//
118118
var IncludeCycleMap = map[string]struct{}{}
119119

120+
// IncludeEnvCycleMap tracks included .env files to avoid infinite loops. Key = abs file paths of included .env file
121+
//
122+
var IncludeEnvCycleMap = map[string]struct{}{}
123+
120124
// RunCycleMap tracks inter-cmd RUNs to avoid infinite loops. Key = lowercase name of cmd
121125
//
122126
var RunCycleMap = map[string]struct{}{}

0 commit comments

Comments
 (0)