Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,8 @@
dist/
envguard
envguard.exe

application.yml
application.properties
!application.yml.example
!application.properties.example
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,55 @@ Summary

---

## JVM / Spring Boot support

envguard understands YAML and `.properties` files out of the box. Nested YAML keys are flattened to dot-notation so the same `check`, `sync`, and `validate` workflow applies.

### Spring Boot (`application.yml`)

Commit a `application.yml.example` with empty values alongside your gitignored `application.yml`:

```bash
# Check your local YAML against the example
envguard check --example=application.yml.example --local=application.yml

# Add any keys that are in the example but missing locally
envguard sync --example=application.yml.example --local=application.yml

# Assert required keys are non-empty (e.g. in CI)
envguard validate --local=application.yml \
--required=spring.datasource.url,spring.datasource.password
```

### Spring Boot (`application.properties`)

```bash
envguard check --example=application.properties.example --local=application.properties

envguard sync --example=application.properties.example --local=application.properties

envguard validate --local=application.properties \
--required=spring.datasource.url,spring.datasource.password,server.port
```

### Quarkus (`application.properties`)

Quarkus uses the same flat `.properties` format, so the commands above apply unchanged:

```bash
envguard check --example=src/main/resources/application.properties.example \
--local=src/main/resources/application.properties
```

You can also force a format explicitly if the file extension is ambiguous:

```bash
envguard check --format=yaml --example=config.example --local=config
envguard check --format=props --example=settings.example --local=settings
```

---

## Project structure

