A small, zero-dependency Go package for bootstrapping command-line programs and long-running services. It populates plain structs from struct tags, environment variables, and command-line arguments, and bundles the runtime plumbing most daemons end up rewriting: graceful startup/shutdown, environment paths, file locks, on-disk persistence, and expiring scratch directories.
Everything is driven by struct tags instead of the flag package or a
third-party configuration library, and the whole package has no dependencies
beyond the standard library.
go get github.com/zxdev/envRequires Go 1.26+.
type params struct {
Action string `env:"a,order,require" help:"action to perform"`
Secret string `env:"hidden" help:"a shared secret"`
Flag bool `default:"on" help:"a flag setting"`
Number int `default:"5" help:"a number"`
when int64 // unexported: never parsed or reported
}
func main() {
var p params
path := env.NewEnv(&p) // parse, populate, and log a summary
_ = path // *env.Path with Etc/Srv/Var/Tmp
}env.NewEnv (an alias for env.Configure) parses the tagged struct, prints a
summary log, and returns an *env.Path describing the environment's standard
directories. Pass more than one struct to populate several at once
(env.NewEnv(&app, &server)); each appears as its own block in the summary.
string, bool, int, int64, uint, uint64, and any type derived from
them (for example time.Duration, which is an int64). Unsupported fields are
left untouched.
Booleans accept on, yes, ok, true, 1 as true; anything else is false.
Slices and maps are not parsed directly, but everything you need can be built
from the basic types: take a string and split it yourself
(one,two,three → slice; k1:v1,k2:v2 → map).
For each field the value is resolved in this order, with later sources winning:
default:tag- command-line flag (
-name value,-name=value,-name:value, or alias) - environment variable
NAME(the field name, upper-cased) - ordered positional argument (when the field is tagged
order)
| Tag | Purpose |
|---|---|
env |
comma-separated flags: alias, order, require, environ, hidden |
default |
initial value (string, bool, or int) |
help |
description shown in -help |
name |
override the field label shown in the help table and summary log |
env:"-" |
ignore the field entirely (handy for embedded *env.Path, slices, etc.) |
env flag meanings:
- alias — a short switch, e.g.
-ain place of-action(any token that isn't a reserved keyword is treated as the alias). - order — switchless positional; populated by its index in
os.Args. - require — hard stop (exit code 1) when the field is neither defaulted nor provided.
- environ — mirror the resolved value back into the process environment (under the lower-cased field name).
- hidden — redact the value in the summary log (shown as
<hidden>).
Pass an *env.Options (or env.Options) as the first argument to Configure /
NewEnv to control behavior:
path := env.NewEnv(&env.Options{Silent: true, SetENV: true}, &p)| Field | Effect |
|---|---|
Silent |
suppress the summary log |
NoHelp |
suppress the -help field table |
SetENV |
mirror every resolved field into the environment (global environ) |
NoExit |
return nil instead of calling os.Exit(0) on version/help |
NoExit is primarily for tests, so a spoofed version/help invocation
returns control instead of terminating the process.
Set these package variables at build time to populate the banner and help output:
go build -ldflags "\
-X github.com/zxdev/env.Version=1.2.3 \
-X github.com/zxdev/env.Build=$(git rev-parse --short HEAD)"env.Description may also be set in code to add a paragraph to the help output.
Configure detects the OS and returns standard directories:
| OS | Etc | Srv | Var | Tmp |
|---|---|---|---|---|
| linux | /etc |
/srv |
/var |
/tmp |
| other | _dev/etc |
_dev/srv |
_dev/var |
_dev/tmp |
On non-linux hosts the program runs in a self-contained _dev/ tree, so
development never touches system directories.
The reserved log switch toggles timestamp headers at runtime without a
recompile: -log on enables date/time prefixes, -log off disables them. On
linux, timestamps are off by default (assuming an external logger adds them);
elsewhere they are on.
version and help are handled automatically. For the Quick-start params
struct, help prints a go-tool style usage: line and a compact flag table:
% myprog help
usage: development [flags]
action a [s] [or ] action to perform
secret [s] [ *] a shared secret
flag [b] [ ] (on) a flag setting
number [i] [ ] (5) a number
Each flag row is name alias [type] [ore*] (default) help, where the type
code is one of s string, i int, u uint, b bool, d duration, and the
[ore*] slots mark order, require, environ, and hidden (*). A default:
tag is shown in parentheses. version prints a one-liner
(name version <ver> build <build>).
A populated run prints the summary log:
% myprog run
2026/06/11 21:19:54 |----------------------------------------|
2026/06/11 21:19:54 | MYPROG ::::::::::::::::::::: event log |
2026/06/11 21:19:54 |-----//o--------------------------------|
2026/06/11 21:19:54 version
2026/06/11 21:19:54 build
2026/06/11 21:19:54 pid 65812
2026/06/11 21:19:54 |-----//o--------------------------------|
2026/06/11 21:19:54 action | run
2026/06/11 21:19:54 secret | <hidden>
2026/06/11 21:19:54 flag | true
2026/06/11 21:19:54 number | 5
2026/06/11 21:19:54 |----------------------------------------|
env.Commands adds go-tool style subcommand dispatch. Each env.Command pairs
a name and one-line help with a tagged config struct and an optional handler;
the selected command's flags are parsed with the same machinery as Configure.
This is the heart of example/main.go:
type pullCfg struct {
Since string `env:"s" default:"24h" help:"lookback window"`
Limit int `default:"100" help:"max records"`
}
type serveCfg struct {
Addr string `env:"a" default:":8080" help:"listen address"`
TLS bool `help:"enable TLS"`
}
func main() {
env.Version = "1.2.0"
env.Build = "2026-06-11"
env.Description = "example is a tool that demonstrates env subcommand dispatch."
var pull pullCfg
var serve serveCfg
env.Commands(nil,
env.Command{
Name: "pull",
Help: "retrieve and stage records",
Cfg: &pull,
Run: func(path *env.Path) { /* pull.exec(path) */ },
},
env.Command{
Name: "serve",
Help: "run the long-lived service",
Cfg: &serve,
Run: func(path *env.Path) { /* runServe(&serve) */ },
},
)
}Dispatch surface:
prog -> menu listing every command
prog help -> menu
prog help <cmd> -> that command's flag table
prog <cmd> help -> that command's flag table
prog version -> one-line version
prog <cmd> [flags] -> Configure(cmd.Cfg) on the remaining args, then Run
The subcommand word is stripped from os.Args before parsing, so each command
sees only its own flags and ordered positionals. An unknown command prints the
menu and exits non-zero. Pass *env.Options (or nil for defaults) to control
Silent/NoHelp/SetENV/NoExit.
With no arguments (or help), env.Commands prints a go-tool style menu:
% go run ./example
example is a tool that demonstrates env subcommand dispatch.
Usage:
example <command> [arguments]
The commands are:
pull retrieve and stage records
serve run the long-lived service
Use "example help <command>" for more information about a command.
help <cmd> (or <cmd> help) drills into a command's flag table:
% go run ./example help pull
usage: example pull [flags]
retrieve and stage records
since s [s] [ ] (24h) lookback window
limit [i] [ ] (100) max records
% go run ./example help serve
usage: example serve [flags]
run the long-lived service
addr a [s] [ ] (:8080) listen address
tls [b] [ ] enable TLS
version prints a one-liner, and any other first word dispatches to that
command — parsing its flags, logging the summary, then running its handler:
% go run ./example version
development version 1.2.0 build 2026-06-11
% go run ./example pull -since 48h
2026/06/11 21:41:15 |----------------------------------------|
2026/06/11 21:41:15 | EXAMPLE :::::::::::::::::::: event log |
2026/06/11 21:41:15 |-----//o--------------------------------|
2026/06/11 21:41:15 1.2.0 version
2026/06/11 21:41:15 2026-06-11 build
2026/06/11 21:41:15 mac pid 20175
2026/06/11 21:41:15 |-----//o--------------------------------|
2026/06/11 21:41:15 since | 48h
2026/06/11 21:41:15 limit | 100
2026/06/11 21:41:15 |----------------------------------------|
2026/06/11 21:41:15 pull: since=48h limit=100 -> _dev/var
env.Graceful coordinates startup and shutdown for long-running services. It
captures os.Interrupt, SIGTERM, and SIGHUP, drives a master context, and
blocks until every managed process has reported done.
func main() {
var a Action
grace := env.NewGraceful().Init(a.Init00, a.Init01, a.Init02)
defer grace.Shutdown()
grace.Register(func() { log.Println("extra cleanup") })
grace.Wait() // block until all Init processes report ready
}Init accepts three function signatures:
| Signature | Behavior |
|---|---|
func() |
non-blocking; init.Done() fires when it returns. Wait() confirms ready. |
func(context.Context, *sync.WaitGroup) |
blocking; call init.Done() yourself when ready, then block on <-ctx.Done(). Wait() confirms ready. |
func(context.Context) |
blocking; ready state is indeterminate — Wait() only confirms it started. |
Control methods:
Silent()— toggle the framed event logging (default on).Frame()— toggle the|----|bar frames around events.SetExit(n)— exit code foros.Exit(n);0(default) exits via a plain return.Context()— the mastercontext.Context, for extending to other processes.Register(fns...)— extra cleanup functions run during shutdown, outside the Init architecture.Wait()— block until all Init processes report ready.Cancel()— trigger an orderly shutdown programmatically.Shutdown()— block until context is done and all managed processes exit.
env.Shutdown(ctx, fn) is a minimal alternative for programs that don't use the
full graceful controller: it blocks on a signal or the context, runs fn, and
calls os.Exit(0).
env.Dir("srv", "cache") // creates srv/cache, returns the joined path
env.Dir("srv", "data.db") // creates srv only; treats data.db as a filePitfall: the final element is treated as a file (and not created as a directory) when it contains
.,_, or-. A real directory namedlog_files,my-app, orv1.2is mistaken for a file and silently skipped. Append a trailing element (env.Dir("srv", "log_files", "")) or create it explicitly to work around the heuristic.
Apply default: tags, then overload from a JSON file (top level only, no
recursion):
type Example struct {
Text string `json:"text,omitempty"`
Number int `json:"number,omitempty" default:"10"`
Show bool `json:"show,omitempty" default:"on"`
}
var cfg Example
env.Conf(&cfg, "conf.json") // missing/unreadable file leaves defaults intactlock := env.Lock{Path: "/tmp", TTL: time.Hour}
if !lock.Lock() {
return // another instance holds a fresh lock
}
defer lock.Unlock()Writes {program}.lock containing the PID into Path. A lock older than TTL
(default 1h) is treated as stale and reacquired.
Periodically removes regular files older than their TTL. Implements the
graceful Init signature, so it can be managed directly.
var expire env.Expire
expire.Add(nil, "srv/cache") // nil → 24h TTL
expire.Add(6, "srv/short") // int → n hours
expire.Add("1h30m", "srv/build") // string → parsed duration
expire.Freq = time.Hour // sweep frequency (default hourly)
grace.Init(expire.Start)An invalid, zero, or negative TTL falls back to 24h rather than deleting
everything on the next sweep. Silent() toggles logging.
Save and resume arbitrary data across runs; an optional TTL expires stale state.
var store env.Persist = "queue" // -> queue.persist
ttl := 24 * time.Hour
m := env.NewMap()
store.Load(m, &ttl) // older than ttl? removed instead of loaded
m.Add("job-key")
if next := m.Next(ttl); next != nil {
for {
key, more := next()
if !more {
break
}
// process key (consumed as it is read)
}
}
if len(*m) > 0 {
store.Save(m) // persist what is left
}env.Map (map[string]time.Time) is a convenience type whose Next iterator
drains entries as it yields them and drops anything older than the supplied age.
Load/Save accept any gob-encodable value, not just Map.
| Symbol | Purpose |
|---|---|
env.NewEnv / env.Configure |
parse & populate tagged structs, return *env.Path |
env.Commands |
go-tool style subcommand dispatch |
env.Options |
configure Silent/NoHelp/SetENV/NoExit |
env.Path |
standard environment directories (Etc/Srv/Var/Tmp) |
env.Conf |
JSON config file with default: tag support |
env.Dir |
ensure a directory tree exists |
env.Lock |
single-instance file lock |
env.Expire |
TTL-based scratch-file cleanup (graceful Init) |
env.Persist / env.Map |
gob persistence with optional expiry |
env.NewGraceful |
graceful startup/shutdown controller |
env.Shutdown |
minimal signal/context shutdown helper |
See example/main.go for a runnable subcommand CLI whose
serve command drives a graceful service.
MIT — see LICENSE.