← back to Cli Printing Press
fix(cli): preserve novel cli files on force generate (#747)
9675f8b2b39be4639a7bdd2e34cc8fcd7e6b00f7 · 2026-05-08 14:38:41 -0700 · Trevin Chow
Files touched
M internal/cli/generate_test.goM internal/cli/root.goA internal/generatedmarker/marker.goA internal/generatedmarker/marker_test.goM internal/pipeline/regenmerge/classify.go
Diff
commit 9675f8b2b39be4639a7bdd2e34cc8fcd7e6b00f7
Author: Trevin Chow <trevin@trevinchow.com>
Date: Fri May 8 14:38:41 2026 -0700
fix(cli): preserve novel cli files on force generate (#747)
---
internal/cli/generate_test.go | 80 ++++++++++++++++++++++++++++++++
internal/cli/root.go | 65 +++++++++++++++++++++++++-
internal/generatedmarker/marker.go | 28 +++++++++++
internal/generatedmarker/marker_test.go | 35 ++++++++++++++
internal/pipeline/regenmerge/classify.go | 26 ++---------
5 files changed, 210 insertions(+), 24 deletions(-)
diff --git a/internal/cli/generate_test.go b/internal/cli/generate_test.go
index f4067740..c4623929 100644
--- a/internal/cli/generate_test.go
+++ b/internal/cli/generate_test.go
@@ -5,6 +5,7 @@ import (
"go/parser"
"go/token"
"os"
+ "os/exec"
"path/filepath"
"testing"
@@ -15,6 +16,77 @@ import (
"github.com/stretchr/testify/require"
)
+func TestGenerateCmdForcePreservesHandAuthoredInternalCLI(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ specPath := filepath.Join(dir, "spec.yaml")
+ outputDir := filepath.Join(dir, "regenapp")
+ require.NoError(t, os.WriteFile(specPath, []byte(`name: regenapp
+description: Regen app API
+version: 0.1.0
+base_url: https://api.example.com
+auth:
+ type: none
+config:
+ format: toml
+ path: ~/.config/regenapp-pp-cli/config.toml
+resources:
+ items:
+ description: Manage items
+ endpoints:
+ list:
+ method: GET
+ path: /items
+ description: List items
+`), 0o644))
+
+ runGenerate := func() {
+ cmd := newGenerateCmd()
+ cmd.SetArgs([]string{
+ "--spec", specPath,
+ "--output", outputDir,
+ "--validate=false",
+ "--force",
+ })
+ require.NoError(t, cmd.Execute())
+ }
+
+ runGenerate()
+
+ novelPath := filepath.Join(outputDir, "internal", "cli", "novel_keep.go")
+ novelSource := []byte(`package cli
+
+func novelPreservedSentinel() string { return "preserved" }
+`)
+ require.NoError(t, os.WriteFile(novelPath, novelSource, 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")
+ require.NoError(t, os.WriteFile(staleGeneratedPath, []byte(`// Copyright 2026 trevin-chow.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+package cli
+
+func staleGeneratedCommand() {}
+`), 0o644))
+
+ runGenerate()
+
+ gotNovel, err := os.ReadFile(novelPath)
+ require.NoError(t, err)
+ assert.Equal(t, string(novelSource), string(gotNovel))
+
+ rootGo, err := os.ReadFile(rootPath)
+ require.NoError(t, err)
+ assert.Contains(t, string(rootGo), "Generated by CLI Printing Press")
+ assert.NotContains(t, string(rootGo), "brokenGeneratedEdit")
+ assert.NoFileExists(t, staleGeneratedPath)
+
+ runGoCommandForCLITest(t, outputDir, "mod", "tidy")
+ runGoCommandForCLITest(t, outputDir, "build", "./cmd/regenapp-pp-cli")
+}
+
func TestGenerateCmdConsumesTrafficAnalysis(t *testing.T) {
t.Parallel()
@@ -856,3 +928,11 @@ func TestEnrichSpecFromCatalogKeepsExplicitDisplayName(t *testing.T) {
assert.Equal(t, "Spec.dev", apiSpec.DisplayName)
}
+
+func runGoCommandForCLITest(t *testing.T, dir string, args ...string) {
+ t.Helper()
+ cmd := exec.Command("go", args...)
+ cmd.Dir = dir
+ out, err := cmd.CombinedOutput()
+ require.NoError(t, err, string(out))
+}
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 7eff875d..e7c029e7 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -19,6 +19,7 @@ import (
"github.com/mvanhorn/cli-printing-press/v4/internal/browsersniff"
"github.com/mvanhorn/cli-printing-press/v4/internal/catalog"
"github.com/mvanhorn/cli-printing-press/v4/internal/docspec"
+ "github.com/mvanhorn/cli-printing-press/v4/internal/generatedmarker"
"github.com/mvanhorn/cli-printing-press/v4/internal/generator"
"github.com/mvanhorn/cli-printing-press/v4/internal/graphql"
"github.com/mvanhorn/cli-printing-press/v4/internal/llm"
@@ -765,17 +766,24 @@ 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)
+// - force=true: RemoveAll the target, then create it fresh (claims exact slot), preserving hand-authored internal/cli/*.go files
// - 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) {
if force {
+ preserved, err := preserveHandAuthoredInternalCLIFiles(absOut)
+ if err != nil {
+ return "", err
+ }
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 := restorePreservedFiles(absOut, preserved); err != nil {
+ return "", err
+ }
return absOut, nil
}
@@ -795,6 +803,61 @@ func claimOrForce(absOut string, force bool, explicitOutput bool) (string, error
return pipeline.ClaimOutputDir(absOut)
}
+type preservedFile struct {
+ relPath string
+ data []byte
+ mode os.FileMode
+}
+
+func preserveHandAuthoredInternalCLIFiles(absOut string) ([]preservedFile, error) {
+ cliDir := filepath.Join(absOut, "internal", "cli")
+ entries, err := os.ReadDir(cliDir)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("reading internal/cli for hand-authored files: %w", err)
+ }
+
+ var preserved []preservedFile
+ for _, entry := range entries {
+ if entry.IsDir() || filepath.Ext(entry.Name()) != ".go" {
+ continue
+ }
+ path := filepath.Join(cliDir, entry.Name())
+ if generatedmarker.HasInFile(path) {
+ continue
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("reading hand-authored candidate %s: %w", path, err)
+ }
+ info, err := entry.Info()
+ if err != nil {
+ return nil, fmt.Errorf("statting hand-authored candidate %s: %w", path, err)
+ }
+ preserved = append(preserved, preservedFile{
+ relPath: filepath.Join("internal", "cli", entry.Name()),
+ data: data,
+ mode: info.Mode().Perm(),
+ })
+ }
+ return preserved, nil
+}
+
+func restorePreservedFiles(absOut string, files []preservedFile) error {
+ for _, file := range files {
+ path := filepath.Join(absOut, file.relPath)
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return fmt.Errorf("creating preserved file parent: %w", err)
+ }
+ if err := os.WriteFile(path, file.data, file.mode); err != nil {
+ return fmt.Errorf("restoring preserved file %s: %w", path, err)
+ }
+ }
+ return nil
+}
+
func fetchOrCacheSpec(specURL string, refresh bool, skipCache bool) ([]byte, error) {
sum := sha256.Sum256([]byte(specURL))
cacheKey := hex.EncodeToString(sum[:])
diff --git a/internal/generatedmarker/marker.go b/internal/generatedmarker/marker.go
new file mode 100644
index 00000000..f93f0124
--- /dev/null
+++ b/internal/generatedmarker/marker.go
@@ -0,0 +1,28 @@
+package generatedmarker
+
+import (
+ "bufio"
+ "os"
+ "strings"
+)
+
+// Text is the marker emitted near the head of generated source files.
+const Text = "Generated by CLI Printing Press"
+
+// HasInFile reports whether one of the file's first few lines contains Text.
+func HasInFile(path string) bool {
+ f, err := os.Open(path)
+ if err != nil {
+ return false
+ }
+ defer func() { _ = f.Close() }()
+
+ scanner := bufio.NewScanner(f)
+ scanner.Buffer(make([]byte, 4096), 64*1024)
+ for line := 0; line < 5 && scanner.Scan(); line++ {
+ if strings.Contains(scanner.Text(), Text) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/generatedmarker/marker_test.go b/internal/generatedmarker/marker_test.go
new file mode 100644
index 00000000..7de5ebd0
--- /dev/null
+++ b/internal/generatedmarker/marker_test.go
@@ -0,0 +1,35 @@
+package generatedmarker
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestHasInFileScansGeneratedMarkerNearHead(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ path := filepath.Join(dir, "generated.go")
+ require.NoError(t, os.WriteFile(path, []byte(`// Copyright 2026 trevin-chow.
+// SPDX-License-Identifier: Apache-2.0
+// Extra generated preamble.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+package cli
+`), 0o644))
+
+ assert.True(t, HasInFile(path))
+}
+
+func TestHasInFileIgnoresHandAuthoredFile(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ path := filepath.Join(dir, "novel.go")
+ require.NoError(t, os.WriteFile(path, []byte("package cli\n\nfunc novel() {}\n"), 0o644))
+
+ assert.False(t, HasInFile(path))
+}
diff --git a/internal/pipeline/regenmerge/classify.go b/internal/pipeline/regenmerge/classify.go
index 3fecc105..de62b2ca 100644
--- a/internal/pipeline/regenmerge/classify.go
+++ b/internal/pipeline/regenmerge/classify.go
@@ -1,8 +1,6 @@
package regenmerge
import (
- "bufio"
- "bytes"
"fmt"
"go/ast"
"go/parser"
@@ -12,11 +10,9 @@ import (
"path/filepath"
"sort"
"strings"
-)
-// templatedMarker is the line-2 comment every newer template emits. Matched
-// case-sensitively as a substring of the second line.
-const templatedMarker = "Generated by CLI Printing Press"
+ "github.com/mvanhorn/cli-printing-press/v4/internal/generatedmarker"
+)
// declSet maps a canonical decl name to whether it was found. Names follow
// the convention used by extractDecls below.
@@ -120,24 +116,8 @@ func canonicalFuncName(fn *ast.FuncDecl) string {
return name
}
-// hasTemplatedMarker reports whether one of the file's first few lines
-// contains the "Generated by CLI Printing Press" marker. Scans the head of
-// the file rather than a fixed byte slab so a long line-1 SPDX/license
-// header can't push the marker past the read window.
func hasTemplatedMarker(filename string) bool {
- f, err := os.Open(filename)
- if err != nil {
- return false
- }
- defer func() { _ = f.Close() }()
- scanner := bufio.NewScanner(f)
- scanner.Buffer(make([]byte, 4096), 64*1024)
- for line := 0; line < 5 && scanner.Scan(); line++ {
- if bytes.Contains(scanner.Bytes(), []byte(templatedMarker)) {
- return true
- }
- }
- return false
+ return generatedmarker.HasInFile(filename)
}
// classifyFiles walks both trees, building the combined file-path set and
← 64363921 fix(generator): use simple backticks in auth_client_credenti
·
back to Cli Printing Press
·
fix(cli): align generated install and rename metadata (#749) ca580efc →