```
Expand Down
4 changes: 4 additions & 0 deletions application.properties.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
spring.datasource.url=
spring.datasource.password=
spring.datasource.driver-class-name=
server.port=
10 changes: 10 additions & 0 deletions application.yml.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
spring:
datasource:
url:
password:
driver-class-name:
redis:
host:
port:
server:
port:
6 changes: 5 additions & 1 deletion cmd/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import (
"github.com/spf13/cobra"
)

var auditRepo string
var (
auditRepo string
auditFormat string
)

var auditCmd = &cobra.Command{
Use: "audit",
Expand All @@ -32,4 +35,5 @@ var auditCmd = &cobra.Command{

func init() {
auditCmd.Flags().StringVar(&auditRepo, "repo", ".", "Path to the git repository to audit")
auditCmd.Flags().StringVar(&auditFormat, "format", "", "File format: env, yaml, props (reserved for future use)")
}
10 changes: 6 additions & 4 deletions cmd/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@ import (
var (
checkExample string
checkLocal string
checkFormat string
)

var checkCmd = &cobra.Command{
Use: "check",
Short: "Compare your .env against .env.example",
Long: `Diffs your local .env file against .env.example and reports missing, undocumented, or empty keys.`,
Run: func(cmd *cobra.Command, args []string) {
result, err := runCheck(checkExample, checkLocal)
result, err := runCheck(checkExample, checkLocal, checkFormat)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
Expand All @@ -34,13 +35,13 @@ var checkCmd = &cobra.Command{
},
}

func runCheck(examplePath, localPath string) (*differ.DiffResult, error) {
example, err := parser.Parse(examplePath)
func runCheck(examplePath, localPath, format string) (*differ.DiffResult, error) {
example, err := parser.ParseAs(examplePath, format)
if err != nil {
return nil, fmt.Errorf("error reading %s: %w", examplePath, err)
}

local, err := parser.Parse(localPath)
local, err := parser.ParseAs(localPath, format)
if err != nil {
return nil, fmt.Errorf("error reading %s: %w", localPath, err)
}
Expand All @@ -55,4 +56,5 @@ func shouldFailCheck(result *differ.DiffResult) bool {
func init() {
checkCmd.Flags().StringVar(&checkExample, "example", ".env.example", "Path to your .env.example file")
checkCmd.Flags().StringVar(&checkLocal, "local", ".env", "Path to your local .env file")
checkCmd.Flags().StringVar(&checkFormat, "format", "", "File format: env, yaml, props (default: auto-detect from extension)")
}
2 changes: 1 addition & 1 deletion cmd/check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func TestRunCheckFailsWhenRequiredKeyIsEmpty(t *testing.T) {
t.Fatalf("write local: %v", err)
}

result, err := runCheck(examplePath, localPath)
result, err := runCheck(examplePath, localPath, "")
if err != nil {
t.Fatalf("runCheck returned error: %v", err)
}
Expand Down
29 changes: 22 additions & 7 deletions cmd/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ import (
var (
syncExample string
syncLocal string
syncFormat string
)

var syncCmd = &cobra.Command{
Use: "sync",
Short: "Add missing keys from .env.example into your .env",
Long: `Reads .env.example and adds any missing keys into your .env with empty values. Existing keys are never overwritten.`,
Run: func(cmd *cobra.Command, args []string) {
added, skipped, err := syncFile(syncExample, syncLocal)
added, skipped, err := syncFile(syncExample, syncLocal, syncFormat)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
Expand All @@ -31,13 +32,17 @@ var syncCmd = &cobra.Command{
},
}

func syncFile(examplePath, localPath string) ([]string, []string, error) {
example, err := parser.Parse(examplePath)
func syncFile(examplePath, localPath, format string) ([]string, []string, error) {
if format == "" {
format = parser.DetectFormat(examplePath)
}

example, err := parser.ParseAs(examplePath, format)
if err != nil {
return nil, nil, fmt.Errorf("error reading %s: %w", examplePath, err)
}

local, err := parseLocalOrEmpty(localPath)
local, err := parseLocalOrEmpty(localPath, format)
if err != nil {
return nil, nil, err
}
Expand All @@ -49,6 +54,8 @@ func syncFile(examplePath, localPath string) ([]string, []string, error) {
}
sort.Strings(keys)

lineFor := envLineFormatter(format)

var added, skipped []string
toAppend := make([]string, 0, len(keys))
for _, key := range keys {
Expand All @@ -57,7 +64,7 @@ func syncFile(examplePath, localPath string) ([]string, []string, error) {
continue
}
added = append(added, key)
toAppend = append(toAppend, key+"=\n")
toAppend = append(toAppend, lineFor(key))
}

if len(toAppend) == 0 {
Expand Down Expand Up @@ -93,8 +100,15 @@ func syncFile(examplePath, localPath string) ([]string, []string, error) {
return added, skipped, nil
}

func parseLocalOrEmpty(path string) (*parser.EnvFile, error) {
local, err := parser.Parse(path)
func envLineFormatter(format string) func(string) string {
if format == parser.FormatYAML {
return func(k string) string { return k + ":\n" }
}
return func(k string) string { return k + "=\n" }
}

func parseLocalOrEmpty(path, format string) (*parser.EnvFile, error) {
local, err := parser.ParseAs(path, format)
if err == nil {
return local, nil
}
Expand Down Expand Up @@ -142,4 +156,5 @@ func fileNeedsLeadingNewline(path string) (bool, error) {
func init() {
syncCmd.Flags().StringVar(&syncExample, "example", ".env.example", "Path to your .env.example file")
syncCmd.Flags().StringVar(&syncLocal, "local", ".env", "Path to your local .env file")
syncCmd.Flags().StringVar(&syncFormat, "format", "", "File format: env, yaml, props (default: auto-detect from extension)")
}
4 changes: 2 additions & 2 deletions cmd/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ func TestSyncFileCreatesMissingLocalFile(t *testing.T) {
t.Fatalf("write example: %v", err)
}

added, skipped, err := syncFile(examplePath, localPath)
added, skipped, err := syncFile(examplePath, localPath, "")
if err != nil {
t.Fatalf("syncFile returned error: %v", err)
}
Expand Down Expand Up @@ -54,7 +54,7 @@ func TestSyncFileSeparatesAppendedKeysWithNewline(t *testing.T) {
t.Fatalf("write local: %v", err)
}

added, skipped, err := syncFile(examplePath, localPath)
added, skipped, err := syncFile(examplePath, localPath, "")
if err != nil {
t.Fatalf("syncFile returned error: %v", err)
}
Expand Down
4 changes: 3 additions & 1 deletion cmd/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
var (
validateLocal string
validateRequired string
validateFormat string
)

var validateCmd = &cobra.Command{
Expand All @@ -26,7 +27,7 @@ var validateCmd = &cobra.Command{
os.Exit(1)
}

local, err := parser.Parse(validateLocal)
local, err := parser.ParseAs(validateLocal, validateFormat)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading %s: %v\n", validateLocal, err)
os.Exit(1)
Expand Down Expand Up @@ -59,4 +60,5 @@ var validateCmd = &cobra.Command{
func init() {
validateCmd.Flags().StringVar(&validateLocal, "local", ".env", "Path to your local .env file")
validateCmd.Flags().StringVar(&validateRequired, "required", "", "Comma-separated list of required keys")
validateCmd.Flags().StringVar(&validateFormat, "format", "", "File format: env, yaml, props (default: auto-detect from extension)")
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/joho/godotenv v1.5.1
github.com/spf13/cobra v1.8.0
golang.org/x/crypto v0.50.0
gopkg.in/yaml.v3 v3.0.1
)

require (
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,7 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
39 changes: 39 additions & 0 deletions internal/parser/parser.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
package parser

import (
"path/filepath"
"strings"

"github.com/joho/godotenv"
)

const (
FormatEnv = "env"
FormatYAML = "yaml"
FormatProps = "props"
)

type EnvFile struct {
Path string
Keys map[string]string
}

// Parse parses an .env file using godotenv.
func Parse(path string) (*EnvFile, error) {
keys, err := godotenv.Read(path)
if err != nil {
Expand All @@ -17,6 +27,35 @@ func Parse(path string) (*EnvFile, error) {
return &EnvFile{Path: path, Keys: keys}, nil
}

// DetectFormat infers the file format from the file extension.
func DetectFormat(path string) string {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".yml", ".yaml":
return FormatYAML
case ".properties":
return FormatProps
default:
return FormatEnv
}
}

// ParseAs parses a file using the given format. If format is empty, format is
// auto-detected from the file extension.
func ParseAs(path, format string) (*EnvFile, error) {
if format == "" {
format = DetectFormat(path)
}
switch format {
case FormatYAML:
return ParseYAML(path)
case FormatProps:
return ParseProps(path)
default:
return Parse(path)
}
}

func KeySet(env *EnvFile) map[string]struct{} {
set := make(map[string]struct{}, len(env.Keys))
for k := range env.Keys {
Expand Down
35 changes: 35 additions & 0 deletions internal/parser/props.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package parser

import (
"os"
"strings"
)

// ParseProps parses a .properties file into a flat map.
// Format is key=value one per line; lines starting with # and empty lines are skipped.
func ParseProps(path string) (*EnvFile, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}

keys := make(map[string]string)
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
key := strings.TrimSpace(parts[0])
if key == "" {
continue
}
value := ""
if len(parts) == 2 {
value = strings.TrimSpace(parts[1])
}
keys[key] = value
}

return &EnvFile{Path: path, Keys: keys}, nil
}
Loading
Loading