commit 696bd783ac715d09d33fc1d90b6a26c411664c1a Author: sneak Date: Mon Feb 16 08:40:46 2026 +0100 Initial commit: Go clone of daemontools envdir diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa834b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +envdir diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..389ad7a --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4f7d757 --- /dev/null +++ b/README.md @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..14142c0 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module sneak.berlin/go/envdir + +go 1.25.5 diff --git a/main.go b/main.go new file mode 100644 index 0000000..61bcebd --- /dev/null +++ b/main.go @@ -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) +}