[object Object]

← back to Cli Printing Press

fix(cli): stop generated main.go from printing every error twice (#1411)

46ad86b2b287dc18cbab40b5dfe0997a52e7a3be · 2026-05-14 15:05:13 -0700 · Trevin Chow

* fix(cli): stop generated main.go from printing every error twice

Every printed CLI's cmd/<cli>/main.go printed each error twice — once
from Cobra (default Error: prefix) and once from main.go's explicit
fmt.Fprintln, because root.go.tmpl set SilenceUsage:true but not
SilenceErrors. Drop the redundant Fprintln (and now-unused fmt import)
in main.go.tmpl so Cobra owns the single error print.

The unknown-flag hint path in root.go.tmpl previously rode on that
second print to surface "hint: did you mean --foo?" — Cobra prints err
before the wrap attaches the hint, so without main.go re-printing, the
suggestion would disappear. Emit the hint to stderr directly in
root.go.tmpl while keeping the existing wrap (which the usageErr
classification and isCobraUsageError tests depend on).

Closes #1407

* fix(cli): drop API names from regression-test comment

Greptile flagged the schoology/epic-myhealth references in the test
header as customer/project name leakage. AGENTS.md already forbids
referencing the current task or specific past incidents in code
comments, so rewrite the why-this-test prose without naming any
specific CLI.

Files touched

Diff

commit 46ad86b2b287dc18cbab40b5dfe0997a52e7a3be
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Thu May 14 15:05:13 2026 -0700

    fix(cli): stop generated main.go from printing every error twice (#1411)
    
    * fix(cli): stop generated main.go from printing every error twice
    
    Every printed CLI's cmd/<cli>/main.go printed each error twice — once
    from Cobra (default Error: prefix) and once from main.go's explicit
    fmt.Fprintln, because root.go.tmpl set SilenceUsage:true but not
    SilenceErrors. Drop the redundant Fprintln (and now-unused fmt import)
    in main.go.tmpl so Cobra owns the single error print.
    
    The unknown-flag hint path in root.go.tmpl previously rode on that
    second print to surface "hint: did you mean --foo?" — Cobra prints err
    before the wrap attaches the hint, so without main.go re-printing, the
    suggestion would disappear. Emit the hint to stderr directly in
    root.go.tmpl while keeping the existing wrap (which the usageErr
    classification and isCobraUsageError tests depend on).
    
    Closes #1407
    
    * fix(cli): drop API names from regression-test comment
    
    Greptile flagged the schoology/epic-myhealth references in the test
    header as customer/project name leakage. AGENTS.md already forbids
    referencing the current task or specific past incidents in code
    comments, so rewrite the why-this-test prose without naming any
    specific CLI.
---
 internal/generator/main_no_double_print_test.go    | 68 ++++++++++++++++++++++
 internal/generator/templates/main.go.tmpl          |  2 -
 internal/generator/templates/root.go.tmpl          |  7 +++
 .../cafe-bistro/cmd/cafe-bistro-pp-cli/main.go     |  2 -
 .../cmd/printing-press-golden-pp-cli/main.go       |  2 -
 .../printing-press-golden/internal/cli/root.go     |  7 +++
 6 files changed, 82 insertions(+), 6 deletions(-)

diff --git a/internal/generator/main_no_double_print_test.go b/internal/generator/main_no_double_print_test.go
new file mode 100644
index 00000000..69d6baac
--- /dev/null
+++ b/internal/generator/main_no_double_print_test.go
@@ -0,0 +1,68 @@
+package generator
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+// TestMainNoDoublePrintErrors asserts the generated cmd/<cli>/main.go does
+// not print err.Error() to stderr. root.go.tmpl leaves Cobra's default
+// error printing on (SilenceUsage:true but no SilenceErrors), so a
+// second main.go-side print would emit every failing command's message
+// twice — once with Cobra's "Error:" prefix and once as the raw text.
+// Pin the structural shape here: no `fmt` import in main.go, no
+// `fmt.Fprintln(os.Stderr, err.Error())` call. Behavior is covered
+// by the emitted root.go's own tests (root_test.go.tmpl).
+func TestMainNoDoublePrintErrors(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("nodbl-canary")
+	outputDir := filepath.Join(t.TempDir(), "nodbl-canary-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	mainPath := filepath.Join(outputDir, "cmd", "nodbl-canary-pp-cli", "main.go")
+	mainSrc, err := os.ReadFile(mainPath)
+	require.NoError(t, err, "main.go must be emitted")
+	src := string(mainSrc)
+
+	require.NotContains(t, src, `"fmt"`,
+		`main.go must not import "fmt" — the only prior use was the double-print line`)
+	require.NotContains(t, src, "fmt.Fprintln(os.Stderr, err.Error())",
+		"main.go must not print err.Error(); Cobra prints it once via SilenceUsage=true/SilenceErrors=false")
+	require.NotContains(t, src, "fmt.Fprint(os.Stderr, err.Error())",
+		"main.go must not print err.Error() via Fprint either")
+
+	// Exit-code propagation must still flow through cli.ExitCode so
+	// usageErr → 2, configErr → 10, authErr → 5 stay intact.
+	require.Contains(t, src, "os.Exit(cli.ExitCode(err))",
+		"main.go must still propagate the typed exit code")
+}
+
+// TestRootEmitsUnknownFlagHintToStderr asserts root.go.tmpl emits an
+// explicit stderr write of the suggestion alongside the err wrap. Before
+// this fix, the hint was only ever visible because main.go re-printed
+// err.Error(); now that main.go is silent, Cobra prints `Error: unknown
+// flag: --foob` (without the hint, since Cobra prints before the wrap
+// happens), and root.go owns surfacing the hint.
+func TestRootEmitsUnknownFlagHintToStderr(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("hint-canary")
+	outputDir := filepath.Join(t.TempDir(), "hint-canary-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	rootSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "root.go"))
+	require.NoError(t, err, "root.go must be emitted")
+	src := string(rootSrc)
+
+	require.Contains(t, src, `fmt.Fprintf(os.Stderr, "hint: did you mean --%s?\n", suggestion)`,
+		"root.go must print the unknown-flag hint to stderr directly; main.go no longer does")
+	// And the wrap still has to happen so the err.Error() chain carries
+	// the hint for downstream consumers and isCobraUsageError keeps its
+	// classification (covered by TestUsageErrorExitCode_EmittedInRoot).
+	require.Contains(t, src, `err = fmt.Errorf("%w\nhint: did you mean --%s?", err, suggestion)`,
+		"root.go must still wrap err with the hint for downstream consumers")
+}
diff --git a/internal/generator/templates/main.go.tmpl b/internal/generator/templates/main.go.tmpl
index 8cef8696..1a60706b 100644
--- a/internal/generator/templates/main.go.tmpl
+++ b/internal/generator/templates/main.go.tmpl
@@ -4,7 +4,6 @@
 package main
 
 import (
-	"fmt"
 	"os"
 
 	"{{modulePath}}/internal/cli"
@@ -12,7 +11,6 @@ import (
 
 func main() {
 	if err := cli.Execute(); err != nil {
-		fmt.Fprintln(os.Stderr, err.Error())
 		os.Exit(cli.ExitCode(err))
 	}
 }
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index c41075ce..66cc0807 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -71,6 +71,13 @@ func Execute() error {
 		if idx := strings.Index(msg, "unknown flag: "); idx >= 0 {
 			flagStr := strings.TrimSpace(msg[idx+len("unknown flag: "):])
 			if suggestion := suggestFlag(flagStr, rootCmd); suggestion != "" {
+				// Cobra already printed `Error: unknown flag: --foob` before
+				// returning; the wrap below attaches the hint to err.Error()
+				// for downstream consumers and exit-code classification, but
+				// would never reach stderr now that main.go no longer prints
+				// err. Emit the hint explicitly so the suggestion still
+				// shows up under Cobra's error line.
+				fmt.Fprintf(os.Stderr, "hint: did you mean --%s?\n", suggestion)
 				err = fmt.Errorf("%w\nhint: did you mean --%s?", err, suggestion)
 			}
 		}
diff --git a/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/cmd/cafe-bistro-pp-cli/main.go b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/cmd/cafe-bistro-pp-cli/main.go
index d7b5d06c..2bd24ffb 100644
--- a/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/cmd/cafe-bistro-pp-cli/main.go
+++ b/testdata/golden/expected/generate-golden-api-unicode/cafe-bistro/cmd/cafe-bistro-pp-cli/main.go
@@ -4,7 +4,6 @@
 package main
 
 import (
-	"fmt"
 	"os"
 
 	"cafe-bistro-pp-cli/internal/cli"
@@ -12,7 +11,6 @@ import (
 
 func main() {
 	if err := cli.Execute(); err != nil {
-		fmt.Fprintln(os.Stderr, err.Error())
 		os.Exit(cli.ExitCode(err))
 	}
 }
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/cmd/printing-press-golden-pp-cli/main.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/cmd/printing-press-golden-pp-cli/main.go
index 6a5aa9d7..ad4c70bb 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/cmd/printing-press-golden-pp-cli/main.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/cmd/printing-press-golden-pp-cli/main.go
@@ -4,7 +4,6 @@
 package main
 
 import (
-	"fmt"
 	"os"
 
 	"printing-press-golden-pp-cli/internal/cli"
@@ -12,7 +11,6 @@ import (
 
 func main() {
 	if err := cli.Execute(); err != nil {
-		fmt.Fprintln(os.Stderr, err.Error())
 		os.Exit(cli.ExitCode(err))
 	}
 }
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/root.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/root.go
index efa2ef80..5c19c268 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/root.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/root.go
@@ -65,6 +65,13 @@ func Execute() error {
 		if idx := strings.Index(msg, "unknown flag: "); idx >= 0 {
 			flagStr := strings.TrimSpace(msg[idx+len("unknown flag: "):])
 			if suggestion := suggestFlag(flagStr, rootCmd); suggestion != "" {
+				// Cobra already printed `Error: unknown flag: --foob` before
+				// returning; the wrap below attaches the hint to err.Error()
+				// for downstream consumers and exit-code classification, but
+				// would never reach stderr now that main.go no longer prints
+				// err. Emit the hint explicitly so the suggestion still
+				// shows up under Cobra's error line.
+				fmt.Fprintf(os.Stderr, "hint: did you mean --%s?\n", suggestion)
 				err = fmt.Errorf("%w\nhint: did you mean --%s?", err, suggestion)
 			}
 		}

← d0226602 fix(cli): derive doctor probe path from auth.verify_path or  ·  back to Cli Printing Press  ·  docs(cli): clarify issue ownership workflow (#1414) 770b65a6 →