From f882bfc1399a74359acf3e2236fec0ef4173da87 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 13 Oct 2025 14:19:18 -0600 Subject: [PATCH] feat(gsheet2env): add tool to convert csv to .env --- io/transform/gsheet2csv/README.md | 54 ++++++ .../gsheet2csv/cmd/gsheet2env/main.go | 169 ++++++++++++++++++ .../gsheet2csv/fixtures/gsheet-env-readme.csv | 15 ++ .../gsheet2csv/fixtures/gsheet-env.csv | 14 ++ 4 files changed, 252 insertions(+) create mode 100644 io/transform/gsheet2csv/cmd/gsheet2env/main.go create mode 100644 io/transform/gsheet2csv/fixtures/gsheet-env-readme.csv create mode 100644 io/transform/gsheet2csv/fixtures/gsheet-env.csv diff --git a/io/transform/gsheet2csv/README.md b/io/transform/gsheet2csv/README.md index b498f8f..157efc5 100644 --- a/io/transform/gsheet2csv/README.md +++ b/io/transform/gsheet2csv/README.md @@ -123,3 +123,57 @@ gsheet2csv --strip-comments ./gsheet.csv > ./sheet.csv \f form feed (also ^L) \v vertical tab (also ^K) ``` + +## gsheet2env + +Converts a Google Sheet to an ENV file. + +### Installation + +```sh +go get github.com/therootcompany/golib/io/transform/gsheet2env +``` + +### Usage + +```sh +gsheet2env --no-shebang --no-header --no-export ./fixtures/gsheet-env-readme.csv +``` + +### Example + +```csv +# DO NOT put SECRETS here. Anyone with a link can see this. Use for config ONLY! +LOCAL_TIMEZONE,America/Denver,"Seatle PT, Denver MT, Arizona MST, Chicago CT, Detroit ET" + +# to show that escapes are handled +FIRST_LETTER,A, +MONEY_SYMBOL,$, +SINGLE_QUOTE,', +DOUBLE_QUOTE,"""", + +# to show that newlines are handled +TRIFORCE,"wisdom +courage +power","Zelda +Link +Ganon" +``` + +```sh +# DO NOT put SECRETS here. Anyone with a link can see this. Use for config ONLY! +# Seatle PT, Denver MT, Arizona MST, Chicago CT, Detroit ET +LOCAL_TIMEZONE='America/Denver' +# to show that escapes are handled +FIRST_LETTER='A' +MONEY_SYMBOL='$' +SINGLE_QUOTE=''"'"'' +DOUBLE_QUOTE='"' +# to show that newlines are handled +# Zelda +# Link +# Ganon +TRIFORCE='wisdom +courage +power' +``` diff --git a/io/transform/gsheet2csv/cmd/gsheet2env/main.go b/io/transform/gsheet2csv/cmd/gsheet2env/main.go new file mode 100644 index 0000000..de0db22 --- /dev/null +++ b/io/transform/gsheet2csv/cmd/gsheet2env/main.go @@ -0,0 +1,169 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + "strings" + + "github.com/therootcompany/golib/io/transform/gsheet2csv" +) + +func isValidKey(key string) bool { + for _, c := range key { + isUpperWord := (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' + if !isUpperWord { + return false + } + } + return true +} + +func main() { + noShebang := flag.Bool("no-shebang", false, "don't begin the file with #!/bin/sh") + noHeader := flag.Bool("no-header", false, "treat all non-comment rows as ENVs - don't expect a header") + noExport := flag.Bool("no-export", false, "disable export prefix") + outputFile := flag.String("o", "-", "path to output env file (default: stdout)") + flag.Parse() + + // Require Google Sheet URL as argument + if len(flag.Args()) != 1 { + fmt.Fprintf(os.Stderr, "Error: exactly one Google Sheet URL or path is required\n") + flag.Usage() + os.Exit(1) + } + gsheetURLOrPath := flag.Args()[0] + + // Prepare output writer + var out *os.File + if len(*outputFile) > 0 && *outputFile != "-" { + var err error + out, err = os.Create(*outputFile) + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating output file: %v\n", err) + os.Exit(1) + return + } + defer func() { _ = out.Close() }() + } else { + out = os.Stdout + } + + gsr := gsheet2csv.NewReaderFrom(gsheetURLOrPath) + // preserves comment-looking data (and actual comments) + gsr.Comment = 0 + gsr.FieldsPerRecord = -1 + + if !*noShebang { + if _, err := out.Write([]byte("#!/bin/sh\n\n")); err != nil { + fmt.Fprintf(os.Stderr, "Error creating output file: %v\n", err) + os.Exit(1) + return + } + } + + if err := convert(gsr, out, *noHeader, *noExport); err != nil { + fmt.Fprintf(os.Stderr, "Error converting CSV to ENV: %v\n", err) + os.Exit(1) + } + + if out != os.Stdout { + fmt.Fprintf(os.Stderr, "wrote %s\n", *outputFile) + } +} + +func convert(gsr *gsheet2csv.Reader, out io.Writer, noHeader bool, noExport bool) error { + consumedHeader := noHeader + for { + row, err := gsr.Read() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return err + } + + var key string + if len(row) >= 1 { + key = strings.TrimSpace(row[0]) + if len(key) == 0 { + if _, err := fmt.Fprintln(out); err != nil { + return err + } + continue + } + + // Preserve but ignore proper comments + if keyComment, exists := strings.CutPrefix(key, "#"); exists { + keyComment = strings.TrimSpace(keyComment) + if len(keyComment) == 0 { + if _, err := fmt.Fprintln(out, "#"); err != nil { + return err + } + continue + } + + saniComment := sanitizeComment(keyComment) + if _, err := fmt.Fprintf(out, "%s", saniComment); err != nil { + return err + } + continue + } + } + + var value string + if len(row) >= 2 { + value = strings.TrimSpace(row[1]) + } + + var saniComment string + if len(row) >= 3 { + saniComment = sanitizeComment(row[2]) + } + + if !consumedHeader { + consumedHeader = true + continue + } + + // Error on invalid keys + if !isValidKey(key) { + return fmt.Errorf("invalid key in record %s", strings.Join(row, ",")) + } + + // Escape single quotes in value for shell compatibility + value = strings.ReplaceAll(value, "'", "'\"'\"'") + + // Output the ENV line + prefix := "" + if !noExport { + prefix = "export " + } + if _, err := fmt.Fprintf(out, "%s%s%s='%s'\n", saniComment, prefix, key, value); err != nil { + return err + } + } + + return nil +} + +func sanitizeComment(comment string) string { + var formatted []string + + comment = strings.TrimSpace(comment) + lines := strings.FieldsFuncSeq(comment, func(r rune) bool { + return r == '\n' || r == '\r' + }) + for line := range lines { + trimmed := strings.TrimSpace(line) + formatted = append(formatted, "# "+trimmed) + } + + comment = strings.Join(formatted, "\n") + if len(comment) > 0 { + comment += "\n" + } + return comment +} diff --git a/io/transform/gsheet2csv/fixtures/gsheet-env-readme.csv b/io/transform/gsheet2csv/fixtures/gsheet-env-readme.csv new file mode 100644 index 0000000..4702f59 --- /dev/null +++ b/io/transform/gsheet2csv/fixtures/gsheet-env-readme.csv @@ -0,0 +1,15 @@ +# DO NOT put SECRETS here. Anyone with a link can see this. Use for config ONLY! +LOCAL_TIMEZONE,America/Denver,"Seatle PT, Denver MT, Arizona MST, Chicago CT, Detroit ET" +# +# to show that escapes are handled +FIRST_LETTER,A, +MONEY_SYMBOL,$, +SINGLE_QUOTE,', +DOUBLE_QUOTE,"""", +# +# to show that newlines are handled +TRIFORCE,"wisdom +courage +power","Zelda +Link +Ganon" diff --git a/io/transform/gsheet2csv/fixtures/gsheet-env.csv b/io/transform/gsheet2csv/fixtures/gsheet-env.csv new file mode 100644 index 0000000..304acf0 --- /dev/null +++ b/io/transform/gsheet2csv/fixtures/gsheet-env.csv @@ -0,0 +1,14 @@ +# DO NOT put SECRETS here. These are used when the Ai program runs to set certain parameters,, +ENV,Value,Comment +LOCAL_TIMEZONE,America/Denver,"Seatle PT, Denver MT, Arizona MST, Chicago CT, Detroit ET" +LITERAL_VAR,$SHELL, +,, +# separated,, +EMPTY_KEY,, +MULTI,"a nice +multi-line +value block","a nice +multi-line +comment block" +,no key, +${FOO},,this will break things \ No newline at end of file