diff --git a/README.md b/README.md index c46c8a4..0928908 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,17 @@ You can also use cenv as a single-util package. See [the package source](cenv.go go get github.com/echo-webkom/cenv ``` +```go +func main() { + err := cenv.Load() + if err != nil { + log.Fatal(err) + } + + ... +} +``` + ## Use Add one or more of the following `@tags` above a field: @@ -41,6 +52,8 @@ Add one or more of the following `@tags` above a field: - `public`: Marks the field as public. The value will be included in the schema. This is for required static values. - `required`: Marks the field as required. The field has to be present, and have a non-empty value. - `length [number]`: Requires a specified length for the fields value. +- `default [value]`: Set a default value. Running `cenv fix` will automatically fill this in if the field is empty. +- `enum [value1] | [value2] | ...`: Require that the field value is one of the given enum values, separated by `|`. - `format [format]`: Requires a specified format for the value. Uses [gokenizer patterns](https://github.com/jesperkha/gokenizer). ```py @@ -57,23 +70,27 @@ OTHER_KEY=abcdefgh # @length 4 # @format {number} PIN_CODE=1234 + +# @enum user | guest | admin +# @default user +ROLE=user ``` -Create a schema file from your env: +Create a schema file from your .env: ```sh # Creates a cenv.schema.json file cenv update ``` -Check you .env after fetching the latest changes +Check your .env after fetching the latest changes ```sh # Compares your env with the existing cenv.schema.json cenv check ``` -You can fix and outdated .env with the fix command. Note that this will overwrite the existing .env, but use the values that were there before, like secret API keys etc. This may not work correctly if the .env is formatted incorrectly. +The `fix` command creates a .env file based on the schema, or fills in missing fields in an existing one. Any values already in your .env, like API keys will be kept, while any missing values will be added if a default or public one is provided. ```sh cenv fix @@ -92,3 +109,4 @@ If you want to overwrite the `Version` variable in `main.go` you have add the fo ```sh go build -o bin/cenv -ldflags "-X 'github.com/echo-webkom/cenv/cmd.Version='" app/main.go ``` + diff --git a/cenv/cenv.go b/cenv/cenv.go index 8b1c70d..4e5ebfa 100644 --- a/cenv/cenv.go +++ b/cenv/cenv.go @@ -13,13 +13,15 @@ type CenvFile struct { } type CenvField struct { - Required bool `json:"required,omitempty"` // This field has to be present and have a non-empty value - Public bool `json:"public,omitempty"` // This has a publicly known but required value, stored in the schema - LengthRequired bool `json:"lengthRequired,omitempty"` // The length of this field is specified in the schema - Length uint32 `json:"length,omitempty"` // The required length, if LengthRequired is true - Format string `json:"format,omitempty"` // Require a specified format for the value - Key string `json:"key,omitempty"` // Field name - Value string `json:"value,omitempty"` // Public only + Required bool `json:"required,omitempty"` // This field has to be present and have a non-empty value + Public bool `json:"public,omitempty"` // This has a publicly known but required value, stored in the schema + LengthRequired bool `json:"lengthRequired,omitempty"` // The length of this field is specified in the schema + Length uint32 `json:"length,omitempty"` // The required length, if LengthRequired is true + Format string `json:"format,omitempty"` // Require a specified format for the value + Key string `json:"key,omitempty"` // Field name + Value string `json:"value,omitempty"` // Public only + Enum []string `json:"enum,omitempty"` // List of legal enum values + Default string `json:"default,omitempty"` // Default value if none is given value string } @@ -62,7 +64,6 @@ func Fix(envPath, schemaPath string) error { } env, _ := ReadEnv(envPath) - file := strings.Builder{} for _, f := range schema.Fields { @@ -80,11 +81,24 @@ func Fix(envPath, schemaPath string) error { if f.Format != "" { s += fmt.Sprintf("# @format %s\n", f.Format) } + if len(f.Enum) != 0 { + s += fmt.Sprintf("# @enum %s\n", strings.Join(f.Enum, " | ")) + } + if f.Default != "" { + s += fmt.Sprintf("# @default %s\n", f.Default) + } + + // Public > Private > Default - if v, ok := env[f.Key]; ok && !f.Public { + if f.Public { + // The value is public (and static) + s += fmt.Sprintf("%s=%s\n", f.Key, f.Value) + } else if v, ok := env[f.Key]; ok && v.value != "" { + // There is already a value here s += fmt.Sprintf("%s=%s\n", f.Key, v.value) } else { - s += fmt.Sprintf("%s=%s\n", f.Key, f.Value) + // The value is either empty or set to default (if any) + s += fmt.Sprintf("%s=%s\n", f.Key, f.Default) } fmt.Printf("cenv: added '%s'\n", f.Key) diff --git a/cenv/parser.go b/cenv/parser.go index c370e61..5a48202 100644 --- a/cenv/parser.go +++ b/cenv/parser.go @@ -3,121 +3,128 @@ package cenv import ( "fmt" "os" + "slices" "strconv" "strings" "github.com/jesperkha/gokenizer" ) -const prefix string = "@" +const PREFIX byte = '@' // ReadEnv parses the env file and tags. It also checks that // tags are defined correctly and will return an error otherwise. func ReadEnv(filepath string) (env map[string]CenvField, err error) { - env = make(map[string]CenvField) - file, err := os.ReadFile(filepath) if err != nil { return env, err } - formats := []string{ - // Dont change order - fmt.Sprintf("#{ws}%s{word} {number}", prefix), - fmt.Sprintf("#{ws}%s{word} {text}", prefix), - fmt.Sprintf("#{ws}%s{word}", prefix), - } - - tokr := gokenizer.New() - - tokr.ClassFunc("env_var", func(b byte) bool { - return strings.Contains("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_", string(b)) - }) - - tokr.Class("key", "{env_var}") - tokr.Class("value", "{string}", "{text}") - tokr.Class("keyValue", "{ws}{key}{ws}={ws}{value}", "{ws}{key}{ws}={ws}") - tokr.Class("comment", "#{any}") - tokr.Class("tag", formats...) - tokr.Class("expression", "{tag}", "{comment}", "{keyValue}") - + env = make(map[string]CenvField) fld := CenvField{} + errs := longError{} - tokr.Pattern("{expression}", func(t gokenizer.Token) error { - tag := t.Get("expression").Get("tag") - if tag.Length != 0 { - name := tag.Get("word").Lexeme - num := tag.Get("number").Lexeme - format := tag.Get("text").Lexeme + for idx, line := range strings.Split(string(file), "\n") { + line := strings.TrimSpace(line) + if line == "" { + continue + } - usesNum := false // Tag uses number value - n := uint64(0) + linenr := idx + 1 - if num != "" { - n, err = strconv.ParseUint(num, 10, 32) - if err != nil { - return fmt.Errorf("expected unsigned int, got '%s'", num) - } + // Parse tag + // Tags are comment with any amount of whitespace followed by + // the prefix '@' then the tag name, and finally the tag value if any. + if strings.HasPrefix(line, "#") { + tagLine := strings.TrimSpace(line[1:]) + if len(tagLine) == 0 || tagLine[0] != PREFIX { + continue } - switch name { - case "required": - fld.Required = true + split := strings.SplitN(tagLine, " ", 2) + tag := strings.TrimPrefix(split[0], string(PREFIX)) + value := "" + if len(split) > 1 { + value = split[1] + } - case "public": + switch tag { + case "required": // @required + fld.Required = true + case "public": // @public fld.Public = true + case "format": // @format + fld.Format = value + case "default": // @default + fld.Default = value + + case "enum": // @enum | | ... + values := strings.Split(value, "|") + for _, v := range values { + v = strings.TrimSpace(v) + if v != "" { + fld.Enum = append(fld.Enum, v) + } + } - case "length": - fld.LengthRequired = true + case "length": // @length + n, err := strconv.Atoi(value) + if err != nil { + errs.Add(fmt.Errorf("invalid number literal '%s', line %d", value, linenr)) + } fld.Length = uint32(n) - usesNum = true - - case "format": - fld.Format = format + fld.LengthRequired = true default: - return fmt.Errorf("unknown tag '%s'", name) + errs.Add(fmt.Errorf("unknown tag '%s', line %d", tag, linenr)) } - if !usesNum && num != "" { - return fmt.Errorf("expected newline after tag name, got '%s'", num) - } + continue } - keyval := t.Get("expression").Get("keyValue") + // Parse key-value pair + // For the sake of consistency across code/config/.env we require that + // keys are alphanumeric (in addition to $ and _). + split := strings.SplitN(line, "=", 2) + if len(split) == 0 { + errs.Add(fmt.Errorf("syntax error, line %d", linenr)) + } - if keyval.Length != 0 { - fld.Key = keyval.Get("key").Lexeme - fld.value = keyval.Get("value").Lexeme + key := strings.TrimSpace(split[0]) + value := "" + if len(split) > 1 { + value = strings.Trim(split[1], "\" ") + } - // Strip string quotes - if fld.value != "" && fld.value[0] == '"' && fld.value[len(fld.value)-1] == '"' { - fld.value = fld.value[1 : len(fld.value)-1] - } + if !isAlnum(key) { + errs.Add(fmt.Errorf("key must be alphanumeric, line %d", linenr)) + } - if fld.Public { - fld.Value = fld.value - } + fld.Key = key + fld.value = value - env[fld.Key] = fld - fld = CenvField{} + if fld.Public { + fld.Value = value } - return nil - }) + env[fld.Key] = fld + fld = CenvField{} // Reset + } - errs := longError{} + return env, validateEnv(env, errs) +} - // Run for each line - for _, line := range strings.Split(string(file), "\n") { - if err = tokr.Run(line); err != nil { - errs.Add(err) +func isAlnum(s string) bool { + for _, c := range s { + if strings.IndexRune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$", c) == -1 { + return false } } - - return env, validateEnv(env, errs) + return true } +// Validate the env file in-place; check that all tags actually match the +// values before comparing with schema. func validateEnv(fs map[string]CenvField, longErr longError) error { for _, f := range fs { if err := validateEnvField(f); err != nil { @@ -135,6 +142,12 @@ func validateEnvField(field CenvField) error { return fmt.Errorf("'%s': value did not match the format '%s'", field.Key, field.Format) } } + if len(field.Enum) != 0 { + if !slices.Contains(field.Enum, field.value) { + list := strings.Join(field.Enum, ", ") + return fmt.Errorf("'%s': field must have one of the specified enum values: [%s]", field.Key, list) + } + } if field.Required && len(field.value) == 0 { return fmt.Errorf("'%s': required field must have a value", field.Key) } diff --git a/cenv/schema.go b/cenv/schema.go index d783349..ba39c69 100644 --- a/cenv/schema.go +++ b/cenv/schema.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "strings" ) func ReadSchema(filepath string) (schema CenvFile, err error) { @@ -53,10 +54,19 @@ func assertBoolEqual(key, name string, schema, env bool) error { return nil } +// Check if tags and fields in env match the ones in schema. func compareFields(sf CenvField, ef CenvField) (errs longError) { if err := assertBoolEqual(sf.Key, "required", sf.Required, ef.Required); err != nil { errs.Add(err) } + if err := assertBoolEqual(sf.Key, "a default value", sf.Default != "", ef.Default != ""); err != nil { + errs.Add(err) + } else if sf.Default != ef.Default { + errs.Add(fmt.Errorf("'%s' has default '%s' in schema, but '%s' in .env", sf.Key, sf.Default, ef.Default)) + } + if schemaEnum, envEnum := strings.Join(sf.Enum, " | "), strings.Join(ef.Enum, " | "); schemaEnum != envEnum { + errs.Add(fmt.Errorf("'%s's enum tag does not match schema", sf.Key)) + } if err := assertBoolEqual(sf.Key, "public", sf.Public, ef.Public); err != nil { errs.Add(err) }