[object Object]

← back to Cli Printing Press

fix(cli): resolve shipcheck CLI binary name from .printing-press.json (#1700)

9eff1e208a5b0238d990f1e0774ae40afc8d5ceb · 2026-05-19 19:01:38 -0700 · Trevin Chow

* fix(cli): resolve shipcheck CLI binary name from .printing-press.json

shipcheckCLIPath and shipcheckCLIPathForGOOS derived the binary name from
filepath.Base(o.dir), which is wrong for slug-keyed library dirs. For
~/printing-press/library/notion, basename is "notion" but the binary is
"notion-pp-cli", so validate-narrative was invoked with a binary path
that does not exist on disk.

Resolve the binary name from .printing-press.json's cli_name via
pipeline.ReadCLIBinaryName, and fall back to filepath.Base(dir) when the
manifest is absent or unparseable (preserves legacy behavior).

Clawpatch finding: fnd_sig-feat-cli-command-e17f734225-_8ca7979519

* test(cli): cover unparseable-manifest fallback in shipcheckBinaryName

Adds TestShipcheckCLIPath_FallsBackOnUnparseableManifest to pin the
parse-error branch of pipeline.ReadCLIBinaryName: when
.printing-press.json exists but is malformed JSON, the binary name
must fall back to filepath.Base(dir) rather than propagate the parse
error or yield an empty name.

Greptile P2 on #1700 — the godoc promised the fallback for the
"absent or unparseable" case but only the absent path was tested.

Files touched

Diff

commit 9eff1e208a5b0238d990f1e0774ae40afc8d5ceb
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Tue May 19 19:01:38 2026 -0700

    fix(cli): resolve shipcheck CLI binary name from .printing-press.json (#1700)
    
    * fix(cli): resolve shipcheck CLI binary name from .printing-press.json
    
    shipcheckCLIPath and shipcheckCLIPathForGOOS derived the binary name from
    filepath.Base(o.dir), which is wrong for slug-keyed library dirs. For
    ~/printing-press/library/notion, basename is "notion" but the binary is
    "notion-pp-cli", so validate-narrative was invoked with a binary path
    that does not exist on disk.
    
    Resolve the binary name from .printing-press.json's cli_name via
    pipeline.ReadCLIBinaryName, and fall back to filepath.Base(dir) when the
    manifest is absent or unparseable (preserves legacy behavior).
    
    Clawpatch finding: fnd_sig-feat-cli-command-e17f734225-_8ca7979519
    
    * test(cli): cover unparseable-manifest fallback in shipcheckBinaryName
    
    Adds TestShipcheckCLIPath_FallsBackOnUnparseableManifest to pin the
    parse-error branch of pipeline.ReadCLIBinaryName: when
    .printing-press.json exists but is malformed JSON, the binary name
    must fall back to filepath.Base(dir) rather than propagate the parse
    error or yield an empty name.
    
    Greptile P2 on #1700 — the godoc promised the fallback for the
    "absent or unparseable" case but only the absent path was tested.
---
 internal/cli/shipcheck.go      | 15 +++++++--
 internal/cli/shipcheck_test.go | 70 ++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 83 insertions(+), 2 deletions(-)

diff --git a/internal/cli/shipcheck.go b/internal/cli/shipcheck.go
index 3c5a577b..8a5dfbad 100644
--- a/internal/cli/shipcheck.go
+++ b/internal/cli/shipcheck.go
@@ -11,6 +11,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/mvanhorn/cli-printing-press/v4/internal/pipeline"
 	"github.com/mvanhorn/cli-printing-press/v4/internal/platform"
 	"github.com/spf13/cobra"
 )
@@ -156,12 +157,22 @@ func shipcheckResearchPath(o *shipcheckOpts) string {
 	return filepath.Join(dir, "research.json")
 }
 
+// shipcheckBinaryName resolves the CLI binary name for a generated CLI directory.
+// Prefers .printing-press.json's cli_name (the canonical "<api-slug>-pp-cli" form)
+// and falls back to the directory's basename for legacy/manifest-less dirs.
+func shipcheckBinaryName(dir string) string {
+	if name := pipeline.ReadCLIBinaryName(dir); name != "" {
+		return name
+	}
+	return filepath.Base(dir)
+}
+
 func shipcheckCLIPath(o *shipcheckOpts) string {
-	return platform.ExecutablePath(filepath.Join(o.dir, filepath.Base(o.dir)))
+	return platform.ExecutablePath(filepath.Join(o.dir, shipcheckBinaryName(o.dir)))
 }
 
 func shipcheckCLIPathForGOOS(o *shipcheckOpts, goos string) string {
-	return platform.ExecutablePathForGOOS(filepath.Join(o.dir, filepath.Base(o.dir)), goos)
+	return platform.ExecutablePathForGOOS(filepath.Join(o.dir, shipcheckBinaryName(o.dir)), goos)
 }
 
 // shipcheckLegResult is the per-leg outcome of one umbrella run.
diff --git a/internal/cli/shipcheck_test.go b/internal/cli/shipcheck_test.go
index db13d6f3..910b9a0b 100644
--- a/internal/cli/shipcheck_test.go
+++ b/internal/cli/shipcheck_test.go
@@ -64,6 +64,76 @@ func TestShipcheckCLIPathForGOOS(t *testing.T) {
 	}
 }
 
