Initial commit: Go clone of daemontools envdir

This commit is contained in:
Jeffrey Paul 2026-02-16 08:40:46 +01:00
commit 696bd783ac
5 changed files with 192 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
envdir

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jeffrey Paul
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

47
README.md Normal file
View File

@ -0,0 +1,47 @@
# envdir
A Go clone of the [daemontools](https://cr.yp.to/daemontools.html) `envdir`
program.
## Install
```
go install sneak.berlin/go/envdir@latest
```
## Usage
```
envdir dir child [args...]
```
`envdir` sets various environment variables as specified by files in `dir`,
then runs `child` with the given arguments.
If `dir` contains a file named `FOO` whose first line is `bar`, `envdir`
sets the environment variable `FOO` to `bar`, then runs `child`.
### Rules
- Each file in `dir` corresponds to one environment variable. The filename
is the variable name and the first line of the file is the value.
- If a file is empty, the corresponding variable is **unset**.
- NUL bytes (`\0`) in file contents are replaced with newlines.
- Trailing spaces and tabs are stripped from the value.
- Lines after the first newline are ignored.
- Subdirectories and files with `=` in the name are skipped.
- Exit code is **111** if `envdir` encounters an error.
## Example
```
$ mkdir /tmp/env
$ echo -n "production" > /tmp/env/APP_ENV
$ echo -n "localhost:5432" > /tmp/env/DB_HOST
$ envdir /tmp/env printenv APP_ENV
production
```
## License
MIT

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module sneak.berlin/go/envdir
go 1.25.5

120
main.go Normal file
View File

@ -0,0 +1,120 @@
package main
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"syscall"
)
func main() {
if len(os.Args) < 3 {
fmt.Fprintf(os.Stderr, "usage: envdir dir child\n")
os.Exit(111)
}
dir := os.Args[1]
child := os.Args[2]
childArgs := os.Args[2:]
env, err := buildEnv(dir)
if err != nil {
fmt.Fprintf(os.Stderr, "envdir: %s\n", err)
os.Exit(111)
}
path, err := findExecutable(child)
if err != nil {
fmt.Fprintf(os.Stderr, "envdir: %s\n", err)
os.Exit(111)
}
err = syscall.Exec(path, childArgs, env)
fmt.Fprintf(os.Stderr, "envdir: exec: %s\n", err)
os.Exit(111)
}
func buildEnv(dir string) ([]string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("unable to read directory %s: %w", dir, err)
}
// Start with the current environment.
envMap := make(map[string]string)
unset := make(map[string]bool)
for _, e := range os.Environ() {
k, v, _ := strings.Cut(e, "=")
envMap[k] = v
}
for _, entry := range entries {
name := entry.Name()
// Skip subdirectories.
if entry.IsDir() {
continue
}
// Skip names containing '='.
if strings.Contains(name, "=") {
continue
}
path := filepath.Join(dir, name)
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("unable to read %s: %w", path, err)
}
// If the file is empty, unset the variable.
if len(data) == 0 {
delete(envMap, name)
unset[name] = true
continue
}
// Use only the first line.
if i := bytes.IndexByte(data, '\n'); i >= 0 {
data = data[:i]
}
// Replace NUL bytes with newlines.
data = bytes.ReplaceAll(data, []byte{0}, []byte{'\n'})
// Trim trailing spaces and tabs.
value := strings.TrimRight(string(data), " \t")
envMap[name] = value
delete(unset, name)
}
env := make([]string, 0, len(envMap))
for k, v := range envMap {
env = append(env, k+"="+v)
}
return env, nil
}
func findExecutable(name string) (string, error) {
// If name contains a slash, use it directly.
if strings.Contains(name, "/") {
return name, nil
}
// Search PATH.
pathEnv := os.Getenv("PATH")
for _, dir := range filepath.SplitList(pathEnv) {
path := filepath.Join(dir, name)
info, err := os.Stat(path)
if err != nil {
continue
}
if info.Mode().IsRegular() && info.Mode()&0111 != 0 {
return path, nil
}
}
return "", fmt.Errorf("command not found: %s", name)
}