mirror of
https://github.com/coder/coder.git
synced 2026-09-01 14:53:15 +08:00
docs: lead with env vars in admin docs and add configuration reference (#26824)
## What & why Admin/setup docs lead with `coder server --flag` examples, but most operators configure Coder through `CODER_*` environment variables (system service, container, or Helm chart). There is no single page mapping a setting to its env var, CLI flag, YAML key, and default, so searching the docs for an env var name such as `CODER_PG_CONNECTION_URL` returns nothing. This adds a generated configuration reference and begins shifting admin docs to lead with the environment-variable form. ## Changes - **Generated configuration reference** (`docs/admin/setup/configuration-reference.md`): a searchable, per-setting list of every visible deployment option. Each option is a heading (grouped and nested by serpent group) followed by its description and the environment variable, CLI flag, YAML key, and default that apply to it. Generated from `codersdk.DeploymentValues` so it stays in sync. - **Generator + `make gen` wiring** (`scripts/configdocgen/`): new binary plus a Makefile target and `GEN_FILES` entry, mirroring the existing `clidocgen` / `auditdocgen` pattern. Output is host-independent (same env normalization as `clidocgen`). - **Demo conversion** (`docs/admin/users/github-auth.md`): inverted to lead with the `/etc/coder.d/coder.env` env-var form; the CLI-flag form becomes a closing note that links to the reference. H2 slugs preserved. - **Style guide** (`.claude/docs/DOCS_STYLE_GUIDE.md`): documents the env-var-first convention for admin/setup docs. - **Navigation**: manifest entry under Administration → Setup, plus a TIP callout on the setup index. ## Risk Docs + gen pipeline only; no runtime change. The page is regenerated by `make gen`; the `gen` and `check-docs` CI checks pass. ## Follow-up Several other admin pages still lead with flag walls. Recommend sweeping them incrementally in separate PRs rather than expanding scope here. <details> <summary>Implementation notes (provenance, conflict resolution, verification)</summary> - Continues prior work by @aslilac and @bpmct from the `kayla/docs-env-vars-first` branch. Both original commits are cherry-picked here with authorship preserved. - Rebased onto current `main`. Resolved two `Makefile` conflicts where `main` had since added the `feature-stages.md` gen target at the same locations; kept both targets (union) in `GEN_FILES`, `gen/mark-fresh`, and the recipe block. - The original branch's checked-in page predated recent `codersdk.DeploymentValues` changes, so it was **regenerated** against current `main` (adds `CODER_SCIM_USE_LEGACY`, the `Networking / Cluster` section with `CODER_CLUSTER_HOST`, `CODER_BOUNDARY_LOG_RETENTION`, and the AI Gateway description rename). The `gen` CI check enforces this stays current. - Fixed flag-link anchors for short-form flags (`--config`, `--log-filter`): the generator derives the anchor from `FlagShorthand` to match `clidocgen`'s heading (e.g. `#-l---log-filter`). - `linkspector` ignores the AWS Bedrock base URL that appears as an illustrative `<region>` placeholder in an option description, consistent with the existing `openai.com` ignore patterns. </details> <details> <summary>Configuration reference layout (2026-07-08 update)</summary> Reworked the reference from a wide table into a nested, per-setting list so it fits without horizontal scrolling and stops repeating the group name in every heading: - **List, not table.** Each option renders as a heading, its description, and a bullet list of only the configuration methods that apply to it (non-applicable methods are omitted instead of shown as `-`). - **Nested sections.** Sections nest by the serpent group hierarchy, so `Email / Email Authentication` becomes `Email` (h2) with an `Email authentication` (h3) subsection instead of a redundant flat title. - **Shorter, sentence-case headings.** The redundant group prefix is stripped from each option name and the remainder is lowercased to sentence case, preserving acronyms and mixed-case tokens (`URL`, `TLS`, `OAuth2`, `GitHub`) plus a small proper-noun allowlist (`Coder`, `Terraform`, `Honeycomb`, `Anthropic`, `Bedrock`, ...). Example: `AI Gateway Send Actor Headers` becomes `Send actor headers`. - **Deprecated options** sort to the end of each section and lead with an emphasized **Deprecated** marker. Headings stay clean (no `(deprecated)` suffix) so their anchors remain stable. - **Section intros** render from a group's `Description` when the source defines one (e.g. DERP); no hand-maintained prose or links are introduced. All transformations run in pure Go at `make gen` time (no AI at generation time). Generation is idempotent, and `markdownlint` and `golangci-lint` both pass. </details> --- 🤖 Opened by Coder Agents on behalf of @nickvigilante. Continues work by @aslilac and @bpmct. --------- Co-authored-by: Kayla (via Coder Agents) <kayla@coder.com> Co-authored-by: Coder Agents <noreply@coder.com> Co-authored-by: Ben Potter <me@bpmct.net>
This commit is contained in:
@@ -168,6 +168,37 @@ superseded by the canonical content guidelines.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Configuration examples: prefer environment variables
|
||||
|
||||
When showing how to configure `coder server` in admin or setup
|
||||
documentation, lead with the environment variable form. Production Coder
|
||||
deployments are typically run as a system service, container, or Helm
|
||||
chart, all of which set configuration through environment variables (for
|
||||
example, Helm `values.yaml` for Kubernetes or `/etc/coder.d/coder.env` for
|
||||
a system service). Showing the CLI flag form first forces operators to
|
||||
mentally translate every example.
|
||||
|
||||
Show the equivalent CLI flag only when the example is invoking
|
||||
`coder server` directly (for local development or one-off runs), or as a
|
||||
supporting note. Point readers at the
|
||||
[configuration reference](../../docs/admin/setup/configuration-reference.md)
|
||||
for the full mapping between environment variables, flags, and YAML keys.
|
||||
|
||||
````markdown
|
||||
```sh
|
||||
# Preferred for admin/setup docs:
|
||||
CODER_UPDATE_CHECK=false
|
||||
```
|
||||
````
|
||||
|
||||
CLI flag form, reserved for ad-hoc invocations:
|
||||
|
||||
````markdown
|
||||
```sh
|
||||
coder server --update-check=false
|
||||
```
|
||||
````
|
||||
|
||||
### Command Examples
|
||||
|
||||
````markdown
|
||||
|
||||
@@ -38,5 +38,8 @@ ignorePatterns:
|
||||
- pattern: "merriam-webster.com"
|
||||
# npmjs.com returns 403 from GitHub runner IPs
|
||||
- pattern: "npmjs.com"
|
||||
# AWS Bedrock base URL appears as an illustrative <region> placeholder in
|
||||
# the generated configuration reference, not as a live link.
|
||||
- pattern: "bedrock-runtime"
|
||||
aliveStatusCodes:
|
||||
- 200
|
||||
|
||||
@@ -73,6 +73,7 @@ endif
|
||||
docs/manifest.json \
|
||||
docs/admin/integrations/prometheus.md \
|
||||
docs/admin/security/audit-logs.md \
|
||||
docs/admin/setup/configuration-reference.md \
|
||||
docs/reference/cli/index.md \
|
||||
coderd/apidoc/swagger.json \
|
||||
coderd/rbac/object_gen.go \
|
||||
@@ -153,6 +154,12 @@ _gen/bin/clidocgen: $(CLIDOCGEN_INPUTS) | _gen
|
||||
@mkdir -p _gen/bin
|
||||
go build -o $@ ./scripts/clidocgen
|
||||
|
||||
# configdocgen reflects over codersdk.DeploymentValues to produce the
|
||||
# configuration reference page.
|
||||
_gen/bin/configdocgen: $(wildcard scripts/configdocgen/*.go) $(wildcard codersdk/*.go) | _gen
|
||||
@mkdir -p _gen/bin
|
||||
go build -o $@ ./scripts/configdocgen
|
||||
|
||||
_gen/bin/dbdump: $(wildcard coderd/database/gen/dump/*.go) $(DBDUMP_INPUTS) | _gen
|
||||
@mkdir -p _gen/bin
|
||||
go build -o $@ ./coderd/database/gen/dump
|
||||
@@ -1002,6 +1009,7 @@ GEN_FILES := \
|
||||
docs/reference/cli/index.md \
|
||||
docs/admin/security/audit-logs.md \
|
||||
docs/install/releases/feature-stages.md \
|
||||
docs/admin/setup/configuration-reference.md \
|
||||
coderd/apidoc/swagger.json \
|
||||
docs/manifest.json \
|
||||
provisioner/terraform/testdata/version \
|
||||
@@ -1100,6 +1108,7 @@ gen/mark-fresh:
|
||||
docs/reference/cli/index.md \
|
||||
docs/admin/security/audit-logs.md \
|
||||
docs/install/releases/feature-stages.md \
|
||||
docs/admin/setup/configuration-reference.md \
|
||||
coderd/apidoc/swagger.json \
|
||||
docs/manifest.json \
|
||||
site/e2e/provisionerGenerated.ts \
|
||||
@@ -1346,6 +1355,13 @@ docs/install/releases/feature-stages.md: \
|
||||
pnpm exec markdown-table-formatter "$$tmpfile" && \
|
||||
mv "$$tmpfile" "$@" && rm -rf "$$tmpdir"
|
||||
|
||||
docs/admin/setup/configuration-reference.md: node_modules/.installed $(wildcard scripts/configdocgen/*.go) $(wildcard codersdk/*.go) _gen/bin/configdocgen | _gen
|
||||
tmpdir=$$(mktemp -d -p _gen) && tmpfile=$$(realpath "$$tmpdir")/$(notdir $@) && \
|
||||
_gen/bin/configdocgen --out="$$tmpfile" && \
|
||||
pnpm exec markdownlint-cli2 --fix "$$tmpfile" && \
|
||||
pnpm exec markdown-table-formatter "$$tmpfile" && \
|
||||
mv "$$tmpfile" "$@" && rm -rf "$$tmpdir"
|
||||
|
||||
coderd/apidoc/.gen: \
|
||||
node_modules/.installed \
|
||||
scripts/apidocgen/node_modules/.installed \
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,11 @@ Coder server's primary configuration is done via environment variables. For a
|
||||
full list of the options, run `coder server --help` or see our
|
||||
[CLI documentation](../../reference/cli/server.md).
|
||||
|
||||
> [!TIP]
|
||||
> Need to look up an exact environment variable, CLI flag, or YAML key for a
|
||||
> setting? See the [configuration reference](./configuration-reference.md) for
|
||||
> a searchable list of every option.
|
||||
|
||||
## Access URL
|
||||
|
||||
`CODER_ACCESS_URL` is required if you are not using the tunnel. Set this to the
|
||||
|
||||
@@ -85,37 +85,13 @@ CODER_OAUTH2_GITHUB_DEFAULT_PROVIDER_ENABLE=false
|
||||
|
||||
## Step 2: Configure Coder with the OAuth credentials
|
||||
|
||||
Go to your Coder host and run the following command to start up the Coder server:
|
||||
Coder server reads these settings from environment variables. Set them
|
||||
wherever your deployment manages environment variables. For example, use
|
||||
Helm `values.yaml` for Kubernetes or `/etc/coder.d/coder.env` for a system
|
||||
service.
|
||||
|
||||
```sh
|
||||
coder server --oauth2-github-allow-signups=true --oauth2-github-allowed-orgs="your-org" --oauth2-github-client-id="8d1...e05" --oauth2-github-client-secret="57ebc9...02c24c"
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> For GitHub Enterprise support, specify the `--oauth2-github-enterprise-base-url` flag.
|
||||
|
||||
Alternatively, if you are running Coder as a system service, you can achieve the
|
||||
same result as the command above by adding the following environment variables
|
||||
to the `/etc/coder.d/coder.env` file:
|
||||
|
||||
```sh
|
||||
CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS=true
|
||||
CODER_OAUTH2_GITHUB_ALLOWED_ORGS="your-org"
|
||||
CODER_OAUTH2_GITHUB_CLIENT_ID="8d1...e05"
|
||||
CODER_OAUTH2_GITHUB_CLIENT_SECRET="57ebc9...02c24c"
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> To allow everyone to sign up using GitHub, set:
|
||||
>
|
||||
> ```shell
|
||||
> CODER_OAUTH2_GITHUB_ALLOW_EVERYONE=true
|
||||
> ```
|
||||
|
||||
Once complete, run `sudo service coder restart` to reboot Coder.
|
||||
|
||||
If deploying Coder via Helm, you can set the above environment variables in the
|
||||
`values.yaml` file as such:
|
||||
**Kubernetes (Helm):** set the variables under `coder.env` in your
|
||||
`values.yaml`:
|
||||
|
||||
```yaml
|
||||
coder:
|
||||
@@ -129,17 +105,48 @@ coder:
|
||||
# If setting allowed orgs, comment out CODER_OAUTH2_GITHUB_ALLOW_EVERYONE and its value
|
||||
- name: CODER_OAUTH2_GITHUB_ALLOWED_ORGS
|
||||
value: "your-org"
|
||||
# If allowing everyone, comment out CODER_OAUTH2_GITHUB_ALLOWED_ORGS and it's value
|
||||
# If allowing everyone, comment out CODER_OAUTH2_GITHUB_ALLOWED_ORGS and its value
|
||||
#- name: CODER_OAUTH2_GITHUB_ALLOW_EVERYONE
|
||||
# value: "true"
|
||||
```
|
||||
|
||||
To upgrade Coder, run:
|
||||
Then apply the change with `helm upgrade`:
|
||||
|
||||
```sh
|
||||
helm upgrade <release-name> coder-v2/coder -n <namespace> -f values.yaml
|
||||
```
|
||||
|
||||
**System service:** add the variables to `/etc/coder.d/coder.env`:
|
||||
|
||||
```sh
|
||||
CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS=true
|
||||
CODER_OAUTH2_GITHUB_ALLOWED_ORGS="your-org"
|
||||
CODER_OAUTH2_GITHUB_CLIENT_ID="8d1...e05"
|
||||
CODER_OAUTH2_GITHUB_CLIENT_SECRET="57ebc9...02c24c"
|
||||
```
|
||||
|
||||
Then restart Coder with `sudo service coder restart`.
|
||||
|
||||
> [!TIP]
|
||||
> To allow everyone to sign up using GitHub, set:
|
||||
>
|
||||
> ```shell
|
||||
> CODER_OAUTH2_GITHUB_ALLOW_EVERYONE=true
|
||||
> ```
|
||||
|
||||
For GitHub Enterprise support, also set
|
||||
`CODER_OAUTH2_GITHUB_ENTERPRISE_BASE_URL`.
|
||||
|
||||
> [!NOTE]
|
||||
> Every option above also has an equivalent CLI flag (for example,
|
||||
> `CODER_OAUTH2_GITHUB_CLIENT_ID` becomes `--oauth2-github-client-id`).
|
||||
> CLI flags are convenient for ad-hoc invocations of `coder server` during
|
||||
> local development. For production deployments, prefer environment
|
||||
> variables so the configuration lives with the container, Helm chart, or
|
||||
> service unit that manages Coder. See the
|
||||
> [configuration reference](../setup/configuration-reference.md) for the
|
||||
> full mapping between environment variables and flags.
|
||||
|
||||
We recommend requiring and auditing MFA usage for all users in your GitHub organizations.
|
||||
This can be enforced from the organization settings page in the **Authentication security** sidebar tab.
|
||||
|
||||
|
||||
@@ -423,6 +423,11 @@
|
||||
"path": "./admin/setup/appearance.md",
|
||||
"state": ["premium"]
|
||||
},
|
||||
{
|
||||
"title": "Configuration Reference",
|
||||
"description": "Searchable list of every Coder server setting with its environment variable, CLI flag, and YAML key",
|
||||
"path": "./admin/setup/configuration-reference.md"
|
||||
},
|
||||
{
|
||||
"title": "Telemetry",
|
||||
"description": "Learn what usage telemetry Coder collects, why it is collected, and how to opt out.",
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
// Command configdocgen generates the Coder server configuration reference at
|
||||
// docs/admin/setup/configuration-reference.md from codersdk.DeploymentValues.
|
||||
// It lists every visible deployment option grouped by serpent group. Each
|
||||
// option is rendered as a heading with its description followed by the
|
||||
// environment variable, CLI flag, YAML key, and default that apply to it.
|
||||
// Because the source is DeploymentValues, the page stays in sync as options
|
||||
// change.
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/scripts/atomicwrite"
|
||||
"github.com/coder/flog"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
const header = `<!-- DO NOT EDIT | GENERATED CONTENT -->
|
||||
# Configuration reference
|
||||
|
||||
Coder server is configured primarily through environment variables. This page
|
||||
lists every option so you can search by environment variable name, CLI flag, or
|
||||
YAML key. For first-time setup guidance and worked examples, see
|
||||
[Configure Control Plane Access](./index.md).
|
||||
|
||||
Each option can be set through one or more of the methods below. An option lists
|
||||
only the methods that apply to it.
|
||||
|
||||
- An environment variable (recommended for production deployments running as a
|
||||
system service, container, or Helm chart).
|
||||
- A CLI flag passed to ` + "`coder server`" + ` (useful for one-off invocations
|
||||
and local development).
|
||||
- A key in a YAML configuration file passed with ` + "`--config`" + `.
|
||||
|
||||
For a full description of each option's accepted values and behavior, follow the
|
||||
flag link into the [` + "`coder server`" + ` CLI reference](../../reference/cli/server.md).
|
||||
|
||||
Deprecated options are listed at the end of each section.
|
||||
|
||||
`
|
||||
|
||||
// generalSection holds options that do not belong to a serpent group.
|
||||
const generalSection = "General"
|
||||
|
||||
// option is the normalized data needed to render one deployment option.
|
||||
type option struct {
|
||||
title string // short, sentence-case heading text
|
||||
env string
|
||||
flagName string
|
||||
flagAnchor string
|
||||
yaml string
|
||||
defValue string
|
||||
desc string
|
||||
deprecated bool
|
||||
sortKey string // original serpent name, for stable ordering
|
||||
}
|
||||
|
||||
// node is one section of the reference: a serpent group (or the synthetic
|
||||
// "General" group) with its direct options and any child sections.
|
||||
type node struct {
|
||||
name string // raw group name (leaf); sentence-cased at render time
|
||||
intro string // group description, if any
|
||||
options []option
|
||||
children []*node
|
||||
childIdx map[string]*node
|
||||
}
|
||||
|
||||
func newNode(name string) *node {
|
||||
return &node{name: name, childIdx: map[string]*node{}}
|
||||
}
|
||||
|
||||
// child returns the named child section, creating it on first use.
|
||||
func (n *node) child(name string) *node {
|
||||
if c, ok := n.childIdx[name]; ok {
|
||||
return c
|
||||
}
|
||||
c := newNode(name)
|
||||
n.childIdx[name] = c
|
||||
n.children = append(n.children, c)
|
||||
return c
|
||||
}
|
||||
|
||||
// prepareEnv mirrors scripts/clidocgen so the generated defaults do not
|
||||
// depend on the generating host. Without it, defaults derived from
|
||||
// os.UserCacheDir and the config dir embed the local home directory.
|
||||
func prepareEnv() {
|
||||
for _, env := range os.Environ() {
|
||||
if strings.HasPrefix(env, "CODER_") {
|
||||
name, _, _ := strings.Cut(env, "=")
|
||||
if err := os.Unsetenv(name); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err := os.Setenv("CLIDOCGEN_CACHE_DIRECTORY", "~/.cache")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = os.Setenv("CLIDOCGEN_CONFIG_DIRECTORY", "~/.config/coderv2")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = os.Setenv("TMPDIR", "/tmp")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
prepareEnv()
|
||||
|
||||
out := flag.String("out", "docs/admin/setup/configuration-reference.md", "path to write the generated reference page")
|
||||
flag.Parse()
|
||||
|
||||
var vals codersdk.DeploymentValues
|
||||
opts := vals.Options()
|
||||
|
||||
root := buildTree(opts)
|
||||
body := render(root)
|
||||
|
||||
content := header + body
|
||||
content = strings.TrimRight(content, "\n") + "\n"
|
||||
if err := atomicwrite.File(*out, []byte(content)); err != nil {
|
||||
flog.Fatalf("write %s: %v", *out, err)
|
||||
}
|
||||
flog.Successf("wrote %s", *out)
|
||||
}
|
||||
|
||||
// buildTree groups options into a section tree, skipping hidden options and
|
||||
// options that have no environment variable, flag, or YAML key (those cannot
|
||||
// be set by an operator).
|
||||
func buildTree(opts serpent.OptionSet) *node {
|
||||
root := newNode("")
|
||||
for _, opt := range opts {
|
||||
if opt.Hidden {
|
||||
continue
|
||||
}
|
||||
if opt.Env == "" && opt.Flag == "" && opt.YAML == "" {
|
||||
continue
|
||||
}
|
||||
sec := sectionFor(root, opt.Group)
|
||||
sec.options = append(sec.options, toOption(opt))
|
||||
}
|
||||
sortTree(root)
|
||||
return root
|
||||
}
|
||||
|
||||
// sectionFor returns the section node for an option's group, creating the
|
||||
// chain of ancestor sections as needed. Options with no group (or an unnamed
|
||||
// group) live in the General section.
|
||||
func sectionFor(root *node, g *serpent.Group) *node {
|
||||
if g == nil {
|
||||
return root.child(generalSection)
|
||||
}
|
||||
cur := root
|
||||
for _, ancestor := range g.Ancestry() {
|
||||
if ancestor.Name == "" {
|
||||
return root.child(generalSection)
|
||||
}
|
||||
cur = cur.child(ancestor.Name)
|
||||
if cur.intro == "" {
|
||||
cur.intro = collapse(ancestor.Description)
|
||||
}
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
func toOption(opt serpent.Option) option {
|
||||
var flagName, flagAnchor string
|
||||
if opt.Flag != "" {
|
||||
flagName = "--" + opt.Flag
|
||||
// clidocgen renders a flag heading as "### -s, --flag" when it has a
|
||||
// shorthand and "### --flag" otherwise, so the anchor must include the
|
||||
// shorthand to match.
|
||||
flagAnchor = "--" + opt.Flag
|
||||
if opt.FlagShorthand != "" {
|
||||
flagAnchor = "-" + opt.FlagShorthand + "---" + opt.Flag
|
||||
}
|
||||
}
|
||||
|
||||
def := opt.Default
|
||||
if def == "" && opt.DefaultFn != nil {
|
||||
// DefaultFn results depend on the host environment, so evaluating them
|
||||
// here would leak host-specific values. Send the reader to the CLI
|
||||
// reference for the resolved default instead.
|
||||
def = "(computed at runtime)"
|
||||
}
|
||||
|
||||
return option{
|
||||
title: shortTitle(opt),
|
||||
env: opt.Env,
|
||||
flagName: flagName,
|
||||
flagAnchor: flagAnchor,
|
||||
yaml: opt.YAMLPath(),
|
||||
defValue: def,
|
||||
desc: collapse(opt.Description),
|
||||
deprecated: isDeprecated(opt),
|
||||
sortKey: opt.Name,
|
||||
}
|
||||
}
|
||||
|
||||
// isDeprecated reports whether an option is deprecated. serpent tracks
|
||||
// replacements in UseInstead, and codersdk also marks some options by leading
|
||||
// the description with "Deprecated".
|
||||
func isDeprecated(opt serpent.Option) bool {
|
||||
if len(opt.UseInstead) > 0 {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(opt.Description)), "deprecated")
|
||||
}
|
||||
|
||||
// sortTree orders sections and their options. General sorts first and
|
||||
// Dangerous last among top-level sections; every other section is
|
||||
// alphabetical. Within a section, active options come before deprecated ones,
|
||||
// each alphabetical by their original name.
|
||||
func sortTree(n *node) {
|
||||
slices.SortStableFunc(n.children, func(a, b *node) int {
|
||||
if c := cmp.Compare(sectionRank(a.name), sectionRank(b.name)); c != 0 {
|
||||
return c
|
||||
}
|
||||
return strings.Compare(a.name, b.name)
|
||||
})
|
||||
for _, c := range n.children {
|
||||
slices.SortStableFunc(c.options, func(a, b option) int {
|
||||
if a.deprecated != b.deprecated {
|
||||
if a.deprecated {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
return strings.Compare(a.sortKey, b.sortKey)
|
||||
})
|
||||
sortTree(c)
|
||||
}
|
||||
}
|
||||
|
||||
// sectionRank orders top-level sections: General first, Dangerous last
|
||||
// (regardless of its emoji prefix), everything else alphabetical.
|
||||
func sectionRank(name string) int {
|
||||
switch {
|
||||
case name == generalSection:
|
||||
return -1
|
||||
case strings.HasSuffix(name, "Dangerous"):
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func render(root *node) string {
|
||||
var b strings.Builder
|
||||
for _, sec := range root.children {
|
||||
renderNode(&b, sec, 2)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderNode(b *strings.Builder, n *node, level int) {
|
||||
_, _ = fmt.Fprintf(b, "%s %s\n\n", strings.Repeat("#", level), sentenceCase(n.name))
|
||||
if n.intro != "" {
|
||||
_, _ = b.WriteString(n.intro)
|
||||
_, _ = b.WriteString("\n\n")
|
||||
}
|
||||
for _, opt := range n.options {
|
||||
renderOption(b, opt, level+1)
|
||||
}
|
||||
for _, c := range n.children {
|
||||
renderNode(b, c, level+1)
|
||||
}
|
||||
}
|
||||
|
||||
func renderOption(b *strings.Builder, opt option, level int) {
|
||||
_, _ = fmt.Fprintf(b, "%s %s\n\n", strings.Repeat("#", level), opt.title)
|
||||
|
||||
desc := opt.desc
|
||||
if opt.deprecated {
|
||||
desc = emphasizeDeprecation(desc)
|
||||
}
|
||||
if desc != "" {
|
||||
_, _ = b.WriteString(desc)
|
||||
_, _ = b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if opt.env != "" {
|
||||
_, _ = fmt.Fprintf(b, "- Environment variable: `%s`\n", opt.env)
|
||||
}
|
||||
if opt.flagName != "" {
|
||||
_, _ = fmt.Fprintf(b, "- CLI flag: [`%s`](../../reference/cli/server.md#%s)\n", opt.flagName, opt.flagAnchor)
|
||||
}
|
||||
if opt.yaml != "" {
|
||||
_, _ = fmt.Fprintf(b, "- YAML key: `%s`\n", opt.yaml)
|
||||
}
|
||||
if opt.defValue != "" {
|
||||
_, _ = fmt.Fprintf(b, "- Default value: `%s`\n", opt.defValue)
|
||||
}
|
||||
_, _ = b.WriteString("\n")
|
||||
}
|
||||
|
||||
// emphasizeDeprecation bolds the leading "Deprecated" marker in a description
|
||||
// so a deprecated option reads clearly. Trailing text is left unbolded so the
|
||||
// paragraph is not a lone emphasis span (markdownlint MD036).
|
||||
func emphasizeDeprecation(desc string) string {
|
||||
const marker = "Deprecated"
|
||||
if len(desc) >= len(marker) && strings.EqualFold(desc[:len(marker)], marker) {
|
||||
if strings.TrimSpace(desc[len(marker):]) != "" {
|
||||
return "**" + desc[:len(marker)] + "**" + desc[len(marker):]
|
||||
}
|
||||
return desc
|
||||
}
|
||||
if desc == "" {
|
||||
return "Deprecated."
|
||||
}
|
||||
return "**Deprecated.** " + desc
|
||||
}
|
||||
|
||||
// shortTitle strips the redundant group prefix from an option name and returns
|
||||
// it in sentence case, e.g. "AI Gateway Send Actor Headers" becomes
|
||||
// "Send actor headers".
|
||||
func shortTitle(opt serpent.Option) string {
|
||||
name := opt.Name
|
||||
if opt.Group != nil {
|
||||
name = stripGroupPrefix(name, opt.Group)
|
||||
}
|
||||
return sentenceCase(name)
|
||||
}
|
||||
|
||||
// stripGroupPrefix removes the group name that many option names repeat. For
|
||||
// space-prefixed names like "AI Gateway Send Actor Headers" it drops the
|
||||
// longest matching ancestor chain ("AI Gateway"). For colon-prefixed names
|
||||
// like "Notifications: Email TLS: StartTLS" it drops every segment up to the
|
||||
// last ": " once the leading segment belongs to the top-level group. Names
|
||||
// that do not repeat the group are returned unchanged.
|
||||
func stripGroupPrefix(name string, g *serpent.Group) string {
|
||||
ancestry := g.Ancestry()
|
||||
if len(ancestry) == 0 {
|
||||
return name
|
||||
}
|
||||
names := make([]string, len(ancestry))
|
||||
for i, a := range ancestry {
|
||||
names[i] = a.Name
|
||||
}
|
||||
|
||||
if before, _, ok := strings.Cut(name, ": "); ok {
|
||||
// Only treat the colon as a group separator when the leading segment
|
||||
// belongs to the top-level group. This avoids mangling meaningful
|
||||
// colons such as "Health Check Threshold: Database".
|
||||
if top := normalize(names[0]); top != "" && strings.HasPrefix(normalize(before), top) {
|
||||
if idx := strings.LastIndex(name, ": "); idx >= 0 {
|
||||
if rest := strings.TrimSpace(name[idx+len(": "):]); rest != "" {
|
||||
return rest
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try the longest ancestor suffix chain first (start == 0 is the full
|
||||
// path) so the most specific prefix wins.
|
||||
for start := range names {
|
||||
prefix := strings.Join(names[start:], " ") + " "
|
||||
if rest, ok := cutFold(name, prefix); ok {
|
||||
if rest = strings.TrimSpace(rest); rest != "" {
|
||||
return rest
|
||||
}
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// properNouns lists words that keep their capitalization in sentence case.
|
||||
// They have ordinary title-case shape, so keepWord's acronym check would not
|
||||
// otherwise catch them.
|
||||
var properNouns = map[string]bool{
|
||||
"anthropic": true,
|
||||
"bedrock": true,
|
||||
"claude": true,
|
||||
"coder": true,
|
||||
"google": true,
|
||||
"helm": true,
|
||||
"honeycomb": true,
|
||||
"maven": true,
|
||||
"postgres": true,
|
||||
"prometheus": true,
|
||||
"stackdriver": true,
|
||||
"tailscale": true,
|
||||
"terraform": true,
|
||||
"wireguard": true,
|
||||
}
|
||||
|
||||
// featureNames are multi-word names whose exact casing is restored after
|
||||
// sentence-casing. A name that prefixes another comes after the longer one.
|
||||
var featureNames = []string{
|
||||
"AI Gateway Proxy",
|
||||
"AI Gateway",
|
||||
"OpenID Connect",
|
||||
"Template Builder",
|
||||
}
|
||||
|
||||
// sentenceCase lowercases a heading's words after the first, preserving the
|
||||
// first word, acronyms and mixed-case tokens, proper nouns, and feature names.
|
||||
func sentenceCase(s string) string {
|
||||
words := strings.Fields(s)
|
||||
seenFirst := false
|
||||
for i, w := range words {
|
||||
if !seenFirst {
|
||||
// Keep any leading symbols (e.g. an emoji) and the first real word.
|
||||
if hasLetter(w) {
|
||||
seenFirst = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !keepWord(w) {
|
||||
words[i] = strings.ToLower(w)
|
||||
}
|
||||
}
|
||||
return restoreFeatureNames(strings.Join(words, " "))
|
||||
}
|
||||
|
||||
// restoreFeatureNames rewrites any case-insensitive occurrence of a feature
|
||||
// name with its canonical casing.
|
||||
func restoreFeatureNames(s string) string {
|
||||
for _, name := range featureNames {
|
||||
s = replaceFold(s, name)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// replaceFold replaces case-insensitive occurrences of canonical in s with
|
||||
// canonical's exact casing. It assumes canonical is ASCII, which holds for the
|
||||
// feature names above.
|
||||
func replaceFold(s, canonical string) string {
|
||||
lower := strings.ToLower(canonical)
|
||||
var b strings.Builder
|
||||
for {
|
||||
idx := strings.Index(strings.ToLower(s), lower)
|
||||
if idx < 0 {
|
||||
_, _ = b.WriteString(s)
|
||||
return b.String()
|
||||
}
|
||||
_, _ = b.WriteString(s[:idx])
|
||||
_, _ = b.WriteString(canonical)
|
||||
s = s[idx+len(canonical):]
|
||||
}
|
||||
}
|
||||
|
||||
// keepWord reports whether a word must keep its capitalization: proper nouns,
|
||||
// all-caps or mixed-case acronyms (URL, GitHub), and tokens with digits
|
||||
// (OAuth2).
|
||||
func keepWord(w string) bool {
|
||||
core := strings.Trim(w, "()[]{}:;,.\"'")
|
||||
if core == "" {
|
||||
return true
|
||||
}
|
||||
if properNouns[strings.ToLower(core)] {
|
||||
return true
|
||||
}
|
||||
for i, r := range core {
|
||||
if i == 0 {
|
||||
continue
|
||||
}
|
||||
if unicode.IsUpper(r) || unicode.IsDigit(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasLetter(s string) bool {
|
||||
for _, r := range s {
|
||||
if unicode.IsLetter(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// collapse trims a string and collapses internal runs of whitespace to a
|
||||
// single space so multi-line source text renders as one paragraph.
|
||||
func collapse(s string) string {
|
||||
return strings.Join(strings.Fields(s), " ")
|
||||
}
|
||||
|
||||
// normalize lowercases a string and drops everything but letters and digits,
|
||||
// so prefixes can be compared regardless of spacing, case, or punctuation.
|
||||
func normalize(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
_, _ = b.WriteRune(unicode.ToLower(r))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// cutFold trims prefix from s using a case-insensitive comparison, reporting
|
||||
// whether it was present.
|
||||
func cutFold(s, prefix string) (string, bool) {
|
||||
if len(s) >= len(prefix) && strings.EqualFold(s[:len(prefix)], prefix) {
|
||||
return s[len(prefix):], true
|
||||
}
|
||||
return s, false
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func TestSentenceCase(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"lowercases trailing words", "Send Actor Headers", "Send actor headers"},
|
||||
{"keeps trailing acronym", "Anthropic Base URL", "Anthropic base URL"},
|
||||
{"keeps all-caps token", "Allow BYOK", "Allow BYOK"},
|
||||
{"lowercases ordinary word", "Email Authentication", "Email authentication"},
|
||||
{"keeps proper noun", "Trace Honeycomb API Key", "Trace Honeycomb API key"},
|
||||
{"restores OpenID Connect", "OpenID Connect sign in text", "OpenID Connect sign in text"},
|
||||
{"keeps leading mixed-case token", "SSH Keygen Algorithm", "SSH keygen algorithm"},
|
||||
{"single lowercase word", "pprof", "pprof"},
|
||||
{"feature name", "AI Gateway", "AI Gateway"},
|
||||
{"longer feature name wins", "AI Gateway Proxy", "AI Gateway Proxy"},
|
||||
{"feature name as whole title", "Template Builder", "Template Builder"},
|
||||
{"feature name after leading word", "Disable Template Builder", "Disable Template Builder"},
|
||||
{"leading symbol is not the first word", "⚠️ Dangerous", "⚠️ Dangerous"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := sentenceCase(tc.in); got != tc.want {
|
||||
t.Errorf("sentenceCase(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripGroupPrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
aiGateway := serpent.Group{Name: "AI Gateway"}
|
||||
email := serpent.Group{Name: "Email"}
|
||||
emailAuth := serpent.Group{Name: "Email Authentication", Parent: &email}
|
||||
introspection := serpent.Group{Name: "Introspection"}
|
||||
healthCheck := serpent.Group{Name: "Health Check", Parent: &introspection}
|
||||
networking := serpent.Group{Name: "Networking"}
|
||||
derp := serpent.Group{Name: "DERP", Parent: &networking}
|
||||
oauth2 := serpent.Group{Name: "OAuth2"}
|
||||
github := serpent.Group{Name: "GitHub", Parent: &oauth2}
|
||||
dangerous := serpent.Group{Name: "⚠️ Dangerous"}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
group *serpent.Group
|
||||
want string
|
||||
}{
|
||||
// Space-prefixed names drop the group path.
|
||||
{"AI Gateway Send Actor Headers", &aiGateway, "Send Actor Headers"},
|
||||
{"DERP Config Path", &derp, "Config Path"},
|
||||
{"OAuth2 GitHub Allow Everyone", &github, "Allow Everyone"},
|
||||
// Colon-prefixed names drop up to the last ": ".
|
||||
{"Email Auth: Identity", &emailAuth, "Identity"},
|
||||
// A meaningful colon that is not a group separator is preserved.
|
||||
{"Health Check Threshold: Database", &healthCheck, "Threshold: Database"},
|
||||
// The Dangerous group's emoji name still matches its "DANGEROUS:" prefix.
|
||||
{"DANGEROUS: Allow Path App Sharing", &dangerous, "Allow Path App Sharing"},
|
||||
// Names that do not repeat the group are unchanged.
|
||||
{"Access URL", &networking, "Access URL"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := stripGroupPrefix(tc.name, tc.group); got != tc.want {
|
||||
t.Errorf("stripGroupPrefix(%q) = %q, want %q", tc.name, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortTitle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
aiGateway := serpent.Group{Name: "AI Gateway"}
|
||||
cases := []struct {
|
||||
opt serpent.Option
|
||||
want string
|
||||
}{
|
||||
{serpent.Option{Name: "AI Gateway Send Actor Headers", Group: &aiGateway}, "Send actor headers"},
|
||||
{serpent.Option{Name: "AI Gateway Anthropic Base URL", Group: &aiGateway}, "Anthropic base URL"},
|
||||
// No group: only sentence case applies.
|
||||
{serpent.Option{Name: "Cache Directory"}, "Cache directory"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.opt.Name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := shortTitle(tc.opt); got != tc.want {
|
||||
t.Errorf("shortTitle(%q) = %q, want %q", tc.opt.Name, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDeprecated(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
opt serpent.Option
|
||||
want bool
|
||||
}{
|
||||
{"description prefix", serpent.Option{Description: "Deprecated: use X instead."}, true},
|
||||
{"description sentence", serpent.Option{Description: "Deprecated and ignored."}, true},
|
||||
{"use instead", serpent.Option{UseInstead: []serpent.Option{{Name: "X"}}}, true},
|
||||
{"active", serpent.Option{Description: "A normal option."}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := isDeprecated(tc.opt); got != tc.want {
|
||||
t.Errorf("isDeprecated(%s) = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmphasizeDeprecation(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
// Description already starts with the marker: only the marker is bolded.
|
||||
{"marker with sentence", "Deprecated and ignored.", "**Deprecated** and ignored."},
|
||||
{"marker with colon", "Deprecated: use X.", "**Deprecated**: use X."},
|
||||
// Description does not start with the marker (the UseInstead path): the
|
||||
// marker is prepended.
|
||||
{"no marker", "A normal description.", "**Deprecated.** A normal description."},
|
||||
{"empty description", "", "Deprecated."},
|
||||
// A bare marker with no trailing text is left unbolded (markdownlint MD036).
|
||||
{"bare marker", "Deprecated", "Deprecated"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := emphasizeDeprecation(tc.in); got != tc.want {
|
||||
t.Errorf("emphasizeDeprecation(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollapse(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := collapse("a\n b\tc "); got != "a b c" {
|
||||
t.Errorf("collapse() = %q, want %q", got, "a b c")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderPipeline exercises buildTree and render end to end: section
|
||||
// nesting and ordering, option skipping, deprecated sinking, and the per-option
|
||||
// bullet list (environment variable, CLI flag anchor, YAML key, default).
|
||||
func TestRenderPipeline(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
email := serpent.Group{Name: "Email", YAML: "email"}
|
||||
emailAuth := serpent.Group{Name: "Email Authentication", YAML: "emailAuth", Parent: &email}
|
||||
dangerous := serpent.Group{Name: "⚠️ Dangerous", YAML: "dangerous"}
|
||||
|
||||
opts := serpent.OptionSet{
|
||||
// Hidden options and options with no env/flag/YAML are skipped.
|
||||
{Name: "Hidden Option", Env: "CODER_HIDDEN", Hidden: true},
|
||||
{Name: "Unsettable Option"},
|
||||
// General section (no group).
|
||||
{Name: "Access URL", Env: "CODER_ACCESS_URL", Flag: "access-url", Default: "https://example.com", Description: "The access URL."},
|
||||
// A DefaultFn with no static Default renders the computed-at-runtime label.
|
||||
{Name: "Cache Directory", Env: "CODER_CACHE_DIRECTORY", Flag: "cache-dir", DefaultFn: func() string { return "~/.cache/coder" }, Description: "The cache directory."},
|
||||
// Deprecated via UseInstead: description does not start with "Deprecated".
|
||||
{Name: "Email From", Env: "CODER_EMAIL_FROM", Flag: "email-from", YAML: "from", Group: &email, Description: "The sender address.", UseInstead: []serpent.Option{{Name: "Notifications Email From"}}},
|
||||
// Active option with a flag shorthand.
|
||||
{Name: "Email Smarthost", Env: "CODER_EMAIL_SMARTHOST", Flag: "email-smarthost", FlagShorthand: "s", YAML: "smarthost", Group: &email, Description: "The SMTP host."},
|
||||
// Nested child section.
|
||||
{Name: "Email Authentication Identity", Env: "CODER_EMAIL_AUTH_IDENTITY", YAML: "identity", Group: &emailAuth, Description: "The identity."},
|
||||
// A Dangerous group sorts last regardless of alphabetical order.
|
||||
{Name: "DANGEROUS: Allow All Cors", Env: "CODER_DANGEROUS_ALLOW_ALL_CORS", Flag: "dangerous-allow-all-cors", Group: &dangerous, Description: "Allow all cross-origin requests."},
|
||||
}
|
||||
|
||||
got := render(buildTree(opts))
|
||||
|
||||
wantContains := []string{
|
||||
"## General",
|
||||
"### Access URL",
|
||||
"- Environment variable: `CODER_ACCESS_URL`",
|
||||
"- CLI flag: [`--access-url`](../../reference/cli/server.md#--access-url)",
|
||||
"- Default value: `https://example.com`",
|
||||
"## Email",
|
||||
"### Smarthost",
|
||||
// Flag shorthand is folded into the anchor to match the CLI reference.
|
||||
"- CLI flag: [`--email-smarthost`](../../reference/cli/server.md#-s---email-smarthost)",
|
||||
// YAML key is the dotted group path.
|
||||
"- YAML key: `email.from`",
|
||||
// Deprecated marker is prepended for the UseInstead path.
|
||||
"**Deprecated.** The sender address.",
|
||||
// A DefaultFn with no static Default is labeled, not evaluated.
|
||||
"- Default value: `(computed at runtime)`",
|
||||
"### Email authentication",
|
||||
"#### Identity",
|
||||
"- YAML key: `email.emailAuth.identity`",
|
||||
// The Dangerous group renders as its own section.
|
||||
"## ⚠️ Dangerous",
|
||||
}
|
||||
for _, w := range wantContains {
|
||||
if !strings.Contains(got, w) {
|
||||
t.Errorf("render() missing %q\n---\n%s", w, got)
|
||||
}
|
||||
}
|
||||
|
||||
// General (rank -1) sorts before every other top-level section.
|
||||
if i, j := strings.Index(got, "## General"), strings.Index(got, "## Email"); i < 0 || j < 0 || i > j {
|
||||
t.Errorf("General should render before Email (got indexes %d, %d)", i, j)
|
||||
}
|
||||
// Active options sort before deprecated ones within a section.
|
||||
if i, j := strings.Index(got, "### Smarthost"), strings.Index(got, "### From"); i < 0 || j < 0 || i > j {
|
||||
t.Errorf("active option should render before deprecated option (got indexes %d, %d)", i, j)
|
||||
}
|
||||
// The Dangerous section sorts last among top-level sections.
|
||||
if i, j := strings.Index(got, "## Email"), strings.Index(got, "## ⚠️ Dangerous"); i < 0 || j < 0 || i > j {
|
||||
t.Errorf("Dangerous section should render last (got indexes %d, %d)", i, j)
|
||||
}
|
||||
// Hidden and unsettable options never render.
|
||||
if strings.Contains(got, "Hidden") {
|
||||
t.Error("hidden option should be skipped")
|
||||
}
|
||||
if strings.Contains(got, "Unsettable") {
|
||||
t.Error("option with no env/flag/YAML should be skipped")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user