From 79bd3d1f3abf94e3d3e4e1f07f41befd92108d45 Mon Sep 17 00:00:00 2001 From: Paul Gottschling Date: Thu, 15 Jan 2026 13:15:24 -0500 Subject: [PATCH] Add custom intros to generated CLI docs (#62850) Generated CLI reference docs take their introductory paragraphs from the app-wide descriptions defined using the `kingpin` library. However, this approach can make for awkward text, as the in-app descriptions are not intended for docs pages. This change modifies the logic for loading config files for generating CLI reference docs in order to define custom introductions. It enforces a nonempty introduction field in the config. There is currently one CLI reference page we generate from the source, the one for tsh. Edit the tsh reference generator config to include an introduction. Tangential changes: - Pass a loaded config in `updateAppUsageTemplate` so we can define introductions in tests. - Print error messages to stdout and exit with an error instead of panicking. This is because kingpin prints CLI help text to stderr, which gets redirected to the generated docs page. --- docs/pages/reference/cli/tsh.mdx | 6 +- lib/utils/docenvdefaults/tsh.yaml | 69 ------------------ lib/utils/docs-usage.md.tmpl | 5 +- lib/utils/docsconfigs/tsh.yaml | 75 +++++++++++++++++++ lib/utils/usage_docs.go | 115 ++++++++++++++++-------------- lib/utils/usage_docs_test.go | 30 ++++++-- 6 files changed, 166 insertions(+), 134 deletions(-) delete mode 100644 lib/utils/docenvdefaults/tsh.yaml create mode 100644 lib/utils/docsconfigs/tsh.yaml diff --git a/docs/pages/reference/cli/tsh.mdx b/docs/pages/reference/cli/tsh.mdx index 21f4e2e0739..688709ad3e7 100644 --- a/docs/pages/reference/cli/tsh.mdx +++ b/docs/pages/reference/cli/tsh.mdx @@ -9,7 +9,11 @@ tags: {/*vale messaging = NO*/} This guide provides a comprehensive list of commands, arguments, and flags for -tsh: Teleport Command Line Client. +tsh. + +tsh is a CLI client for accessing Teleport-protected resources. It allows users +to interact with current and past sessions on the cluster, copy files to and +from nodes, and list information about the cluster. ```code $ tsh [] [ ...] diff --git a/lib/utils/docenvdefaults/tsh.yaml b/lib/utils/docenvdefaults/tsh.yaml deleted file mode 100644 index b9f14831d75..00000000000 --- a/lib/utils/docenvdefaults/tsh.yaml +++ /dev/null @@ -1,69 +0,0 @@ -TELEPORT_AUTH: - description: "Any defined authentication connector, including `passwordless` and `local` (i.e., no authentication connector)" - default: "none" - type: "string" - -TELEPORT_CLUSTER: - description: "Name of a Teleport root or leaf cluster" - default: "none" - type: "string" - -TELEPORT_LOGIN: - description: "Login name to be used by default on the remote host" - default: "none" - type: "string" - -TELEPORT_LOGIN_BIND_ADDR: - description: "Address in the form of host:port to bind to for login command webhook" - default: "none" - type: "string" - -TELEPORT_PROXY: - description: "Address of the Teleport proxy server" - default: "none" - type: "string" - -TELEPORT_RELAY: - description: "Address of the Teleport relay server to use, \"none\" to disable the use of a relay, or \"default\" to use the default address specified by the control plane at login time. Defaults to port 443." - default: "none" - type: "string" - -TELEPORT_HEADLESS: - description: "Use headless authentication" - default: false - type: "bool" - -TELEPORT_HOME: - description: "Home location for tsh configuration and data" - default: "none" - type: "string" - -TELEPORT_USER: - description: "A Teleport user name" - default: "none" - type: "string" - -TELEPORT_ADD_KEYS_TO_AGENT: - description: "Specifies if the user certificate should be stored on the running SSH agent" - default: "auto" - type: "string" - -TELEPORT_USE_LOCAL_SSH_AGENT: - description: "Disable or enable local SSH agent integration" - default: "none" - type: "string" - -TELEPORT_GLOBAL_TSH_CONFIG: - description: "Override location of global `tsh` config file from default `/etc/tsh.yaml`" - default: "none" - type: "string" - -TELEPORT_MFA_MODE: - description: "Preferred mode for MFA and Passwordless assertions" - default: "auto" - type: "string" - -TELEPORT_IDENTITY_FILE: - description: "File path to identity file" - default: "none" - type: "string" diff --git a/lib/utils/docs-usage.md.tmpl b/lib/utils/docs-usage.md.tmpl index 4b2f0ff868b..24617f6d8b5 100644 --- a/lib/utils/docs-usage.md.tmpl +++ b/lib/utils/docs-usage.md.tmpl @@ -62,10 +62,9 @@ tags: {/*vale messaging = NO*/} This guide provides a comprehensive list of commands, arguments, and flags for -{{.App.Name}}: {{ if .App.Help -}} -{{- .App.Help|Wrap 0 }} -{{ end -}} +{{.App.Name}}. +{{ .App.Help|Wrap 0 }} {{template "FormatUsage" .App -}} {{if .Context.Flags|AnyVisibleFlags -}} Global flags: diff --git a/lib/utils/docsconfigs/tsh.yaml b/lib/utils/docsconfigs/tsh.yaml new file mode 100644 index 00000000000..ae1ce7411e7 --- /dev/null +++ b/lib/utils/docsconfigs/tsh.yaml @@ -0,0 +1,75 @@ +introduction: |- + tsh is a CLI client for accessing Teleport-protected resources. It allows + users to interact with current and past sessions on the cluster, copy files to + and from nodes, and list information about the cluster. + +env_vars: + TELEPORT_AUTH: + description: "Any defined authentication connector, including `passwordless` and `local` (i.e., no authentication connector)" + default: "none" + type: "string" + + TELEPORT_CLUSTER: + description: "Name of a Teleport root or leaf cluster" + default: "none" + type: "string" + + TELEPORT_LOGIN: + description: "Login name to be used by default on the remote host" + default: "none" + type: "string" + + TELEPORT_LOGIN_BIND_ADDR: + description: "Address in the form of host:port to bind to for login command webhook" + default: "none" + type: "string" + + TELEPORT_PROXY: + description: "Address of the Teleport proxy server" + default: "none" + type: "string" + + TELEPORT_RELAY: + description: "Address of the Teleport relay server to use, \"none\" to disable the use of a relay, or \"default\" to use the default address specified by the control plane at login time. Defaults to port 443." + default: "none" + type: "string" + + TELEPORT_HEADLESS: + description: "Use headless authentication" + default: false + type: "bool" + + TELEPORT_HOME: + description: "Home location for tsh configuration and data" + default: "none" + type: "string" + + TELEPORT_USER: + description: "A Teleport user name" + default: "none" + type: "string" + + TELEPORT_ADD_KEYS_TO_AGENT: + description: "Specifies if the user certificate should be stored on the running SSH agent" + default: "auto" + type: "string" + + TELEPORT_USE_LOCAL_SSH_AGENT: + description: "Disable or enable local SSH agent integration" + default: "none" + type: "string" + + TELEPORT_GLOBAL_TSH_CONFIG: + description: "Override location of global `tsh` config file from default `/etc/tsh.yaml`" + default: "none" + type: "string" + + TELEPORT_MFA_MODE: + description: "Preferred mode for MFA and Passwordless assertions" + default: "auto" + type: "string" + + TELEPORT_IDENTITY_FILE: + description: "File path to identity file" + default: "none" + type: "string" diff --git a/lib/utils/usage_docs.go b/lib/utils/usage_docs.go index e1457a60bde..491d6e87262 100644 --- a/lib/utils/usage_docs.go +++ b/lib/utils/usage_docs.go @@ -22,10 +22,8 @@ import ( "bytes" "cmp" _ "embed" - "errors" "fmt" "io" - "io/fs" "os" "path/filepath" "regexp" @@ -273,13 +271,45 @@ func formatHelp(help string) string { var docsUsageTemplatePath = filepath.Join("lib", "utils", "docs-usage.md.tmpl") // updateAppUsageTemplatePath updates the app usage template to print a reference -// guide for the CLI application. It reads the template from r. -func updateAppUsageTemplate(r io.Reader, app *kingpin.Application) { +// guide for the CLI application. It reads the template from r and uses the +// config to add an introductory paragraph and entries for environment variables +// that are not available to kingpin. +func updateAppUsageTemplate(r io.Reader, config generatorConfig, app *kingpin.Application) { var buf bytes.Buffer if _, err := buf.ReadFrom(r); err != nil { panic(fmt.Sprintf("unable to read from the docs usage template: %v", err)) } + existingEnvVars := make(map[string]struct{}) + for _, flag := range app.Model().Flags { + if flag.Envar != "" { + existingEnvVars[flag.Envar] = struct{}{} + } + } + + for envVarName, envVar := range config.EnvVars { + // Check if the flag already exists in the app model to avoid + // duplicate flag errors. + if _, flagExists := existingEnvVars[envVarName]; flagExists { + continue + } + + // If the flag does not exist, create it with the default value + // and description from the YAML file. + flag := app.Flag(envVarName, envVar.Description). + Envar(envVarName). + Default(envVar.Default) + if envVar.Type == "bool" { + flag.Bool() + } else { + flag.String() + } + } + + // We override the default app description with a custom description + // that is better suited to the docs. + app.Help = config.Introduction + app.UsageFuncs(map[string]any{ "AnyEnvVarsForCmd": anyEnvVarsForCmd, "AnyVisibleFlags": anyVisibleFlags, @@ -300,78 +330,55 @@ type envVarDefault struct { Type string `yaml:"type"` } -// loadDefaultEnvVars loads possible default environment variables defined in a YAML file +type generatorConfig struct { + Introduction string `yaml:"introduction"` + EnvVars map[string]envVarDefault `yaml:"env_vars"` +} + +// loadConfig loads possible default environment variables defined in a YAML file // that matches the application name. -func loadDefaultEnvVars(appName string) (map[string]envVarDefault, error) { - pathname := filepath.Join("lib", "utils", "docenvdefaults", appName+".yaml") - data, err := os.ReadFile(pathname) - envDefaults := make(map[string]envVarDefault) - if errors.Is(err, fs.ErrNotExist) { - fmt.Printf("No doc generation config file at %v. Skipping manual environment variable additions.", pathname) - return envDefaults, nil - } - +func loadConfig(appName string) (generatorConfig, error) { + pathname := filepath.Join("lib", "utils", "docsconfigs", appName+".yaml") + f, err := os.Open(pathname) if err != nil { - return nil, fmt.Errorf("unable to read CLI doc generation config file at %v: %w", pathname, err) + return generatorConfig{}, fmt.Errorf("unable to open CLI doc generation config file at %v: %w", pathname, err) } - if len(data) == 0 { - return nil, fmt.Errorf("read zero bytes from CLI doc generation config file at %v: %w", pathname, err) + var conf generatorConfig + if err = yaml.NewDecoder(f).Decode(&conf); err != nil { + return generatorConfig{}, fmt.Errorf("unable to parse the CLI doc generation config file at %v: %w", pathname, err) } - if err := yaml.Unmarshal(data, &envDefaults); err != nil { - return nil, fmt.Errorf("unable to parse YAML from %s: %w", pathname, err) + if conf.Introduction == "" { + return generatorConfig{}, fmt.Errorf(`CLI doc generation config file at %v must have an 'introduction' field`, pathname) } - for envVar, def := range envDefaults { + for envVar, def := range conf.EnvVars { if def.Description == "" || def.Default == "" || def.Type == "" { - return nil, fmt.Errorf("invalid YAML structure in %s: entry %q is missing one of required fields 'description', 'default' or 'type'", pathname, envVar) + return generatorConfig{}, fmt.Errorf("invalid YAML structure in %s: entry %q is missing one of required fields 'description', 'default' or 'type'", pathname, envVar) } } - return envDefaults, nil + return conf, nil } // UpdateAppUsageTemplate updates the app usage template to print a reference -// guide for the CLI application. Panics on errors since we need to keep the +// guide for the CLI application. Exits on errors since we need to keep the // signature of UpdateAppUsageTemplate consistent with the one included without -// build tags, i.e., with no return value. +// build tags, i.e., with no return value. Writes error messages to stdout to +// separate them from the help text, which kingpin writes to stderr. func UpdateAppUsageTemplate(app *kingpin.Application, _ []string) { - defaultEnvVars, err := loadDefaultEnvVars(app.Name) + config, err := loadConfig(app.Name) if err != nil { - panic(err) - } - - existingEnvVars := make(map[string]struct{}) - for _, flag := range app.Model().Flags { - if flag.Envar != "" { - existingEnvVars[flag.Envar] = struct{}{} - } - } - - for envVarName, envVar := range defaultEnvVars { - // Check if the flag already exists in the app model to avoid - // duplicate flag errors. - if _, flagExists := existingEnvVars[envVarName]; flagExists { - continue - } - - // If the flag does not exist, create it with the default value - // and description from the YAML file. - flag := app.Flag(envVarName, envVar.Description). - Envar(envVarName). - Default(envVar.Default) - if envVar.Type == "bool" { - flag.Bool() - } else { - flag.String() - } + fmt.Fprintf(os.Stdout, "Unable to load the docs generator configuration for %v: %v", app.Name, err) + os.Exit(1) } f, err := os.Open(docsUsageTemplatePath) if err != nil { - panic(fmt.Sprintf("unable to open the docs usage template at %v: %v", docsUsageTemplatePath, err)) + fmt.Fprintf(os.Stdout, "Unable to open the docs usage template at %v: %v", docsUsageTemplatePath, err) + os.Exit(1) } - updateAppUsageTemplate(f, app) + updateAppUsageTemplate(f, config, app) } diff --git a/lib/utils/usage_docs_test.go b/lib/utils/usage_docs_test.go index b9a77696045..67bbd3f827f 100644 --- a/lib/utils/usage_docs_test.go +++ b/lib/utils/usage_docs_test.go @@ -33,6 +33,7 @@ func TestUpdateAppUsageTemplate(t *testing.T) { name string makeApp func() *kingpin.Application expectSubstring string // The @ character is replaced with a backtick + config generatorConfig }{ { name: "subcommand flags and global flags", @@ -46,13 +47,23 @@ func TestUpdateAppUsageTemplate(t *testing.T) { createRocket.Flag("launch", "Whether to launch the Rocket").Bool() return app }, + config: generatorConfig{ + Introduction: "This is the main CLI tool.", + }, expectSubstring: `--- title: myapp Reference description: Provides a comprehensive list of commands, arguments, and flags for myapp. +sidebar_label: myapp +tags: + - reference + - platform-wide --- +{/*vale messaging = NO*/} This guide provides a comprehensive list of commands, arguments, and flags for -myapp: This is the main CLI tool. +myapp. + +This is the main CLI tool. @@@code $ myapp [] [ ...] @@ -117,8 +128,13 @@ Arguments: app.Flag("dry-run", "Whether to use dry-run mode").Default("false").Bool() return app }, + config: generatorConfig{ + Introduction: "This is the main CLI tool.", + }, expectSubstring: `This guide provides a comprehensive list of commands, arguments, and flags for -myapp: This is the main CLI tool. +myapp. + +This is the main CLI tool. @@@code $ myapp [] [ ...] @@ -129,8 +145,8 @@ Global flags: |Flag|Default|Description| |---|---|---| |@--config@|@config.yaml@|The location of the config file| -|@--verbosity@|@3@|Verbosity level.| |@--[no-]dry-run@|@false@|Whether to use dry-run mode| +|@--verbosity@|@3@|Verbosity level.| `, }, @@ -158,8 +174,8 @@ Flags: |Flag|Default|Description| |---|---|---| -|@--verbosity@|@3@|Verbosity level.| |@--[no-]dry-run@|@false@|Whether to use dry-run mode| +|@--verbosity@|@3@|Verbosity level.| `, }, @@ -187,8 +203,8 @@ Arguments: |Argument|Default|Description| |---|---|---| -|verbosity|@3@ (optional)|Verbosity level.| |dry-run|@false@ (optional)|Whether to use dry-run mode| +|verbosity|@3@ (optional)|Verbosity level.| `, }, @@ -399,8 +415,8 @@ Environment variables: |Variable|Default|Description| |---|---|---| -|@CREATE_TYPE@|none (optional)|The type of the resource| |@CREATE_NAME@|@myresource@|The name of the resource| +|@CREATE_TYPE@|none (optional)|The type of the resource| Flags: @@ -428,7 +444,7 @@ Arguments: docsUsageTemplatePath := "docs-usage.md.tmpl" f, err := os.Open(docsUsageTemplatePath) require.NoError(t, err) - updateAppUsageTemplate(f, app) + updateAppUsageTemplate(f, tt.config, app) // kingpin only adds a help command if there is at least // one subcommand. Make sure that all test cases