[object Object]

← back to Cli Printing Press

fix(cli): harden force-generate preservation (#750)

c397021fccf0c31d7e8ed39736bae3e613bb95d5 · 2026-05-08 15:11:24 -0700 · Trevin Chow

Files touched

Diff

commit c397021fccf0c31d7e8ed39736bae3e613bb95d5
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Fri May 8 15:11:24 2026 -0700

    fix(cli): harden force-generate preservation (#750)
---
 internal/cli/generate_test.go       | 115 ++++++++++++++++++++++++++++++++++++
 internal/cli/root.go                |  21 ++++++-
 internal/pipeline/contracts_test.go |   2 +-
 3 files changed, 135 insertions(+), 3 deletions(-)

diff --git a/internal/cli/generate_test.go b/internal/cli/generate_test.go
index c4623929..bdf91339 100644
--- a/internal/cli/generate_test.go
+++ b/internal/cli/generate_test.go
@@ -1,6 +1,7 @@
 package cli
 
 import (
+	"bytes"
 	"encoding/json"
 	"go/parser"
 	"go/token"
@@ -87,6 +88,120 @@ func staleGeneratedCommand() {}
 	runGoCommandForCLITest(t, outputDir, "build", "./cmd/regenapp-pp-cli")
 }
 
+func TestGenerateCmdHelpDescribesForceAsGeneratedOverwrite(t *testing.T) {
+	t.Parallel()
+
+	cmd := newGenerateCmd()
+	var out bytes.Buffer
+	cmd.SetOut(&out)
+	cmd.SetErr(&out)
+	cmd.SetArgs([]string{"--help"})
+
+	require.NoError(t, cmd.Execute())
+	assert.Contains(t, out.String(), "Recreate the base output directory while preserving hand-authored internal/cli/*.go files")
+}
+
+func TestGenerateCmdForceRefusesSymlinkedInternalCliPreservation(t *testing.T) {
+	t.Parallel()
+
+	dir := t.TempDir()
+	specPath := filepath.Join(dir, "spec.yaml")
+	outputDir := filepath.Join(dir, "symlinkapp")
+	require.NoError(t, os.WriteFile(specPath, []byte(`name: symlinkapp
+description: Symlink app API
+version: 0.1.0
+base_url: https://api.example.com
+auth:
+  type: none
+config:
+  format: toml
+  path: ~/.config/symlinkapp-pp-cli/config.toml
+resources:
+  items:
+    description: Manage items
+    endpoints:
+      list:
+        method: GET
+        path: /items
+        description: List items
+`), 0o644))
+
+	runGenerate := func() error {
+		cmd := newGenerateCmd()
+		cmd.SetArgs([]string{
+			"--spec", specPath,
+			"--output", outputDir,
+			"--validate=false",
+			"--force",
+		})
+		return cmd.Execute()
+	}
+
+	require.NoError(t, runGenerate())
+
+	externalTarget := filepath.Join(dir, "outside.go")
+	require.NoError(t, os.WriteFile(externalTarget, []byte("package cli\n"), 0o644))
+	linkPath := filepath.Join(outputDir, "internal", "cli", "novel_link.go")
+	if err := os.Symlink(externalTarget, linkPath); err != nil {
+		t.Skipf("symlink unavailable: %v", err)
+	}
+
+	err := runGenerate()
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "refusing to preserve symlinked internal/cli file")
+}
+
+func TestGenerateCmdForceRefusesSymlinkedInternalAncestor(t *testing.T) {
+	t.Parallel()
+
+	dir := t.TempDir()
+	specPath := filepath.Join(dir, "spec.yaml")
+	outputDir := filepath.Join(dir, "symlinkparentapp")
+	require.NoError(t, os.WriteFile(specPath, []byte(`name: symlinkparentapp
+description: Symlink parent app API
+version: 0.1.0
+base_url: https://api.example.com
+auth:
+  type: none
+config:
+  format: toml
+  path: ~/.config/symlinkparentapp-pp-cli/config.toml
+resources:
+  items:
+    description: Manage items
+    endpoints:
+      list:
+        method: GET
+        path: /items
+        description: List items
+`), 0o644))
+
+	runGenerate := func() error {
+		cmd := newGenerateCmd()
+		cmd.SetArgs([]string{
+			"--spec", specPath,
+			"--output", outputDir,
+			"--validate=false",
+			"--force",
+		})
+		return cmd.Execute()
+	}
+
+	require.NoError(t, runGenerate())
+
+	externalInternal := filepath.Join(dir, "external-internal")
+	require.NoError(t, os.MkdirAll(filepath.Join(externalInternal, "cli"), 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(externalInternal, "cli", "novel.go"), []byte("package cli\n"), 0o644))
+	require.NoError(t, os.RemoveAll(filepath.Join(outputDir, "internal")))
+	if err := os.Symlink(externalInternal, filepath.Join(outputDir, "internal")); err != nil {
+		t.Skipf("symlink unavailable: %v", err)
+	}
+
+	err := runGenerate()
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "refusing to preserve hand-authored files through symlinked internal")
+}
+
 func TestGenerateCmdConsumesTrafficAnalysis(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/cli/root.go b/internal/cli/root.go
index f65ed773..d7f205a0 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -104,7 +104,7 @@ func newGenerateCmd() *cobra.Command {
 		Example: `  # Generate from a local OpenAPI spec
   printing-press generate --spec ./openapi.yaml
 
-  # Generate from a URL with force overwrite
+  # Generate from a URL and recreate output while preserving hand-authored CLI files
   printing-press generate --spec https://api.example.com/openapi.json --force
 
   # Generate from API documentation
@@ -380,7 +380,7 @@ func newGenerateCmd() *cobra.Command {
 	cmd.Flags().StringVar(&outputDir, "output", "", "Output directory (default: ~/printing-press/library/<name>)")
 	cmd.Flags().BoolVar(&validate, "validate", true, "Run quality gates on the generated project")
 	cmd.Flags().BoolVar(&refresh, "refresh", false, "Refresh cached remote spec before generating")
-	cmd.Flags().BoolVar(&force, "force", false, "Overwrite the base output directory (e.g. ~/printing-press/library/notion) instead of auto-incrementing")
+	cmd.Flags().BoolVar(&force, "force", false, "Recreate the base output directory while preserving hand-authored internal/cli/*.go files")
 	cmd.Flags().BoolVar(&lenient, "lenient", false, "Skip validation errors from broken $refs in OpenAPI specs")
 	cmd.Flags().StringVar(&docsURL, "docs", "", "API documentation URL to generate spec from")
 	cmd.Flags().BoolVar(&polish, "polish", false, "Run LLM polish pass on generated CLI (requires claude or codex CLI)")
@@ -814,6 +814,20 @@ type preservedFile struct {
 }
 
 func preserveHandAuthoredInternalCLIFiles(absOut string) ([]preservedFile, error) {
+	for _, rel := range []string{"internal", filepath.Join("internal", "cli")} {
+		path := filepath.Join(absOut, rel)
+		info, err := os.Lstat(path)
+		if err != nil {
+			if os.IsNotExist(err) {
+				continue
+			}
+			return nil, fmt.Errorf("statting %s for hand-authored files: %w", rel, err)
+		}
+		if info.Mode()&os.ModeSymlink != 0 {
+			return nil, fmt.Errorf("refusing to preserve hand-authored files through symlinked %s: %s", rel, path)
+		}
+	}
+
 	cliDir := filepath.Join(absOut, "internal", "cli")
 	entries, err := os.ReadDir(cliDir)
 	if err != nil {
@@ -828,6 +842,9 @@ func preserveHandAuthoredInternalCLIFiles(absOut string) ([]preservedFile, error
 		if entry.IsDir() || filepath.Ext(entry.Name()) != ".go" {
 			continue
 		}
+		if entry.Type()&os.ModeSymlink != 0 {
+			return nil, fmt.Errorf("refusing to preserve symlinked internal/cli file: %s", filepath.Join(cliDir, entry.Name()))
+		}
 		path := filepath.Join(cliDir, entry.Name())
 		if generatedmarker.HasInFile(path) {
 			continue
diff --git a/internal/pipeline/contracts_test.go b/internal/pipeline/contracts_test.go
index 57250ba1..6adad0b4 100644
--- a/internal/pipeline/contracts_test.go
+++ b/internal/pipeline/contracts_test.go
@@ -169,7 +169,7 @@ func TestGenerateHelpMentionsPublishedLibraryDefault(t *testing.T) {
 	root := readContractFile(t, filepath.Join("..", "..", "internal", "cli", "root.go"))
 
 	assert.Contains(t, root, "Output directory (default: ~/printing-press/library/<name>)")
-	assert.Contains(t, root, "Overwrite the base output directory (e.g. ~/printing-press/library/notion)")
+	assert.Contains(t, root, "Recreate the base output directory while preserving hand-authored internal/cli/*.go files")
 	assert.NotContains(t, root, "~/printing-press/workspaces/<scope>/library")
 }
 

← ee8f6ad9 feat(cli): credit original printer in per-CLI README (closes  ·  back to Cli Printing Press  ·  fix(cli): route live cache through typed upserts (#751) c332b534 →