Repository: caddyserver/caddy (language: go)

Issue:
Add `-v` flag support to main CLI command
Most a note-to-self to implement this, but if someone wants to open a PR before I do, by all means.

In the past, we used Go's stdlib flags which meant it was not possible to support `caddy -v`. But we've since switched to Cobra, so it's now possible to do so.

For context, some users were complaining about this on social media:
- https://frfsh.plus.st/notes/9hxugdnvdqnjgnm7
- https://mstdn.social/@mattwilcox/111201843985131969


Discussion / hints:
`-v` is `verbose`, yeah?
No, `caddy -v` would be the same as `caddy version`.

It's conventional to have `-v` emit the version unless it means verbose, but for the main `caddy` command it does nothing other than output help regardless of flags right now so there's no conflict.

Fix patch:
diff --git a/.gitignore b/.gitignore
index d6ee0e81210..6cde9db004a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,7 @@ Caddyfile.*
 cmd/caddy/caddy
 cmd/caddy/caddy.exe
 cmd/caddy/tmp/*.exe
+cmd/caddy/.env
 
 # mac specific
 .DS_Store
diff --git a/cmd/cobra.go b/cmd/cobra.go
index 4339cdbfbf0..c071f4a18c9 100644
--- a/cmd/cobra.go
+++ b/cmd/cobra.go
@@ -1,7 +1,11 @@
 package caddycmd
 
 import (
+	"fmt"
+
 	"github.com/spf13/cobra"
+
+	"github.com/caddyserver/caddy/v2"
 )
 
 var rootCmd = &cobra.Command{
@@ -95,15 +99,22 @@ https://caddyserver.com/docs/running
 	// kind of annoying to have all the help text printed out if
 	// caddy has an error provisioning its modules, for instance...
 	SilenceUsage: true,
+	Version:      onlyVersionText(),
 }
 
 const fullDocsFooter = `Full documentation is available at:
 https://caddyserver.com/docs/command-line`
 
 func init() {
+	rootCmd.SetVersionTemplate("{{.Version}}")
 	rootCmd.SetHelpTemplate(rootCmd.HelpTemplate() + "\n" + fullDocsFooter + "\n")
 }
 
+func onlyVersionText() string {
+	_, f := caddy.Version()
+	return f
+}
+
 func caddyCmdToCobra(caddyCmd Command) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   caddyCmd.Name,
@@ -123,7 +134,24 @@ func caddyCmdToCobra(caddyCmd Command) *cobra.Command {
 // in a cobra command's RunE field.
 func WrapCommandFuncForCobra(f CommandFunc) func(cmd *cobra.Command, _ []string) error {
 	return func(cmd *cobra.Command, _ []string) error {
-		_, err := f(Flags{cmd.Flags()})
+		status, err := f(Flags{cmd.Flags()})
+		if status > 1 {
+			cmd.SilenceErrors = true
+			return &exitError{ExitCode: status, Err: err}
+		}
 		return err
 	}
 }
+
+// exitError carries the exit code from CommandFunc to Main()
+type exitError struct {
+	ExitCode int
+	Err      error
+}
+
+func (e *exitError) Error() string {
+	if e.Err == nil {
+		return fmt.Sprintf("exiting with status %d", e.ExitCode)
+	}
+	return e.Err.Error()
+}
diff --git a/cmd/commandfuncs.go b/cmd/commandfuncs.go
index 8963988616e..b0c576a3d4f 100644
--- a/cmd/commandfuncs.go
+++ b/cmd/commandfuncs.go
@@ -42,14 +42,14 @@ import (
 )
 
 func cmdStart(fl Flags) (int, error) {
-	startCmdConfigFlag := fl.String("config")
-	startCmdConfigAdapterFlag := fl.String("adapter")
-	startCmdPidfileFlag := fl.String("pidfile")
-	startCmdWatchFlag := fl.Bool("watch")
+	configFlag := fl.String("config")
+	configAdapterFlag := fl.String("adapter")
+	pidfileFlag := fl.String("pidfile")
+	watchFlag := fl.Bool("watch")
 
 	var err error
-	var startCmdEnvfileFlag []string
-	startCmdEnvfileFlag, err = fl.GetStringSlice("envfile")
+	var envfileFlag []string
+	envfileFlag, err = fl.GetStringSlice("envfile")
 	if err != nil {
 		return caddy.ExitCodeFailedStartup,
 			fmt.Errorf("reading envfile flag: %v", err)
@@ -74,23 +74,23 @@ func cmdStart(fl Flags) (int, error) {
 	// sure by giving it some random bytes and having it echo
 	// them back to us)
 	cmd := exec.Command(os.Args[0], "run", "--pingback", ln.Addr().String())
-	if startCmdConfigFlag != "" {
-		cmd.Args = append(cmd.Args, "--config", startCmdConfigFlag)
+	if configFlag != "" {
+		cmd.Args = append(cmd.Args, "--config", configFlag)
 	}
 
-	for _, envFile := range startCmdEnvfileFlag {
-		cmd.Args = append(cmd.Args, "--envfile", envFile)
+	for _, envfile := range envfileFlag {
+		cmd.Args = append(cmd.Args, "--envfile", envfile)
 	}
-	if startCmdConfigAdapterFlag != "" {
-		cmd.Args = append(cmd.Args, "--adapter", startCmdConfigAdapterFlag)
+	if configAdapterFlag != "" {
+		cmd.Args = append(cmd.Args, "--adapter", configAdapterFlag)
 	}
-	if startCmdWatchFlag {
+	if watchFlag {
 		cmd.Args = append(cmd.Args, "--watch")
 	}
-	if startCmdPidfileFlag != "" {
-		cmd.Args = append(cmd.Args, "--pidfile", startCmdPidfileFlag)
+	if pidfileFlag != "" {
+		cmd.Args = append(cmd.Args, "--pidfile", pidfileFlag)
 	}
-	stdinpipe, err := cmd.StdinPipe()
+	stdinPipe, err := cmd.StdinPipe()
 	if err != nil {
 		return caddy.ExitCodeFailedStartup,
 			fmt.Errorf("creating stdin pipe: %v", err)
@@ -102,7 +102,8 @@ func cmdStart(fl Flags) (int, error) {
 	expect := make([]byte, 32)
 	_, err = rand.Read(expect)
 	if err != nil {
-		return caddy.ExitCodeFailedStartup, fmt.Errorf("generating random confirmation bytes: %v", err)
+		return caddy.ExitCodeFailedStartup,
+			fmt.Errorf("generating random confirmation bytes: %v", err)
 	}
 
 	// begin writing the confirmation bytes to the child's
@@ -110,14 +111,15 @@ func cmdStart(fl Flags) (int, error) {
 	// started yet, and writing synchronously would result
 	// in a deadlock
 	go func() {
-		_, _ = stdinpipe.Write(expect)
-		stdinpipe.Close()
+		_, _ = stdinPipe.Write(expect)
+		stdinPipe.Close()
 	}()
 
 	// start the process
 	err = cmd.Start()
 	if err != nil {
-		return caddy.ExitCodeFailedStartup, fmt.Errorf("starting caddy process: %v", err)
+		return caddy.ExitCodeFailedStartup,
+			fmt.Errorf("starting caddy process: %v", err)
 	}
 
 	// there are two ways we know we're done: either
@@ -165,47 +167,37 @@ func cmdStart(fl Flags) (int, error) {
 func cmdRun(fl Flags) (int, error) {
 	caddy.TrapSignals()
 
-	runCmdConfigFlag := fl.String("config")
-	runCmdConfigAdapterFlag := fl.String("adapter")
-	runCmdResumeFlag := fl.Bool("resume")
-	runCmdPrintEnvFlag := fl.Bool("environ")
-	runCmdWatchFlag := fl.Bool("watch")
-	runCmdPidfileFlag := fl.String("pidfile")
-	runCmdPingbackFlag := fl.String("pingback")
-
-	var err error
-	var runCmdLoadEnvfileFlag []string
-	runCmdLoadEnvfileFlag, err = fl.GetStringSlice("envfile")
-	if err != nil {
-		return caddy.ExitCodeFailedStartup,
-			fmt.Errorf("reading envfile flag: %v", err)
-	}
+	configFlag := fl.String("config")
+	configAdapterFlag := fl.String("adapter")
+	resumeFlag := fl.Bool("resume")
+	printEnvFlag := fl.Bool("environ")
+	watchFlag := fl.Bool("watch")
+	pidfileFlag := fl.String("pidfile")
+	pingbackFlag := fl.String("pingback")
 
 	// load all additional envs as soon as possible
-	for _, envFile := range runCmdLoadEnvfileFlag {
-		if err := loadEnvFromFile(envFile); err != nil {
-			return caddy.ExitCodeFailedStartup,
-				fmt.Errorf("loading additional environment variables: %v", err)
-		}
+	err := handleEnvFileFlag(fl)
+	if err != nil {
+		return caddy.ExitCodeFailedStartup, err
 	}
 
 	// if we are supposed to print the environment, do that first
-	if runCmdPrintEnvFlag {
+	if printEnvFlag {
 		printEnvironment()
 	}
 
 	// load the config, depending on flags
 	var config []byte
-	if runCmdResumeFlag {
+	if resumeFlag {
 		config, err = os.ReadFile(caddy.ConfigAutosavePath)
 		if os.IsNotExist(err) {
 			// not a bad error; just can't resume if autosave file doesn't exist
 			caddy.Log().Info("no autosave file exists", zap.String("autosave_file", caddy.ConfigAutosavePath))
-			runCmdResumeFlag = false
+			resumeFlag = false
 		} else if err != nil {
 			return caddy.ExitCodeFailedStartup, err
 		} else {
-			if runCmdConfigFlag == "" {
+			if configFlag == "" {
 				caddy.Log().Info("resuming from last configuration",
 					zap.String("autosave_file", caddy.ConfigAutosavePath))
 			} else {
@@ -218,19 +210,19 @@ func cmdRun(fl Flags) (int, error) {
 	}
 	// we don't use 'else' here since this value might have been changed in 'if' block; i.e. not mutually exclusive
 	var configFile string
-	if !runCmdResumeFlag {
-		config, configFile, err = LoadConfig(runCmdConfigFlag, runCmdConfigAdapterFlag)
+	if !resumeFlag {
+		config, configFile, err = LoadConfig(configFlag, configAdapterFlag)
 		if err != nil {
 			return caddy.ExitCodeFailedStartup, err
 		}
 	}
 
 	// create pidfile now, in case loading config takes a while (issue #5477)
-	if runCmdPidfileFlag != "" {
-		err := caddy.PIDFile(runCmdPidfileFlag)
+	if pidfileFlag != "" {
+		err := caddy.PIDFile(pidfileFlag)
 		if err != nil {
 			caddy.Log().Error("unable to write PID file",
-				zap.String("pidfile", runCmdPidfileFlag),
+				zap.String("pidfile", pidfileFlag),
 				zap.Error(err))
 		}
 	}
@@ -244,13 +236,13 @@ func cmdRun(fl Flags) (int, error) {
 
 	// if we are to report to another process the successful start
 	// of the server, do so now by echoing back contents of stdin
-	if runCmdPingbackFlag != "" {
+	if pingbackFlag != "" {
 		confirmationBytes, err := io.ReadAll(os.Stdin)
 		if err != nil {
 			return caddy.ExitCodeFailedStartup,
 				fmt.Errorf("reading confirmation bytes from stdin: %v", err)
 		}
-		conn, err := net.Dial("tcp", runCmdPingbackFlag)
+		conn, err := net.Dial("tcp", pingbackFlag)
 		if err != nil {
 			return caddy.ExitCodeFailedStartup,
 				fmt.Errorf("dialing confirmation address: %v", err)
@@ -259,14 +251,14 @@ func cmdRun(fl Flags) (int, error) {
 		_, err = conn.Write(confirmationBytes)
 		if err != nil {
 			return caddy.ExitCodeFailedStartup,
-				fmt.Errorf("writing confirmation bytes to %s: %v", runCmdPingbackFlag, err)
+				fmt.Errorf("writing confirmation bytes to %s: %v", pingbackFlag, err)
 		}
 	}
 
 	// if enabled, reload config file automatically on changes
 	// (this better only be used in dev!)
-	if runCmdWatchFlag {
-		go watchConfigFile(configFile, runCmdConfigAdapterFlag)
+	if watchFlag {
+		go watchConfigFile(configFile, configAdapterFlag)
 	}
 
 	// warn if the environment does not provide enough information about the disk
@@ -292,11 +284,11 @@ func cmdRun(fl Flags) (int, error) {
 }
 
 func cmdStop(fl Flags) (int, error) {
-	addrFlag := fl.String("address")
+	addressFlag := fl.String("address")
 	configFlag := fl.String("config")
 	configAdapterFlag := fl.String("adapter")
 
-	adminAddr, err := DetermineAdminAPIAddress(addrFlag, nil, configFlag, configAdapterFlag)
+	adminAddr, err := DetermineAdminAPIAddress(addressFlag, nil, configFlag, configAdapterFlag)
 	if err != nil {
 		return caddy.ExitCodeFailedStartup, fmt.Errorf("couldn't determine admin API address: %v", err)
 	}
@@ -314,7 +306,7 @@ func cmdStop(fl Flags) (int, error) {
 func cmdReload(fl Flags) (int, error) {
 	configFlag := fl.String("config")
 	configAdapterFlag := fl.String("adapter")
-	addrFlag := fl.String("address")
+	addressFlag := fl.String("address")
 	forceFlag := fl.Bool("force")
 
 	// get the config in caddy's native format
@@ -326,7 +318,7 @@ func cmdReload(fl Flags) (int, error) {
 		return caddy.ExitCodeFailedStartup, fmt.Errorf("no config file to load")
 	}
 
-	adminAddr, err := DetermineAdminAPIAddress(addrFlag, config, configFlag, configAdapterFlag)
+	adminAddr, err := DetermineAdminAPIAddress(addressFlag, config, configFlag, configAdapterFlag)
 	if err != nil {
 		return caddy.ExitCodeFailedStartup, fmt.Errorf("couldn't determine admin API address: %v", err)
 	}
@@ -428,48 +420,60 @@ func cmdListModules(fl Flags) (int, error) {
 	return caddy.ExitCodeSuccess, nil
 }
 
-func cmdEnviron(_ Flags) (int, error) {
+func cmdEnviron(fl Flags) (int, error) {
+	// load all additional envs as soon as possible
+	err := handleEnvFileFlag(fl)
+	if err != nil {
+		return caddy.ExitCodeFailedStartup, err
+	}
+
 	printEnvironment()
 	return caddy.ExitCodeSuccess, nil
 }
 
 func cmdAdaptConfig(fl Flags) (int, error) {
-	adaptCmdInputFlag := fl.String("config")
-	adaptCmdAdapterFlag := fl.String("adapter")
-	adaptCmdPrettyFlag := fl.Bool("pretty")
-	adaptCmdValidateFlag := fl.Bool("validate")
+	inputFlag := fl.String("config")
+	adapterFlag := fl.String("adapter")
+	prettyFlag := fl.Bool("pretty")
+	validateFlag := fl.Bool("validate")
 
 	var err error
-	adaptCmdInputFlag, err = configFileWithRespectToDefault(caddy.Log(), adaptCmdInputFlag)
+	inputFlag, err = configFileWithRespectToDefault(caddy.Log(), inputFlag)
 	if err != nil {
 		return caddy.ExitCodeFailedStartup, err
 	}
 
-	if adaptCmdAdapterFlag == "" {
+	// load all additional envs as soon as possible
+	err = handleEnvFileFlag(fl)
+	if err != nil {
+		return caddy.ExitCodeFailedStartup, err
+	}
+
+	if adapterFlag == "" {
 		return caddy.ExitCodeFailedStartup,
 			fmt.Errorf("adapter name is required (use --adapt flag or leave unspecified for default)")
 	}
 
-	cfgAdapter := caddyconfig.GetAdapter(adaptCmdAdapterFlag)
+	cfgAdapter := caddyconfig.GetAdapter(adapterFlag)
 	if cfgAdapter == nil {
 		return caddy.ExitCodeFailedStartup,
-			fmt.Errorf("unrecognized config adapter: %s", adaptCmdAdapterFlag)
+			fmt.Errorf("unrecognized config adapter: %s", adapterFlag)
 	}
 
-	input, err := os.ReadFile(adaptCmdInputFlag)
+	input, err := os.ReadFile(inputFlag)
 	if err != nil {
 		return caddy.ExitCodeFailedStartup,
 			fmt.Errorf("reading input file: %v", err)
 	}
 
-	opts := map[string]any{"filename": adaptCmdInputFlag}
+	opts := map[string]any{"filename": inputFlag}
 
 	adaptedConfig, warnings, err := cfgAdapter.Adapt(input, opts)
 	if err != nil {
 		return caddy.ExitCodeFailedStartup, err
 	}
 
-	if adaptCmdPrettyFlag {
+	if prettyFlag {
 		var prettyBuf bytes.Buffer
 		err = json.Indent(&prettyBuf, adaptedConfig, "", "\t")
 		if err != nil {
@@ -487,13 +491,13 @@ func cmdAdaptConfig(fl Flags) (int, error) {
 		if warn.Directive != "" {
 			msg = fmt.Sprintf("%s: %s", warn.Directive, warn.Message)
 		}
-		caddy.Log().Named(adaptCmdAdapterFlag).Warn(msg,
+		caddy.Log().Named(adapterFlag).Warn(msg,
 			zap.String("file", warn.File),
 			zap.Int("line", warn.Line))
 	}
 
 	// validate output if requested
-	if adaptCmdValidateFlag {
+	if validateFlag {
 		var cfg *caddy.Config
 		err = caddy.StrictUnmarshalJSON(adaptedConfig, &cfg)
 		if err != nil {
@@ -509,36 +513,26 @@ func cmdAdaptConfig(fl Flags) (int, error) {
 }
 
 func cmdValidateConfig(fl Flags) (int, error) {
-	validateCmdConfigFlag := fl.String("config")
-	validateCmdAdapterFlag := fl.String("adapter")
-
-	var err error
-	var runCmdLoadEnvfileFlag []string
-	runCmdLoadEnvfileFlag, err = fl.GetStringSlice("envfile")
-	if err != nil {
-		return caddy.ExitCodeFailedStartup,
-			fmt.Errorf("reading envfile flag: %v", err)
-	}
+	configFlag := fl.String("config")
+	adapterFlag := fl.String("adapter")
 
 	// load all additional envs as soon as possible
-	for _, envFile := range runCmdLoadEnvfileFlag {
-		if err := loadEnvFromFile(envFile); err != nil {
-			return caddy.ExitCodeFailedStartup,
-				fmt.Errorf("loading additional environment variables: %v", err)
-		}
+	err := handleEnvFileFlag(fl)
+	if err != nil {
+		return caddy.ExitCodeFailedStartup, err
 	}
 
 	// use default config and ensure a config file is specified
-	validateCmdConfigFlag, err = configFileWithRespectToDefault(caddy.Log(), validateCmdConfigFlag)
+	configFlag, err = configFileWithRespectToDefault(caddy.Log(), configFlag)
 	if err != nil {
 		return caddy.ExitCodeFailedStartup, err
 	}
-	if validateCmdConfigFlag == "" {
+	if configFlag == "" {
 		return caddy.ExitCodeFailedStartup,
 			fmt.Errorf("input file required when there is no Caddyfile in current directory (use --config flag)")
 	}
 
-	input, _, err := LoadConfig(validateCmdConfigFlag, validateCmdAdapterFlag)
+	input, _, err := LoadConfig(configFlag, adapterFlag)
 	if err != nil {
 		return caddy.ExitCodeFailedStartup, err
 	}
@@ -561,13 +555,13 @@ func cmdValidateConfig(fl Flags) (int, error) {
 }
 
 func cmdFmt(fl Flags) (int, error) {
-	formatCmdConfigFile := fl.Arg(0)
-	if formatCmdConfigFile == "" {
-		formatCmdConfigFile = "Caddyfile"
+	configFile := fl.Arg(0)
+	if configFile == "" {
+		configFile = "Caddyfile"
 	}
 
 	// as a special case, read from stdin if the file name is "-"
-	if formatCmdConfigFile == "-" {
+	if configFile == "-" {
 		input, err := io.ReadAll(os.Stdin)
 		if err != nil {
 			return caddy.ExitCodeFailedStartup,
@@ -577,7 +571,7 @@ func cmdFmt(fl Flags) (int, error) {
 		return caddy.ExitCodeSuccess, nil
 	}
 
-	input, err := os.ReadFile(formatCmdConfigFile)
+	input, err := os.ReadFile(configFile)
 	if err != nil {
 		return caddy.ExitCodeFailedStartup,
 			fmt.Errorf("reading input file: %v", err)
@@ -586,7 +580,7 @@ func cmdFmt(fl Flags) (int, error) {
 	output := caddyfile.Format(input)
 
 	if fl.Bool("overwrite") {
-		if err := os.WriteFile(formatCmdConfigFile, output, 0o600); err != nil {
+		if err := os.WriteFile(configFile, output, 0o600); err != nil {
 			return caddy.ExitCodeFailedStartup, fmt.Errorf("overwriting formatted file: %v", err)
 		}
 		return caddy.ExitCodeSuccess, nil
@@ -610,7 +604,7 @@ func cmdFmt(fl Flags) (int, error) {
 		fmt.Print(string(output))
 	}
 
-	if warning, diff := caddyfile.FormattingDifference(formatCmdConfigFile, input); diff {
+	if warning, diff := caddyfile.FormattingDifference(configFile, input); diff {
 		return caddy.ExitCodeFailedStartup, fmt.Errorf(`%s:%d: Caddyfile input is not formatted; Tip: use '--overwrite' to update your Caddyfile in-place instead of previewing it. Consult '--help' for more options`,
 			warning.File,
 			warning.Line,
@@ -620,6 +614,25 @@ func cmdFmt(fl Flags) (int, error) {
 	return caddy.ExitCodeSuccess, nil
 }
 
+// handleEnvFileFlag loads the environment variables from the given --envfile
+// flag if specified. This should be called as early in the command function.
+func handleEnvFileFlag(fl Flags) error {
+	var err error
+	var envfileFlag []string
+	envfileFlag, err = fl.GetStringSlice("envfile")
+	if err != nil {
+		return fmt.Errorf("reading envfile flag: %v", err)
+	}
+
+	for _, envfile := range envfileFlag {
+		if err := loadEnvFromFile(envfile); err != nil {
+			return fmt.Errorf("loading additional environment variables: %v", err)
+		}
+	}
+
+	return nil
+}
+
 // AdminAPIRequest makes an API request according to the CLI flags given,
 // with the given HTTP method and request URI. If body is non-nil, it will
 // be assumed to be Content-Type application/json. The caller should close
diff --git a/cmd/commands.go b/cmd/commands.go
index c64ab7134a8..e5e1265e441 100644
--- a/cmd/commands.go
+++ b/cmd/commands.go
@@ -94,8 +94,8 @@ func init() {
 Starts the Caddy process, optionally bootstrapped with an initial config file.
 This command unblocks after the server starts running or fails to run.
 
-If --envfile is specified, an environment file with environment variables in
-the KEY=VALUE format will be loaded into the Caddy process.
+If --envfile is specified, an environment file with environment variables
+in the KEY=VALUE format will be loaded into the Caddy process.
 
 On Windows, the spawned child process will remain attached to the terminal, so
 closing the window will forcefully stop Caddy; to avoid forgetting this, try
@@ -133,8 +133,8 @@ As a special case, if the current working directory has a file called
 that file will be loaded and used to configure Caddy, even without any command
 line flags.
 
-If --envfile is specified, an environment file with environment variables in
-the KEY=VALUE format will be loaded into the Caddy process.
+If --envfile is specified, an environment file with environment variables
+in the KEY=VALUE format will be loaded into the Caddy process.
 
 If --environ is specified, the environment as seen by the Caddy process will
 be printed before starting. This is the same as the environ command but does
@@ -240,6 +240,7 @@ documentation: https://go.dev/doc/modules/version-numbers
 
 	RegisterCommand(Command{
 		Name:  "environ",
+		Usage: "[--envfile <path>]",
 		Short: "Prints the environment",
 		Long: `
 Prints the environment as seen by this Caddy process.
@@ -249,6 +250,9 @@ configuration uses environment variables (e.g. "{env.VARIABLE}") then
 this command can be useful for verifying that the variables will have
 the values you expect in your config.
 
+If --envfile is specified, an environment file with environment variables
+in the KEY=VALUE format will be loaded into the Caddy process.
+
 Note that environments may be different depending on how you run Caddy.
 Environments for Caddy instances started by service managers such as
 systemd are often different than the environment inherited from your
@@ -259,12 +263,15 @@ by adding the "--environ" flag.
 
 Environments may contain sensitive data.
 `,
-		Func: cmdEnviron,
+		CobraFunc: func(cmd *cobra.Command) {
+			cmd.Flags().StringSliceP("envfile", "", []string{}, "Environment file(s) to load")
+			cmd.RunE = WrapCommandFuncForCobra(cmdEnviron)
+		},
 	})
 
 	RegisterCommand(Command{
 		Name:  "adapt",
-		Usage: "--config <path> [--adapter <name>] [--pretty] [--validate]",
+		Usage: "--config <path> [--adapter <name>] [--pretty] [--validate] [--envfile <path>]",
 		Short: "Adapts a configuration to Caddy's native JSON",
 		Long: `
 Adapts a configuration to Caddy's native JSON format and writes the
@@ -276,12 +283,16 @@ for human readability.
 If --validate is used, the adapted config will be checked for validity.
 If the config is invalid, an error will be printed to stderr and a non-
 zero exit status will be returned.
+
+If --envfile is specified, an environment file with environment variables
+in the KEY=VALUE format will be loaded into the Caddy process.
 `,
 		CobraFunc: func(cmd *cobra.Command) {
 			cmd.Flags().StringP("config", "c", "", "Configuration file to adapt (required)")
 			cmd.Flags().StringP("adapter", "a", "caddyfile", "Name of config adapter")
 			cmd.Flags().BoolP("pretty", "p", false, "Format the output for human readability")
 			cmd.Flags().BoolP("validate", "", false, "Validate the output")
+			cmd.Flags().StringSliceP("envfile", "", []string{}, "Environment file(s) to load")
 			cmd.RunE = WrapCommandFuncForCobra(cmdAdaptConfig)
 		},
 	})
@@ -295,8 +306,8 @@ Loads and provisions the provided config, but does not start running it.
 This reveals any errors with the configuration through the loading and
 provisioning stages.
 
-If --envfile is specified, an environment file with environment variables in
-the KEY=VALUE format will be loaded into the Caddy process.
+If --envfile is specified, an environment file with environment variables
+in the KEY=VALUE format will be loaded into the Caddy process.
 `,
 		CobraFunc: func(cmd *cobra.Command) {
 			cmd.Flags().StringP("config", "c", "", "Input configuration file")
diff --git a/cmd/main.go b/cmd/main.go
index b4e3fdc87bf..fa15c087438 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -17,6 +17,7 @@ package caddycmd
 import (
 	"bufio"
 	"bytes"
+	"errors"
 	"flag"
 	"fmt"
 	"io"
@@ -63,6 +64,10 @@ func Main() {
 	}
 
 	if err := rootCmd.Execute(); err != nil {
+		var exitError *exitError
+		if errors.As(err, &exitError) {
+			os.Exit(exitError.ExitCode)
+		}
 		os.Exit(1)
 	}
 }


Test patch: