From 638a291daa974f8c3a565340cf8f48612020922f Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sat, 14 Feb 2026 14:30:52 -0700 Subject: [PATCH] feat: add cmd/envtocsv for converting env files --- cmd/envtocsv/go.mod | 5 ++ cmd/envtocsv/go.sum | 2 + cmd/envtocsv/main.go | 138 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 cmd/envtocsv/go.mod create mode 100644 cmd/envtocsv/go.sum create mode 100644 cmd/envtocsv/main.go diff --git a/cmd/envtocsv/go.mod b/cmd/envtocsv/go.mod new file mode 100644 index 0000000..8b8eb95 --- /dev/null +++ b/cmd/envtocsv/go.mod @@ -0,0 +1,5 @@ +module github.com/therootcompany/golib/cmd/envtocsv + +go 1.25.0 + +require github.com/joho/godotenv v1.5.1 diff --git a/cmd/envtocsv/go.sum b/cmd/envtocsv/go.sum new file mode 100644 index 0000000..d61b19e --- /dev/null +++ b/cmd/envtocsv/go.sum @@ -0,0 +1,2 @@ +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= diff --git a/cmd/envtocsv/main.go b/cmd/envtocsv/main.go new file mode 100644 index 0000000..e8006d1 --- /dev/null +++ b/cmd/envtocsv/main.go @@ -0,0 +1,138 @@ +// envtocsv - Converts one or more .env files into a merged, sorted CSV +// +// Authored in 2026 by AJ ONeal w/ Grok (https://grok.com). +// To the extent possible under law, the author(s) have dedicated all copyright +// and related and neighboring rights to this software to the public domain +// worldwide. This software is distributed without any warranty. +// +// You should have received a copy of the CC0 Public Domain Dedication along with +// this software. If not, see . + +package main + +import ( + "encoding/csv" + "flag" + "fmt" + "log" + "os" + "slices" + "strings" + + "github.com/joho/godotenv" +) + +type MainConfig struct { + outputPath string + useTab bool +} + +func main() { + cfg := MainConfig{} + + flag.StringVar(&cfg.outputPath, "o", "-", "output path ('-' = stdout)") + flag.BoolVar(&cfg.useTab, "tab", false, "use tab-delimited output instead of comma-separated") + flag.Parse() + + files := flag.Args() + if len(files) == 0 { + files = []string{".env"} + } + + multiFile := len(files) > 1 + + var allRows [][]string + + for _, path := range files { + envMap, err := godotenv.Read(path) + if err != nil { + log.Printf("Warning: skipping %s: %v", path, err) + continue + } + + rows := make([][]string, 0, len(envMap)) + for k, v := range envMap { + if multiFile { + rows = append(rows, []string{k, v, path}) + } else { + rows = append(rows, []string{k, v}) + } + } + + // Sort this file's rows by key + slices.SortFunc(rows, func(a, b []string) int { + return strings.Compare(a[0], b[0]) + }) + + allRows = append(allRows, rows...) + } + + if len(allRows) == 0 { + log.Println("No valid key-value pairs found in any input file") + return + } + + // Deduplicate: keep the last occurrence of each key (override behavior) + seen := make(map[string]int) // key → index of latest occurrence + for i, row := range allRows { + seen[row[0]] = i + } + + // Build final list (only the winning rows) + finalRows := make([][]string, 0, len(seen)) + for _, idx := range seen { + finalRows = append(finalRows, allRows[idx]) + } + + // Final sort by key for stable, readable output + slices.SortFunc(finalRows, func(a, b []string) int { + return strings.Compare(a[0], b[0]) + }) + + // Output writer setup + var w *csv.Writer + if cfg.outputPath == "-" { + w = csv.NewWriter(os.Stdout) + } else { + f, err := os.Create(cfg.outputPath) + if err != nil { + log.Fatalf("Failed to create output file %s: %v", cfg.outputPath, err) + } + defer f.Close() + w = csv.NewWriter(f) + } + + // Configure delimiter based on -tab flag + if cfg.useTab { + w.Comma = '\t' + w.UseCRLF = false // keep LF even on Windows for consistency + } else { + w.Comma = ',' + } + + defer w.Flush() + + // Header — conditional on multi-file + header := []string{"key", "value"} + if multiFile { + header = append(header, "source") + } + if err := w.Write(header); err != nil { + log.Fatalf("Failed to write header: %v", err) + } + + // Write rows + for _, row := range finalRows { + if err := w.Write(row); err != nil { + log.Fatalf("Failed to write to %s: %v", cfg.outputPath, err) + } + } + + if cfg.outputPath != "-" { + fmt.Fprintf(os.Stderr, "Wrote %d unique keys to %s (%s-delimited)\n", + len(finalRows), cfg.outputPath, map[bool]string{true: "tab", false: "comma"}[cfg.useTab]) + } else { + fmt.Fprintf(os.Stderr, "(%d unique keys written to stdout, %s-delimited)\n", + len(finalRows), map[bool]string{true: "tab", false: "comma"}[cfg.useTab]) + } +}