diff --git a/.gitignore b/.gitignore index f1e231c..c3e92e6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,8 @@ dist/ envguard envguard.exe + +application.yml +application.properties +!application.yml.example +!application.properties.example diff --git a/README.md b/README.md index d4be39f..4e52699 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/application.properties.example b/application.properties.example new file mode 100644 index 0000000..b49cbfd --- /dev/null +++ b/application.properties.example @@ -0,0 +1,4 @@ +spring.datasource.url= +spring.datasource.password= +spring.datasource.driver-class-name= +server.port= diff --git a/application.yml.example b/application.yml.example new file mode 100644 index 0000000..b55792b --- /dev/null +++ b/application.yml.example @@ -0,0 +1,10 @@ +spring: + datasource: + url: + password: + driver-class-name: + redis: + host: + port: +server: + port: diff --git a/cmd/audit.go b/cmd/audit.go index e4e8bec..ed09ee1 100644 --- a/cmd/audit.go +++ b/cmd/audit.go @@ -9,7 +9,10 @@ import ( "github.com/spf13/cobra" ) -var auditRepo string +var ( + auditRepo string + auditFormat string +) var auditCmd = &cobra.Command{ Use: "audit", @@ -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)") } diff --git a/cmd/check.go b/cmd/check.go index e26cd6d..678a9c2 100644 --- a/cmd/check.go +++ b/cmd/check.go @@ -13,6 +13,7 @@ import ( var ( checkExample string checkLocal string + checkFormat string ) var checkCmd = &cobra.Command{ @@ -20,7 +21,7 @@ var checkCmd = &cobra.Command{ 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) @@ -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) } @@ -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)") } diff --git a/cmd/check_test.go b/cmd/check_test.go index 26c2469..1c24c37 100644 --- a/cmd/check_test.go +++ b/cmd/check_test.go @@ -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) } diff --git a/cmd/sync.go b/cmd/sync.go index c2c47b2..fdd9354 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -14,6 +14,7 @@ import ( var ( syncExample string syncLocal string + syncFormat string ) var syncCmd = &cobra.Command{ @@ -21,7 +22,7 @@ var syncCmd = &cobra.Command{ 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) @@ -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 } @@ -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 { @@ -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 { @@ -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 } @@ -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)") } diff --git a/cmd/sync_test.go b/cmd/sync_test.go index 26b1ab1..946a836 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -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) } @@ -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) } diff --git a/cmd/validate.go b/cmd/validate.go index 4ec905d..fc880ee 100644 --- a/cmd/validate.go +++ b/cmd/validate.go @@ -13,6 +13,7 @@ import ( var ( validateLocal string validateRequired string + validateFormat string ) var validateCmd = &cobra.Command{ @@ -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) @@ -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)") } diff --git a/go.mod b/go.mod index 84770cd..7a305e3 100644 --- a/go.mod +++ b/go.mod @@ -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 ( diff --git a/go.sum b/go.sum index 1c2a257..223a989 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/parser/parser.go b/internal/parser/parser.go index 620d88c..c95a545 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -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 { @@ -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 { diff --git a/internal/parser/props.go b/internal/parser/props.go new file mode 100644 index 0000000..bb31d7c --- /dev/null +++ b/internal/parser/props.go @@ -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 +} diff --git a/internal/parser/yaml.go b/internal/parser/yaml.go new file mode 100644 index 0000000..87774f7 --- /dev/null +++ b/internal/parser/yaml.go @@ -0,0 +1,52 @@ +package parser + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// ParseYAML parses a YAML file into a flat map using dot notation for nested keys. +// Arrays are indexed: server.hosts.0, server.hosts.1, etc. +func ParseYAML(path string) (*EnvFile, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var raw interface{} + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parsing %s: %w", path, err) + } + + keys := make(map[string]string) + flattenYAML("", raw, keys) + + return &EnvFile{Path: path, Keys: keys}, nil +} + +func flattenYAML(prefix string, value interface{}, result map[string]string) { + switch v := value.(type) { + case map[string]interface{}: + for k, val := range v { + newKey := k + if prefix != "" { + newKey = prefix + "." + k + } + flattenYAML(newKey, val, result) + } + case []interface{}: + for i, val := range v { + flattenYAML(fmt.Sprintf("%s.%d", prefix, i), val, result) + } + case nil: + if prefix != "" { + result[prefix] = "" + } + default: + if prefix != "" { + result[prefix] = fmt.Sprintf("%v", v) + } + } +}