← back to Cli Printing Press
fix(cli): harden force-generate preservation (#750)
c397021fccf0c31d7e8ed39736bae3e613bb95d5 · 2026-05-08 15:11:24 -0700 · Trevin Chow
Files touched
M internal/cli/generate_test.goM internal/cli/root.goM internal/pipeline/contracts_test.go
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 →