feat(gsheet2env): add tool to convert csv to .env

This commit is contained in:
AJ ONeal 2025-10-13 14:19:18 -06:00
parent 24ec3f021d
commit f882bfc139
No known key found for this signature in database
4 changed files with 252 additions and 0 deletions

View File

@ -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'
```

View File

@ -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
}

View File

@ -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"
Can't render this file because it has a wrong number of fields in line 2.

View File

@ -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
1 # DO NOT put SECRETS here. These are used when the Ai program runs to set certain parameters
2 ENV Value Comment
3 LOCAL_TIMEZONE America/Denver Seatle PT, Denver MT, Arizona MST, Chicago CT, Detroit ET
4 LITERAL_VAR $SHELL
5
6 # separated
7 EMPTY_KEY
8 MULTI a nice multi-line value block a nice multi-line comment block
9 no key
10 ${FOO} this will break things