← 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
M internal/cli/generate_test.goM internal/cli/root.go
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 →