Core config & arguments
The framework configures itself through the same machinery it gives your
services: the core owns a config struct, and its values follow the usual
precedence — defaults < config files < environment < arguments. This page
lists what the core processes, which values are deliberately locked out of
config files, and how to remove core features from a binary entirely.
Reserved names#
Two aliases are reserved — claiming either is a startup error:
core, the framework's own configuration, and introspection, the
core-registered Introspector. The framework's own
id sxcli.dev/fw is reserved too. In config files the core's values
live in the core section:
{ "core": { "disable": ["sqlite"], "override": ["sqlite=mysql"] } }
Environment names, as always, derive from the applet's primary alias,
not from core: for an applet srv the core's --config argument reads
SRV__CONFIG.
What the core processes#
| Value | Argument | Environment | Config file | Purpose |
|---|---|---|---|---|
| config | --config, -c | derived (<ALIAS>__CONFIG) | never | explicit config file; replaces the location search |
| help | --help, -h | never | never | print the full argument schema with current effective values, exit 0 |
| write-config | --write-config | never | never | write the merged configuration, exit 0 |
| validate-config | --validate-config | never | never | run every check, report, exit without running |
| upgrade-config | --upgrade-config | never | never | migrate the --config file's sections to current schema versions, exit |
| from-version | --from-version | never | never | assert a versionless section's version (section=N) |
| disable | --disable | derived | yes | remove services from the closure |
| enable | --enable | derived | yes | force services into the closure |
| override | --override | derived | yes | remap dependencies (from=to) |
disable, enable and override are list-valued: repeat the argument to
append (--disable a --disable b), comma-separate the environment value,
use arrays in files.
The three service controls accept either vocabulary — a service's
alias or its id. An alias is resolved first, then an id, and a string
that is one service's alias but a different service's id is refused
rather than guessed at. (Their usage: strings say "service ids"; the
implementation takes both.) An unknown name is a startup error, never a
silent no-op.
--validate-config and --upgrade-config belong to
config migrations, which covers what they do to
a versioned file.
The core also dogfoods its own metadata layer, so tooling can treat
these values sensibly: --config declares HintFile, and
--disable/--enable declare HintServiceID — completable from the
Introspector. --override takes from=to pairs, which no honest hint
describes, so it declares none. See
Your config struct.
Run-scoped values and why config files refuse them#
help and write-config are argument-only. A config file or an
inherited environment variable setting them would be a persistent denial
of service — every run printing help, or writing a config and exiting.
config is settable by argument or environment (<APPLET>_CONFIG is a
legitimate deployment pattern) but never by a config file — a file
choosing the next file is redirection, not configuration.
Enforcement is loud in both directions:
- a config file attempting to set any of the three (
help,write-config,config) is a startup error, never a silent skip; - all three are excluded from
--write-configoutput, so a generated config can never smuggle them back in.
The same semantics are available to your own services: tag a field
dump:"-" and it becomes run-scoped — excluded from generated configs and
refused from config files.
`--write-config` and `--config` together#
--write-config short-circuits the run: the pipeline stops right after
the merge — the configuration is written and the process exits 0 without
Configured, Start or Run. Where it writes depends on --config:
$ mytool --write-config # no target: merged config as JSON to stdout $ mytool --write-config -c /etc/mytool/config.yaml # target: written in the format the extension names $ mytool --port 9090 --write-config -c local.json # arguments participate in the merge as usual
The output format follows the target's extension via a format provider —
.json natively, .yaml with the provider linked in. If no registered
provider claims the target's extension, startup fails and says so.
The target is input and output:
- An existing target is loaded first as the explicit config, then
rewritten — which makes
--write-configa one-command normalizer: point it at a hand-edited or differently-formatted file and it comes back canonical (this is also the way to convert JSON ⇄ YAML). - A missing target is only created. And here lies the one deliberate
exception to
--configsemantics: in a normal run a non-empty--configreplaces the standard location search entirely, but in--write-configmode a missing target does not — the locations are loaded and merged as usual, so the newly written file captures the configuration the binary would actually be running with.
The output is also minimal: empty values — and the sections they would leave empty — are omitted.
Permissions are conservative: a newly created file gets mode 0600; an
existing target's permissions are the operator's prior decision and are
left untouched — normalizing a file must not silently revoke a
deliberately granted group read.
Recomposing services#
Operators can recompose a binary without recompiling:
$ mytool --disable sqlite --enable mysql --override sqlite=mysql
disableremoves services from the closure even if required. Disabling the dispatched applet itself fails with a message naming it, as does listing the same service in bothenableanddisable.enablepulls services in — with their transitive dependencies — even if nothing requires them. This is also how a service earns an unconditional lifecycle now thatAlwaysOnis gone.overrideremaps dependencies: wherever a dependency namessqlite,mysqlis resolved instead. The substitute must satisfy the dependency's field type, checked at resolve time. An override key may name an unregistered id, and an override matching no dependency is legal — but every unused key is logged as a warning, so a typo never silently does nothing.- A dependency marked
;optionaltolerates a disabled target, never an unknown one: an id that names no registered service is a startup error even on an optional field.
Limits#
Config files are size-capped: the default cap is 1 MiB, checked with
stat before the file is ever opened. The cap is a build-time
property of the binary:
fw.MaxConfigSize(64 << 10) // tighter than the default
Suppressing core features#
A hardened or embedded binary can remove pieces of the core's surface
entirely — build-time, from main() or an init() before Main:
func main() { fw.Suppress(fw.FeatureConfigFile, fw.FeatureOverride) fw.Main() }
Suppressible: FeatureConfigFile (--config), FeatureWriteConfig,
FeatureDisable, FeatureEnable, FeatureOverride, FeatureHelp,
FeatureValidateConfig, and FeatureUpgradeConfig (which takes its
--from-version companion with it — the companion is inert alone).
A suppressed feature vanishes: its argument becomes unknown (a
strict-parse error), its environment variable is never consulted, and its
key appearing in a config file's core section is a loud startup error —
operators learn it is not honored instead of wondering why it is ignored.
The counterpart for default-off features is fw.Enable. There is
currently one: FeatureSCMDebug unlocks the Windows-only --scm-debug
argument, which runs the Windows service code path in a console for
testing. It is argument-only by construction, absent from --help, and
without the opt-in the token is rejected as an unknown argument — as it is
on every non-Windows platform.
Misusing the build-time API is itself a collected startup violation, never
silently ignored: suppressing a default-off feature, enabling a default-on
one, unknown features, or a non-positive MaxConfigSize all fail startup
with every problem reported at once.
Related: the full rules live in the design spec; the API surface is in the reference.