[object Object]

← back to Cli Printing Press

fix(cli): preserve internal sibling packages on force regen (#897)

dceb6e58f1bce60ea2228c7169eb590228b6c591 · 2026-05-10 10:50:22 -0700 · Trevin Chow

* fix(cli): preserve internal sibling packages on force regen

* fix(cli): address force regen review feedback

Files touched

Diff

commit dceb6e58f1bce60ea2228c7169eb590228b6c591
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sun May 10 10:50:22 2026 -0700

    fix(cli): preserve internal sibling packages on force regen (#897)
    
    * fix(cli): preserve internal sibling packages on force regen
    
    * fix(cli): address force regen review feedback
---
 internal/cli/generate_test.go |  82 ++++++++++++++++++++-
 internal/cli/root.go          | 167 +++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 246 insertions(+), 3 deletions(-)

diff --git a/internal/cli/generate_test.go b/internal/cli/generate_test.go
index 95e4c092..5b10c1d8 100644
--- a/internal/cli/generate_test.go
+++ b/internal/cli/generate_test.go
@@ -8,6 +8,7 @@ import (
 	"os"
 	"os/exec"
 	"path/filepath"
+	"syscall"
 	"testing"
 
 	"github.com/mvanhorn/cli-printing-press/v4/internal/catalog"
@@ -62,6 +63,14 @@ func novelPreservedSentinel() string { return "preserved" }
 `)
 	require.NoError(t, os.WriteFile(novelPath, novelSource, 0o644))
 
+	siblingPath := filepath.Join(outputDir, "internal", "source", "custom", "client.go")
+	siblingSource := []byte(`package custom
+
+func KeepSiblingPackage() string { return "preserved" }
+`)
+	require.NoError(t, os.MkdirAll(filepath.Dir(siblingPath), 0o755))
+	require.NoError(t, os.WriteFile(siblingPath, siblingSource, 0o644))
+
 	rootPath := filepath.Join(outputDir, "internal", "cli", "root.go")
 	require.NoError(t, os.WriteFile(rootPath, []byte("package cli\n\nfunc brokenGeneratedEdit() {\n"), 0o644))
 	staleGeneratedPath := filepath.Join(outputDir, "internal", "cli", "old_generated.go")
@@ -78,6 +87,10 @@ func staleGeneratedCommand() {}
 	require.NoError(t, err)
 	assert.Equal(t, string(novelSource), string(gotNovel))
 
+	gotSibling, err := os.ReadFile(siblingPath)
+	require.NoError(t, err)
+	assert.Equal(t, string(siblingSource), string(gotSibling))
+
 	rootGo, err := os.ReadFile(rootPath)
 	require.NoError(t, err)
 	assert.Contains(t, string(rootGo), "Generated by CLI Printing Press")
@@ -98,7 +111,7 @@ func TestGenerateCmdHelpDescribesForceAsGeneratedOverwrite(t *testing.T) {
 	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")
+	assert.Contains(t, out.String(), "Recreate the base output directory while preserving hand-authored internal/cli/*.go files and internal sibling packages")
 }
 
 func TestGenerateCmdForceRefusesSymlinkedInternalCliPreservation(t *testing.T) {
@@ -151,6 +164,56 @@ resources:
 	assert.Contains(t, err.Error(), "refusing to preserve symlinked internal/cli file")
 }
 
+func TestGenerateCmdForceRefusesSymlinkedInternalSiblingPackage(t *testing.T) {
+	t.Parallel()
+
+	dir := t.TempDir()
+	specPath := filepath.Join(dir, "spec.yaml")
+	outputDir := filepath.Join(dir, "symlinksiblingapp")
+	require.NoError(t, os.WriteFile(specPath, []byte(`name: symlinksiblingapp
+description: Symlink sibling app API
+version: 0.1.0
+base_url: https://api.example.com
+auth:
+  type: none
+config:
+  format: toml
+  path: ~/.config/symlinksiblingapp-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())
+
+	externalSibling := filepath.Join(dir, "external-source")
+	require.NoError(t, os.MkdirAll(externalSibling, 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(externalSibling, "client.go"), []byte("package source\n"), 0o644))
+	if err := os.Symlink(externalSibling, filepath.Join(outputDir, "internal", "source")); err != nil {
+		t.Skipf("symlink unavailable: %v", err)
+	}
+
+	err := runGenerate()
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "refusing to preserve symlinked internal sibling package")
+}
+
 func TestGenerateCmdForceRefusesSymlinkedInternalAncestor(t *testing.T) {
 	t.Parallel()
 
@@ -202,6 +265,23 @@ resources:
 	assert.Contains(t, err.Error(), "refusing to preserve hand-authored files through symlinked internal")
 }
 
