[object Object]

← back to Cli Printing Press

fix(cli): publish manifest must read spec.yaml alongside spec.json (#220)

c5ed4369a52f331e5a3fcccf922c754f2f4b5027 · 2026-04-18 02:21:11 -0700 · Trevin Chow

writeCLIManifestForPublish only looked for spec.json in the working
directory, so YAML-archived CLIs (espn, hackernews, recipe-goat,
yahoo-finance, weather-goat, archive-is, movie-goat) silently lost
spec_format, spec_checksum, MCP metadata refresh, and tools-manifest.json
regeneration at publish time. The generate-time manifest path already
handled the multi-extension case; the publish-time path drifted.

Extracted findArchivedSpec(dir) for the spec.json -> spec.yaml -> spec.yml
lookup and used it in both call sites. Backfill of existing manifests is
a separate concern (requires a republish per CLI).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit c5ed4369a52f331e5a3fcccf922c754f2f4b5027
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sat Apr 18 02:21:11 2026 -0700

    fix(cli): publish manifest must read spec.yaml alongside spec.json (#220)
    
    writeCLIManifestForPublish only looked for spec.json in the working
    directory, so YAML-archived CLIs (espn, hackernews, recipe-goat,
    yahoo-finance, weather-goat, archive-is, movie-goat) silently lost
    spec_format, spec_checksum, MCP metadata refresh, and tools-manifest.json
    regeneration at publish time. The generate-time manifest path already
    handled the multi-extension case; the publish-time path drifted.
    
    Extracted findArchivedSpec(dir) for the spec.json -> spec.yaml -> spec.yml
    lookup and used it in both call sites. Backfill of existing manifests is
    a separate concern (requires a republish per CLI).
    
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 internal/pipeline/climanifest.go      | 30 +++++++++++++++++++--------
 internal/pipeline/climanifest_test.go | 39 +++++++++++++++++++++++++++++++++++
 internal/pipeline/publish.go          | 13 ++++++------
 3 files changed, 66 insertions(+), 16 deletions(-)

diff --git a/internal/pipeline/climanifest.go b/internal/pipeline/climanifest.go
index 1e8bd9dc..55fc346e 100644
--- a/internal/pipeline/climanifest.go
+++ b/internal/pipeline/climanifest.go
@@ -76,6 +76,25 @@ func WriteCLIManifest(dir string, m CLIManifest) error {
 	return nil
 }
 
+// findArchivedSpec looks for a spec file archived alongside a generated CLI.
+// generate archives the source spec as spec.json (for JSON inputs) or
+// spec.yaml (for YAML inputs); older runs occasionally used spec.yml. Returns
+// the first match's path and contents, or an empty path with nil error when
+// no archive is present.
+func findArchivedSpec(dir string) (string, []byte, error) {
+	for _, name := range []string{"spec.json", "spec.yaml", "spec.yml"} {
+		path := filepath.Join(dir, name)
+		data, err := os.ReadFile(path)
+		if err == nil {
+			return path, data, nil
+		}
+		if !os.IsNotExist(err) {
+			return "", nil, fmt.Errorf("reading %s: %w", path, err)
+		}
+	}
+	return "", nil, nil
+}
+
 // specChecksum computes a SHA-256 checksum of the file at path.
 // Returns "sha256:<hex>" on success, or an empty string if the file
 // does not exist.
@@ -173,22 +192,15 @@ func WriteManifestForGenerate(p GenerateManifestParams) error {
 
 	// Fallback: detect format and checksum from any spec file cached in the output dir.
 	if m.SpecFormat == "" || m.SpecChecksum == "" {
-		for _, name := range []string{"spec.json", "spec.yaml", "spec.yml"} {
-			specFile := filepath.Join(p.OutputDir, name)
-			data, err := os.ReadFile(specFile)
-			if err != nil {
-				continue
-			}
+		if specFile, data, err := findArchivedSpec(p.OutputDir); err == nil && specFile != "" {
 			if m.SpecFormat == "" {
 				m.SpecFormat = detectSpecFormat(data)
 			}
 			if m.SpecChecksum == "" {
-				cs, err := specChecksum(specFile)
-				if err == nil {
+				if cs, err := specChecksum(specFile); err == nil {
 					m.SpecChecksum = cs
 				}
 			}
-			break
 		}
 	}
 
diff --git a/internal/pipeline/climanifest_test.go b/internal/pipeline/climanifest_test.go
index 7dd71966..1151e730 100644
--- a/internal/pipeline/climanifest_test.go
+++ b/internal/pipeline/climanifest_test.go
@@ -259,6 +259,45 @@ func TestPublishManifestNormalizesURLDuplicatedInBothFields(t *testing.T) {
 	assert.Empty(t, got.SpecPath, "URL should not be duplicated in spec_path")
 }
 
+func TestPublishWorkingCLIWritesManifestForYAMLSpec(t *testing.T) {
+	home := setPressTestEnv(t)
+
+	workingDir := filepath.Join(home, "working", "yaml-spec-pp-cli")
+	require.NoError(t, os.MkdirAll(workingDir, 0o755))
+	require.NoError(t, os.WriteFile(
+		filepath.Join(workingDir, "main.go"),
+		[]byte("package main\nfunc main() {}"),
+		0o644,
+	))
+
+	specContent := []byte("openapi: 3.0.0\ninfo:\n  title: YAML Test\n  version: 1.0.0\npaths: {}\n")
+	require.NoError(t, os.WriteFile(
+		filepath.Join(workingDir, "spec.yaml"),
+		specContent,
+		0o644,
+	))
+
+	state := NewState("yaml-api", workingDir)
+	require.NoError(t, os.MkdirAll(filepath.Dir(state.StatePath()), 0o755))
+	require.NoError(t, state.Save())
+
+	publishDir := filepath.Join(home, "library", "yaml-spec-pp-cli")
+	finalDir, err := PublishWorkingCLI(state, publishDir)
+	require.NoError(t, err)
+
+	data, err := os.ReadFile(filepath.Join(finalDir, CLIManifestFilename))
+	require.NoError(t, err)
+
+	var got CLIManifest
+	require.NoError(t, json.Unmarshal(data, &got))
+
+	assert.Equal(t, "openapi3", got.SpecFormat, "publish must detect format of YAML-archived specs")
+
+	h := sha256.Sum256(specContent)
+	expectedChecksum := "sha256:" + hex.EncodeToString(h[:])
+	assert.Equal(t, expectedChecksum, got.SpecChecksum, "publish must checksum YAML-archived specs")
+}
+
 func TestPublishWorkingCLIManifestWithoutSpec(t *testing.T) {
 	home := setPressTestEnv(t)
 
diff --git a/internal/pipeline/publish.go b/internal/pipeline/publish.go
index 2509c64a..13125e73 100644
--- a/internal/pipeline/publish.go
+++ b/internal/pipeline/publish.go
@@ -217,14 +217,13 @@ func writeCLIManifestForPublish(state *PipelineState, dir string) error {
 		}
 	}
 
-	// Detect spec format and compute checksum from the spec file in the
-	// working directory. spec.json only exists when specFlag is --spec;
-	// for --docs runs it won't be present and these fields stay empty.
-	specFile := filepath.Join(state.EffectiveWorkingDir(), "spec.json")
-	if data, err := os.ReadFile(specFile); err == nil {
+	// Detect spec format and compute checksum from the spec file archived
+	// alongside the CLI. generate writes spec.json for JSON inputs and
+	// spec.yaml for YAML inputs; --docs / --plan runs leave no archive and
+	// these fields stay empty.
+	if specFile, data, err := findArchivedSpec(state.EffectiveWorkingDir()); err == nil && specFile != "" {
 		m.SpecFormat = detectSpecFormat(data)
-		checksum, err := specChecksum(specFile)
-		if err == nil {
+		if checksum, err := specChecksum(specFile); err == nil {
 			m.SpecChecksum = checksum
 		}
 

← 16ed5a56 feat(cli): printing-press patch — AST-inject PR #218 feature  ·  back to Cli Printing Press  ·  feat(cli): patch skips AST mutations owned by colliding feat 331809da →