Config and environment
The merge precedence, environment binding, resolver chains, and .env loading.
Before Run, the framework merges values for the matched command from several layers. Each field ends up with the highest-precedence value that supplied it.
Merge precedence
The layers, in order of increasing precedence:
struct default < resolver chain (files / mapping) < environment < parsed flagsFlags always win. Environment is folded in by the core after the resolver chain, so a value from the environment beats one from a config file but loses to a flag. With no resolvers registered, only defaults, environment, and flags apply, so file config is entirely optional.
Environment binding
An env-tagged field picks up its variable during the merge. Nothing else is needed, since the core folds the process environment in for every command.
type ServeConfig struct {
Addr string `arg:"addr" env:"ADDR" default:":8080" help:"Listen address"`
}ADDR=:9090 mytool serve # Addr = ":9090"
mytool serve --addr :3000 # Addr = ":3000" (flag beats env)Resolver chains
A resolver contributes a layer of values below the environment. Resolvers compose like middleware, each threading its output into the next, and the framework runs them in the order registered with App.Resolve, lowest precedence first.
app.Resolve(globalResolver, projectResolver, secretsResolver)The only config seam in the core is the Resolver interface, so the core never imports a file-config package. File-backed resolution lives in the config sub-package, whose config.NewResolver(store, rules) satisfies Resolver structurally. A store is a single config file with whole-file and dotted-key access, and codecs (json, yaml, toml) are addons you register only when needed.
import "github.com/toaweme/cli/config"
app.Resolve(
config.NewResolver(config.NewFileStore(config.HomePath("mytool"), "config", true), nil),
config.NewResolver(config.FileSecrets(config.HomePath("mytool")), nil),
)Loading .env files
LoadDotEnv reads .env files and sets variables that are not already set, so the merge then picks them up through the normal environment layer. It silently skips files that do not exist.
if err := cli.LoadDotEnv(); err != nil { // loads ".env" from the working directory
return fmt.Errorf("failed to load .env: %w", err)
}To parse a file without touching the process environment, use GetDotEnv for one file or GetDotEnvs to merge several, where an earlier file takes precedence.
values, err := cli.GetDotEnv(".env.local")Warning
LoadDotEnv only sets variables that are unset, so a value already in the environment is never overwritten by a .env file. Load .env before building the app if you want those values folded into the merge.