+// TestShipcheckCLIPath_ManifestOverridesBasename covers the slug-keyed
+// library layout: dir basename ("notion") differs from the actual binary
+// name ("notion-pp-cli"), so the binary path must be resolved from
+// .printing-press.json's cli_name rather than the directory's basename.
+func TestShipcheckCLIPath_ManifestOverridesBasename(t *testing.T) {
+	t.Parallel()
+
+	dir := filepath.Join(t.TempDir(), "notion")
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("creating slug dir: %v", err)
+	}
+	if err := os.WriteFile(filepath.Join(dir, ".printing-press.json"), []byte(`{"cli_name":"notion-pp-cli"}`), 0o644); err != nil {
+		t.Fatalf("writing manifest: %v", err)
+	}
+	opts := &shipcheckOpts{dir: dir}
+
+	if got, want := shipcheckCLIPathForGOOS(opts, "linux"), filepath.Join(dir, "notion-pp-cli"); got != want {
+		t.Fatalf("linux path = %q, want %q", got, want)
+	}
+	if got, want := shipcheckCLIPathForGOOS(opts, "windows"), filepath.Join(dir, "notion-pp-cli.exe"); got != want {
+		t.Fatalf("windows path = %q, want %q", got, want)
+	}
+}
+
+// TestShipcheckCLIPath_FallsBackToBasename covers the manifest-less case:
+// when .printing-press.json is missing or unparseable, the binary name
+// falls back to the directory basename (preserves legacy behavior).
+func TestShipcheckCLIPath_FallsBackToBasename(t *testing.T) {
+	t.Parallel()
+
+	dir := filepath.Join(t.TempDir(), "sample-cli")
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("creating dir: %v", err)
+	}
+	opts := &shipcheckOpts{dir: dir}
+
+	if got, want := shipcheckCLIPathForGOOS(opts, "linux"), filepath.Join(dir, "sample-cli"); got != want {
+		t.Fatalf("linux path = %q, want %q", got, want)
+	}
+	if got, want := shipcheckCLIPathForGOOS(opts, "windows"), filepath.Join(dir, "sample-cli.exe"); got != want {
+		t.Fatalf("windows path = %q, want %q", got, want)
+	}
+}
+
+// TestShipcheckCLIPath_FallsBackOnUnparseableManifest pins the
+// unparseable-error branch of pipeline.ReadCLIBinaryName: a
+// .printing-press.json that exists but contains malformed JSON must
+// fall back to the directory basename rather than propagating a parse
+// error or producing an empty binary name.
+func TestShipcheckCLIPath_FallsBackOnUnparseableManifest(t *testing.T) {
+	t.Parallel()
+
+	dir := filepath.Join(t.TempDir(), "notion")
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("creating slug dir: %v", err)
+	}
+	// Truncated JSON: opening brace, key, colon, no value, no close.
+	if err := os.WriteFile(filepath.Join(dir, ".printing-press.json"), []byte(`{"cli_name":`), 0o644); err != nil {
+		t.Fatalf("writing malformed manifest: %v", err)
+	}
+	opts := &shipcheckOpts{dir: dir}
+
+	if got, want := shipcheckCLIPathForGOOS(opts, "linux"), filepath.Join(dir, "notion"); got != want {
+		t.Fatalf("linux path = %q, want %q", got, want)
+	}
+	if got, want := shipcheckCLIPathForGOOS(opts, "windows"), filepath.Join(dir, "notion.exe"); got != want {
+		t.Fatalf("windows path = %q, want %q", got, want)
+	}
+}
+
 type shipcheckHarness struct {
 	dir     string
 	logFile string

← b534884f feat(cli): GraphQL credential probe in doctor + actionable c  ·  back to Cli Printing Press  ·  fix(cli): harden manifest-gen remote spec loader against han aecf495a →