Configuration¶
The configuration layer has been extracted into the standalone
gitlab.com/phpboyscout/go/config
module. Its full documentation — the Store/View model, layered sources and
precedence, typed sections (UnmarshalSection / ObserveSection), schema
validation, explicit hot-reload (Watch), transactional writes (Apply), flag
binding, and testing with the published mocks package — now lives at:
API reference: pkg.go.dev/gitlab.com/phpboyscout/go/config. See the migration note for the import-path change.
go-tool-base imports the module directly (no adapter package). This page documents only what GTB layers on top — the conventions and wiring that are framework concerns and deliberately not in the module.
How GTB wires the container¶
The root command builds the store during its pre-run and publishes it as
props.Config, so every command and initialiser receives the same instance:
p := &props.Props{
Config: cfg, // *config.Store
Logger: l,
FS: fs,
}
// Reads pin an immutable snapshot:
timeout := p.Config.View().GetString("app.timeout")
The store is the live object that owns reloads and writes; reads go through
props.Config.View(), which pins a consistent snapshot (a *config.View,
satisfying the module's config.Reader). Hot reload is explicit — the root
pre-run calls Store.Watch, so file changes propagate without any per-package
wiring — and writes go through the store's transactional Apply, which edits
the target file in place, preserving comments and writing only the named keys.
The layer declaration, highest precedence first: changed CLI flags; environment
variables under the tool's EnvPrefix; the project-local .<tool>.yaml; the
config files (--config paths if given, otherwise ~/.<tool>/config.yaml then
/etc/<tool>/config.yaml); the tool's ConfigPaths embedded assets; and the
merged assets/config.yaml embedded defaults, which always apply. The per-user
config outranks the system /etc file, the Unix convention.
Only config files that exist are declared as layers — a non-existent file
contributes nothing and must not become a phantom write target. The one
exception is the write destination: the highest-precedence path is always
available so config set/unset/edit have somewhere to land and can create
the file on first write. Writes therefore go to the per-user config (or the
project-local file when one is present), never to a system path the user cannot
write.
Two safeguards ride on every config-command write. The written file is
re-tightened to 0600: the store deliberately preserves an existing
file's mode on write (it treats the mode as the owner's choice), but a GTB
config file routinely holds credentials, so the framework actively re-asserts
owner-only permissions. And when a write would place a recognised credential
in a project-local .<tool>.yaml — a file that may be committed to version
control — config set warns, and, in an interactive terminal, asks to confirm.
It never blocks the write (a project-local secret can be deliberate), but it
nudges toward env-var or keychain storage. See
config set and
Configure credentials.
Props.Tool.EnvPrefix is propagated into the store as the module's
WithEnv layer, so a tool's config keys resolve from MYTOOL_* rather than bare
names — see the module's
env-prefix rationale.
Project-local trust: security keys are ignored until you trust the file¶
The project-local .<tool>.yaml is convenient, but it arrives with a repository
you may not have written — a git clone can ship one. So its security-sensitive
keys are ignored unless you explicitly trust the file, while its ordinary
workflow keys (logging, output, feature toggles) always apply. The protected set
is the keys that govern self-update verification and posture
(update.require_signature, update.require_checksum,
update.require_external_crosscheck, update.policy, update.key_source,
update.external_key_email), telemetry consent (telemetry.enabled), and every
credential subtree (*.auth.*, *.api.*, bitbucket.app_password). When any of
these is stripped from an untrusted file, the framework logs a WARN naming the
file and the ignored keys — never a silent drop.
Trust is direnv-style. <tool> config trust records the file's absolute path
and the SHA-256 of its exact current content in a per-user store
(~/.<tool>/trusted-projects.yaml, owner-only, never inside a repository).
Editing a trusted file — or a fresh clone swapping it out — changes the hash and
revokes trust until you run the command again. Use config trust --list to see
what is trusted and config trust --forget to revoke. Until a file is trusted it
is also read-only: config set in an untrusted repository routes the write to
your own config, not the repository file.
An explicit --config suppresses the project-local layer entirely (naming a file
means "use this one"), so this trust gate only applies to the implicitly
discovered .<tool>.yaml. CI runs untrusted by default — a pipeline that
legitimately depends on project-local security keys should trust the file in a
provisioning step or supply those values through the user config or environment.
See config trust and the
security-decisions record.
Embedded defaults: the assets/config.yaml convention¶
GTB discovers shipped assets at fixed paths inside each registered bundle's
embed.FS (directive: //go:embed assets/*):
assets/config.yaml— the embedded-defaults layer. Merged across every registered bundle and always applied as the lowest-precedence layer, so a user file that omits a key resolves to the shipped default.assets/init/config.yaml— the init template: the human-facing document (comments included) thatinitwrites to the user's config file.
The framework's own baseline bundle registers first (inside props.NewAssets),
the tool's bundle next, and feature bundles (registered via
setup.RegisterAssets) are applied for enabled features at root construction —
later bundles override earlier ones in the merged structured reads:
//go:embed assets/*
var assets embed.FS
p := &props.Props{
Assets: props.NewAssets(props.AssetMap{"root": &assets}),
// ...
}
Defaults live only here — never duplicated into default: struct tags, which the
module treats as hint text and never applies.
The project-local config layer¶
At startup GTB also looks for a project-local file named .<tool>.yaml (e.g.
.myapp.yaml), discovered by walking up from the working directory to the filesystem
root — a repo-root convention like .editorconfig. When found it is merged last among
the file sources, so it deep-merges over and overrides the per-user
~/.<tool>/config.yaml:
~/.myapp/config.yaml # global, per-user
/path/to/repo/.myapp.yaml # project — overrides the global, committed with the repo
This keeps a project's non-secret settings in the repo that owns them. A tool opts out
simply by not having the file; it never errors when absent. Environment variables and
flags still override it — it sits in the file tier of the module's precedence chain.
An explicit --config suppresses the layer entirely: naming a config file means
"use this one", and a project-local file the caller did not name must not override
files they did. The filename derives from Props.Tool.Name.
Binding CLI flags¶
GTB registers and binds flags on your behalf, so you rarely touch the module's
flag layer (config.WithFlags / config.BindFlag) directly:
portFlags := pflag.NewFlagSet("server", pflag.ContinueOnError)
portFlags.Int("server-port", 8080, "server port")
rootCmd := root.NewCmdRootWithOptions(props,
root.WithBoundFlags(map[string]*pflag.Flag{"server.port": portFlags.Lookup("server-port")}),
// or, by convention (--server-port -> server.port):
root.WithConventionBoundFlags(portFlags),
)
A subcommand's own local flags are bound by the same hyphen-to-dot convention when that command runs. Only flags the user actually changed are bound, which is what keeps a defaulted flag from masking file or env values — see the module's default-clobber warning.
The built-in --debug and --ci flags fold through the same path, so
Config.View().GetBool("ci") reflects --ci; --debug additionally retains its
immediate effect on the log level.
Initialiser integration¶
Tool initialisers work against two narrow surfaces:
IsConfigured(cfg config.Reader) checks existing state against a pinned view, and
Configure(p, cfg setup.Editor) writes new values through cfg.Set(...) — the
setup.Editor routes writes through the store's transactional Apply, so
template comments in the user's file survive the wizards.
Sensitive-value masking¶
Masking lives in GTB's config command (pkg/cmd/config/sensitive.go), not in the
container — the module never inspects values for sensitivity. config get / config
list render secrets as ****<tail> using two independent strategies:
- Key-name matching — the leaf segment of the dotted key against
token,password,secret,key,apikey,auth. - Value-content matching — the value against known token patterns (e.g.
ghp_…,github_pat_…), which catches keys likegithub.auth.valuewhose name is not sensitive.
Tool authors extend it via functional options:
cmdconfig.NewCmdConfig(props,
cmdconfig.WithKeyPattern("credential"),
cmdconfig.WithValuePattern(regexp.MustCompile(`^sk-[A-Za-z0-9]{32}$`)),
)
Relationship with init and config¶
| Workflow | Command |
|---|---|
| First-run bootstrap | init |
| Re-configure a subsystem interactively | init <subsystem> (e.g. init ai) |
| Read / write / remove a single value | config get / config set / config unset |
| Find where config actually lives | config path (backed by the store's declared file layers) |
| Hand-edit the file safely (re-validated) | config edit |
| Inspect all resolved config | config list |
| Validate config against schema | config validate |
Both InitCmd and ConfigCmd should be disabled in containerised services where local
YAML config is not applicable.
Related¶
- Module docs: config.go.phpboyscout.uk
- How-to: Test code that uses configuration, Bind flags to config, React to config changes, Validate component config
- Assets: Embed custom assets