+func TestMovePreservedDirFallsBackWhenRenameCrossesDevices(t *testing.T) {
+	t.Parallel()
+
+	dir := t.TempDir()
+	src := filepath.Join(dir, "staged")
+	dst := filepath.Join(dir, "restored")
+	require.NoError(t, os.MkdirAll(filepath.Join(src, "nested"), 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(src, "nested", "client.go"), []byte("package nested\n"), 0o644))
+
+	err := movePreservedDir(src, dst, func(_, _ string) error {
+		return &os.LinkError{Op: "rename", Old: src, New: dst, Err: syscall.EXDEV}
+	})
+	require.NoError(t, err)
+	assert.NoDirExists(t, src)
+	assert.FileExists(t, filepath.Join(dst, "nested", "client.go"))
+}
+
 func TestGenerateCmdConsumesTrafficAnalysis(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 755c09b7..a35a9015 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -4,6 +4,7 @@ import (
 	"crypto/sha256"
 	"encoding/hex"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"io"
 	"net/http"
@@ -12,6 +13,7 @@ import (
 	"regexp"
 	"runtime"
 	"strings"
+	"syscall"
 	"time"
 
 	catalogfs "github.com/mvanhorn/cli-printing-press/v4/catalog"
@@ -380,7 +382,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, "Recreate the base output directory while preserving hand-authored internal/cli/*.go files")
+	cmd.Flags().BoolVar(&force, "force", false, "Recreate the base output directory while preserving hand-authored internal/cli/*.go files and internal sibling packages")
 	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)")
@@ -761,7 +763,7 @@ func httpTransportPriority(value string) int {
 
 // claimOrForce resolves the output directory based on --force and --output flags.
 //
-//   - force=true:  RemoveAll the target, then create it fresh (claims exact slot), preserving hand-authored internal/cli/*.go files
+//   - force=true:  RemoveAll the target, then create it fresh (claims exact slot), preserving hand-authored internal/cli/*.go files and internal sibling packages
 //   - explicit output (--output set) without force: error if exists and non-empty
 //   - default (no --output, no --force): auto-increment via ClaimOutputDir
 func claimOrForce(absOut string, force bool, explicitOutput bool) (string, error) {
@@ -770,12 +772,20 @@ func claimOrForce(absOut string, force bool, explicitOutput bool) (string, error
 		if err != nil {
 			return "", err
 		}
+		preservedDirs, err := preserveHandAuthoredInternalSiblingDirs(absOut)
+		if err != nil {
+			return "", err
+		}
+		defer cleanupPreservedDirs(preservedDirs)
 		if err := os.RemoveAll(absOut); err != nil {
 			return "", fmt.Errorf("removing existing output dir: %w", err)
 		}
 		if err := os.MkdirAll(absOut, 0o755); err != nil {
 			return "", fmt.Errorf("creating output dir: %w", err)
 		}
+		if err := restorePreservedDirs(absOut, preservedDirs); err != nil {
+			return "", err
+		}
 		if err := restorePreservedFiles(absOut, preserved); err != nil {
 			return "", err
 		}
@@ -804,6 +814,23 @@ type preservedFile struct {
 	mode    os.FileMode
 }
 
+type preservedDir struct {
+	relPath string
+	staged  string
+}
+
+var generatorOwnedInternalDirs = map[string]bool{
+	"cache":   true,
+	"cli":     true,
+	"client":  true,
+	"cliutil": true,
+	"config":  true,
+	"mcp":     true,
+	"share":   true,
+	"store":   true,
+	"types":   true,
+}
+
 func preserveHandAuthoredInternalCLIFiles(absOut string) ([]preservedFile, error) {
 	for _, rel := range []string{"internal", filepath.Join("internal", "cli")} {
 		path := filepath.Join(absOut, rel)
@@ -857,6 +884,70 @@ func preserveHandAuthoredInternalCLIFiles(absOut string) ([]preservedFile, error
 	return preserved, nil
 }
 
+func preserveHandAuthoredInternalSiblingDirs(absOut string) ([]preservedDir, error) {
+	internalDir := filepath.Join(absOut, "internal")
+	entries, err := os.ReadDir(internalDir)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil, nil
+		}
+		return nil, fmt.Errorf("reading internal for hand-authored sibling packages: %w", err)
+	}
+
+	var preserved []preservedDir
+	var stagingRoot string
+	for _, entry := range entries {
+		if entry.Type()&os.ModeSymlink != 0 {
+			return nil, fmt.Errorf("refusing to preserve symlinked internal sibling package: %s", filepath.Join(internalDir, entry.Name()))
+		}
+		if !entry.IsDir() || generatorOwnsInternalDir(internalDir, entry.Name()) {
+			continue
+		}
+		if stagingRoot == "" {
+			stagingRoot, err = os.MkdirTemp("", "printing-press-preserved-internal-*")
+			if err != nil {
+				return nil, fmt.Errorf("creating preservation staging dir: %w", err)
+			}
+		}
+		src := filepath.Join(internalDir, entry.Name())
+		staged := filepath.Join(stagingRoot, entry.Name())
+		if err := copyPreservedDir(src, staged); err != nil {
+			cleanupPreservedDirs(preserved)
+			_ = os.RemoveAll(stagingRoot)
+			return nil, err
+		}
+		preserved = append(preserved, preservedDir{
+			relPath: filepath.Join("internal", entry.Name()),
+			staged:  staged,
+		})
+	}
+	return preserved, nil
+}
+
+func generatorOwnsInternalDir(internalDir, name string) bool {
+	if name == "cli" {
+		return true
+	}
+	if !generatorOwnedInternalDirs[name] {
+		return false
+	}
+	return dirContainsGeneratedMarker(filepath.Join(internalDir, name))
+}
+
+func dirContainsGeneratedMarker(dir string) bool {
+	found := false
+	_ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
+		if err != nil || found || d.IsDir() || d.Type()&os.ModeSymlink != 0 {
+			return nil
+		}
+		if generatedmarker.HasInFile(path) {
+			found = true
+		}
+		return nil
+	})
+	return found
+}
+
 func restorePreservedFiles(absOut string, files []preservedFile) error {
 	for _, file := range files {
 		path := filepath.Join(absOut, file.relPath)
@@ -870,6 +961,78 @@ func restorePreservedFiles(absOut string, files []preservedFile) error {
 	return nil
 }
 
+func restorePreservedDirs(absOut string, dirs []preservedDir) error {
+	for _, dir := range dirs {
+		dst := filepath.Join(absOut, dir.relPath)
+		if _, err := os.Stat(dst); err == nil {
+			return fmt.Errorf("refusing to overwrite generated internal sibling package while restoring preserved files: %s", dst)
+		} else if err != nil && !os.IsNotExist(err) {
+			return fmt.Errorf("checking preserved sibling restore path %s: %w", dst, err)
+		}
+		if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
+			return fmt.Errorf("creating preserved sibling parent: %w", err)
+		}
+		if err := movePreservedDir(dir.staged, dst, os.Rename); err != nil {
+			return fmt.Errorf("restoring preserved sibling package %s: %w", dst, err)
+		}
+	}
+	return nil
+}
+
+func cleanupPreservedDirs(dirs []preservedDir) {
+	for _, dir := range dirs {
+		_ = os.RemoveAll(filepath.Dir(dir.staged))
+	}
+}
+
+func movePreservedDir(src, dst string, rename func(string, string) error) error {
+	if err := rename(src, dst); err != nil {
+		if !errors.Is(err, syscall.EXDEV) {
+			return err
+		}
+		if err := copyPreservedDir(src, dst); err != nil {
+			return err
+		}
+		return os.RemoveAll(src)
+	}
+	return nil
+}
+
+func copyPreservedDir(src, dst string) error {
+	return filepath.WalkDir(src, func(path string, d os.DirEntry, walkErr error) error {
+		if walkErr != nil {
+			return walkErr
+		}
+		if d.Type()&os.ModeSymlink != 0 {
+			return fmt.Errorf("refusing to preserve symlink inside internal sibling package: %s", path)
+		}
+		rel, err := filepath.Rel(src, path)
+		if err != nil {
+			return fmt.Errorf("relativizing preserved sibling path %s: %w", path, err)
+		}
+		target := filepath.Join(dst, rel)
+		info, err := d.Info()
+		if err != nil {
+			return fmt.Errorf("statting preserved sibling path %s: %w", path, err)
+		}
+		switch {
+		case d.IsDir():
+			return os.MkdirAll(target, info.Mode().Perm())
+		case info.Mode().IsRegular():
+			data, err := os.ReadFile(path)
+			if err != nil {
+				return fmt.Errorf("reading preserved sibling file %s: %w", path, err)
+			}
+			if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+				return fmt.Errorf("creating preserved sibling file parent: %w", err)
+			}
+			return os.WriteFile(target, data, info.Mode().Perm())
+		default:
+			return fmt.Errorf("refusing to preserve non-regular internal sibling path: %s", path)
+		}
+	})
+}
+
 func fetchOrCacheSpec(specURL string, refresh bool, skipCache bool) ([]byte, error) {
 	sum := sha256.Sum256([]byte(specURL))
 	cacheKey := hex.EncodeToString(sum[:])

← d94d89dc fix(cli): handle empty sync pages (#903)  ·  back to Cli Printing Press  ·  fix(cli): seed template-var placeholders in verify mode (#93 a1d39bf3 →