← back to Cli Printing Press
fix(cli): refuse to ship CLIs with placeholder base URL (#1186)
c9a35b5e2423c8d8a21eec6fa126c9b10e85746d · 2026-05-12 08:01:03 -0700 · Trevin Chow
* fix(cli): refuse to ship CLIs with placeholder base URL
The OpenAPI, GraphQL, and docspec parsers all fall back to
https://api.example.com when the source declares no real host. The
generate command previously wrote that placeholder into config.toml and
shipped a CLI whose `doctor` would DNS-fail on every call.
Track the fallback via a new BaseURLIsPlaceholder field on spec.APISpec
and a shared spec.PlaceholderBaseURL constant. All three parsers mark
the flag; the generate command refuses both the OpenAPI/GraphQL spec
loop and the --docs path when it fires. The LLM doc-scrape path
re-detects the placeholder after spec.ParseBytes since yaml:"-" strips
the flag on round-trip.
Closes #1012
* test(cli): use spec.PlaceholderBaseURL in placeholder assertions
Tests previously hardcoded the placeholder string literal, so renaming
the shared constant would leave them green while silently asserting the
old value. Reference spec.PlaceholderBaseURL directly so any future
rename surfaces in the test diff.
Files touched
M internal/cli/generate_test.goM internal/cli/root.goM internal/docspec/docspec.goM internal/docspec/docspec_test.goM internal/graphql/parser.goM internal/graphql/parser_test.goM internal/openapi/parser.goM internal/openapi/parser_test.goM internal/spec/spec.go
Diff
commit c9a35b5e2423c8d8a21eec6fa126c9b10e85746d
Author: Trevin Chow <trevin@trevinchow.com>
Date: Tue May 12 08:01:03 2026 -0700
fix(cli): refuse to ship CLIs with placeholder base URL (#1186)
* fix(cli): refuse to ship CLIs with placeholder base URL
The OpenAPI, GraphQL, and docspec parsers all fall back to
https://api.example.com when the source declares no real host. The
generate command previously wrote that placeholder into config.toml and
shipped a CLI whose `doctor` would DNS-fail on every call.
Track the fallback via a new BaseURLIsPlaceholder field on spec.APISpec
and a shared spec.PlaceholderBaseURL constant. All three parsers mark
the flag; the generate command refuses both the OpenAPI/GraphQL spec
loop and the --docs path when it fires. The LLM doc-scrape path
re-detects the placeholder after spec.ParseBytes since yaml:"-" strips
the flag on round-trip.
Closes #1012
* test(cli): use spec.PlaceholderBaseURL in placeholder assertions
Tests previously hardcoded the placeholder string literal, so renaming
the shared constant would leave them green while silently asserting the
old value. Reference spec.PlaceholderBaseURL directly so any future
rename surfaces in the test diff.
---
internal/cli/generate_test.go | 39 +++++++++++++++++++++++
internal/cli/root.go | 7 +++++
internal/docspec/docspec.go | 37 +++++++++++++++-------
internal/docspec/docspec_test.go | 10 ++++--
internal/graphql/parser.go | 15 +++++----
internal/graphql/parser_test.go | 17 ++++++++++
internal/openapi/parser.go | 5 ++-
internal/openapi/parser_test.go | 68 ++++++++++++++++++++++++++++++++++++++++
internal/spec/spec.go | 10 ++++++
9 files changed, 187 insertions(+), 21 deletions(-)
diff --git a/internal/cli/generate_test.go b/internal/cli/generate_test.go
index 80b95418..b72f5e27 100644
--- a/internal/cli/generate_test.go
+++ b/internal/cli/generate_test.go
@@ -1709,3 +1709,42 @@ func runGoCommandForCLITest(t *testing.T, dir string, args ...string) {
out, err := cmd.CombinedOutput()
require.NoError(t, err, string(out))
}
+
+// TestGenerateCmdRefusesPlaceholderBaseURL pins the contract that the
+// generate command halts when the spec declares neither `servers:` nor
+// any per-operation server. The parser falls back to a fake hostname so
+// the spec keeps parsing, but the generate command must refuse to write
+// a CLI whose `doctor` would DNS-fail on every call.
+func TestGenerateCmdRefusesPlaceholderBaseURL(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ specPath := filepath.Join(dir, "spec.yaml")
+ outputDir := filepath.Join(dir, "noserversapp")
+ require.NoError(t, os.WriteFile(specPath, []byte(`openapi: "3.0.3"
+info:
+ title: No Servers App
+ version: "1.0"
+paths:
+ /things:
+ get:
+ operationId: listThings
+ responses:
+ '200':
+ description: OK
+`), 0o644))
+
+ cmd := newGenerateCmd()
+ cmd.SetArgs([]string{
+ "--spec", specPath,
+ "--output", outputDir,
+ "--validate=false",
+ "--force",
+ })
+
+ err := cmd.Execute()
+ require.Error(t, err, "expected generate to refuse a spec without servers")
+ assert.Contains(t, err.Error(), specPath, "error must name the offending spec file")
+ assert.Contains(t, err.Error(), "no `servers:`", "error must explain that the spec declares no servers")
+ assert.NoDirExists(t, outputDir, "refusal must fire before any output is written")
+}
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 4e26b6ef..39043fd6 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -140,6 +140,9 @@ func newGenerateCmd() *cobra.Command {
if err != nil {
return &ExitError{Code: ExitSpecError, Err: fmt.Errorf("generating spec from docs: %w", err)}
}
+ if docSpec.BaseURLIsPlaceholder {
+ return &ExitError{Code: ExitSpecError, Err: fmt.Errorf("doc scrape of %s found no API base URL; the generator refuses to ship a CLI whose `doctor` would DNS-fail on every call. Re-run with docs that include the API host, or supply a real --base-url via crowd-sniff", docsURL)}
+ }
docYAML, err := yaml.Marshal(docSpec)
if err != nil {
return &ExitError{Code: ExitSpecError, Err: fmt.Errorf("marshaling doc spec: %w", err)}
@@ -291,6 +294,10 @@ func newGenerateCmd() *cobra.Command {
return &ExitError{Code: ExitSpecError, Err: fmt.Errorf("parsing spec %s: %w", specFile, err)}
}
+ if apiSpec.BaseURLIsPlaceholder {
+ return &ExitError{Code: ExitSpecError, Err: fmt.Errorf("spec %s declares no `servers:` block and no per-operation servers; the generator cannot resolve a real base URL and refuses to ship a CLI whose `doctor` would DNS-fail on every call. Add a `servers:` block with the real API host, or run via crowd-sniff with `--base-url` to supply one", specFile)}
+ }
+
specs = append(specs, apiSpec)
}
diff --git a/internal/docspec/docspec.go b/internal/docspec/docspec.go
index cd1e84dd..1f627396 100644
--- a/internal/docspec/docspec.go
+++ b/internal/docspec/docspec.go
@@ -38,7 +38,7 @@ func GenerateFromDocs(docsURL, apiName string) (*spec.APISpec, error) {
resources := groupByResource(endpoints)
auth := detectAuth(body)
- baseURL := detectBaseURL(body)
+ baseURL, baseURLIsPlaceholder := detectBaseURL(body)
params := extractParams(body)
// Attach extracted params as body params to POST/PUT/PATCH endpoints
@@ -53,11 +53,12 @@ func GenerateFromDocs(docsURL, apiName string) (*spec.APISpec, error) {
}
apiSpec := &spec.APISpec{
- Name: apiName,
- Description: fmt.Sprintf("CLI for %s (generated from docs)", apiName),
- Version: "1.0.0",
- BaseURL: baseURL,
- Auth: auth,
+ Name: apiName,
+ Description: fmt.Sprintf("CLI for %s (generated from docs)", apiName),
+ Version: "1.0.0",
+ BaseURL: baseURL,
+ BaseURLIsPlaceholder: baseURLIsPlaceholder,
+ Auth: auth,
Config: spec.ConfigSpec{
Format: "toml",
Path: fmt.Sprintf("~/.config/%s-pp-cli/config.toml", apiName),
@@ -259,19 +260,20 @@ func detectAuth(body string) spec.AuthConfig {
}
}
-func detectBaseURL(body string) string {
+// The second return is true when no URL matched and the caller is getting
+// the placeholder fallback — used by callers to refuse shipping.
+func detectBaseURL(body string) (string, bool) {
matches := baseURLRe.FindAllString(body, -1)
if len(matches) > 0 {
- // Return the first match, preferring longer ones (more specific)
best := matches[0]
for _, m := range matches[1:] {
if len(m) > len(best) {
best = m
}
}
- return best
+ return best, false
}
- return "https://api.example.com"
+ return spec.PlaceholderBaseURL, true
}
func extractParams(body string) []spec.Param {
@@ -353,7 +355,18 @@ func GenerateFromDocsLLM(docsURL, apiName string) (*spec.APISpec, error) {
yamlContent := ExtractYAML(response)
- return spec.ParseBytes([]byte(yamlContent))
+ parsed, err := spec.ParseBytes([]byte(yamlContent))
+ if err != nil {
+ return nil, err
+ }
+ // The prompt template (BuildDocSpecLLMPrompt) seeds base_url with the
+ // PlaceholderBaseURL; an LLM that can't find a real host often echoes
+ // it back. Set the flag here so callers refuse to ship — yaml:"-" on
+ // BaseURLIsPlaceholder means ParseBytes can't carry the flag itself.
+ if parsed.BaseURL == spec.PlaceholderBaseURL {
+ parsed.BaseURLIsPlaceholder = true
+ }
+ return parsed, nil
}
// BuildDocSpecLLMPrompt constructs a prompt that asks the LLM to read API docs
@@ -366,7 +379,7 @@ The YAML must follow this exact format:
name: %s
description: "CLI for %s"
version: "1.0.0"
-base_url: "https://api.example.com"
+base_url: "`+spec.PlaceholderBaseURL+`"
auth:
type: "bearer_token" # one of: api_key, oauth2, bearer_token, none
header: "Authorization"
diff --git a/internal/docspec/docspec_test.go b/internal/docspec/docspec_test.go
index a7a1e7c4..5fdab1b7 100644
--- a/internal/docspec/docspec_test.go
+++ b/internal/docspec/docspec_test.go
@@ -5,6 +5,7 @@ import (
"net/http/httptest"
"testing"
+ "github.com/mvanhorn/cli-printing-press/v4/internal/spec"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -88,8 +89,13 @@ func TestDetectAuth(t *testing.T) {
}
func TestDetectBaseURL(t *testing.T) {
- assert.Equal(t, "https://api.stripe.com", detectBaseURL("Base URL: https://api.stripe.com/v1"))
- assert.Equal(t, "https://api.example.com", detectBaseURL("No URL here"))
+ url, isPlaceholder := detectBaseURL("Base URL: https://api.stripe.com/v1")
+ assert.Equal(t, "https://api.stripe.com", url)
+ assert.False(t, isPlaceholder)
+
+ url, isPlaceholder = detectBaseURL("No URL here")
+ assert.Equal(t, spec.PlaceholderBaseURL, url)
+ assert.True(t, isPlaceholder, "missing URL must signal placeholder fallback")
}
func TestFirstSegment(t *testing.T) {
diff --git a/internal/graphql/parser.go b/internal/graphql/parser.go
index 21ecef77..63bb408e 100644
--- a/internal/graphql/parser.go
+++ b/internal/graphql/parser.go
@@ -71,8 +71,10 @@ func parseSDLContent(source, raw string) (*spec.APISpec, error) {
name := deriveAPIName(source)
baseURL, endpointPath, auth := knownGraphQLDefaults(name, source)
+ baseURLIsPlaceholder := false
if baseURL == "" {
- baseURL = "https://api.example.com"
+ baseURL = spec.PlaceholderBaseURL
+ baseURLIsPlaceholder = true
}
if endpointPath == "" {
// Default to the canonical "/graphql" path so existing GraphQL specs
@@ -90,11 +92,12 @@ func parseSDLContent(source, raw string) (*spec.APISpec, error) {
}
apiSpec := &spec.APISpec{
- Name: name,
- Description: "Generated from GraphQL schema",
- BaseURL: baseURL,
- GraphQLEndpointPath: endpointPath,
- Auth: auth,
+ Name: name,
+ Description: "Generated from GraphQL schema",
+ BaseURL: baseURL,
+ BaseURLIsPlaceholder: baseURLIsPlaceholder,
+ GraphQLEndpointPath: endpointPath,
+ Auth: auth,
Config: spec.ConfigSpec{
Format: "toml",
Path: fmt.Sprintf("~/.config/%s-pp-cli/config.toml", name),
diff --git a/internal/graphql/parser_test.go b/internal/graphql/parser_test.go
index 35498295..6023069c 100644
--- a/internal/graphql/parser_test.go
+++ b/internal/graphql/parser_test.go
@@ -237,3 +237,20 @@ func bodyParam(params []spec.Param, name string) spec.Param {
}
return spec.Param{}
}
+
+// TestParseSDLMarksFallbackBaseURLAsPlaceholder pins that an unknown
+// GraphQL source (no entry in knownGraphQLDefaults) sets
+// BaseURLIsPlaceholder so the generate command can refuse a shipping CLI
+// whose `doctor` would DNS-fail on every call.
+func TestParseSDLMarksFallbackBaseURLAsPlaceholder(t *testing.T) {
+ t.Parallel()
+
+ parsed, err := ParseSDLBytes("unknown-graphql-service.graphql", []byte(testSDL))
+ require.NoError(t, err)
+ assert.True(t, parsed.BaseURLIsPlaceholder, "unknown GraphQL source must mark BaseURL as placeholder")
+ assert.Equal(t, spec.PlaceholderBaseURL, parsed.BaseURL)
+
+ parsed, err = ParseSDLBytes("linear-schema.graphql", []byte(testSDL))
+ require.NoError(t, err)
+ assert.False(t, parsed.BaseURLIsPlaceholder, "known GraphQL source (linear) must not be marked placeholder")
+}
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 9d755a59..d3384cc9 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -360,9 +360,11 @@ func parseWithLocation(data []byte, lenient bool, location *url.URL) (*spec.APIS
basePath = perOpPath
}
}
+ baseURLIsPlaceholder := false
if baseURL == "" && basePath == "" {
warnf("no servers defined in spec; generated CLI will require base_url in config")
- baseURL = "https://api.example.com"
+ baseURL = spec.PlaceholderBaseURL
+ baseURLIsPlaceholder = true
}
auth := mapAuthWithDescriptionInference(doc, name, !metadata.explicitEmptySecuritySchemes)
@@ -390,6 +392,7 @@ func parseWithLocation(data []byte, lenient bool, location *url.URL) (*spec.APIS
Description: description,
Version: version,
BaseURL: baseURL,
+ BaseURLIsPlaceholder: baseURLIsPlaceholder,
BasePath: basePath,
WebsiteURL: websiteURL,
ProxyRoutes: proxyRoutes,
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 211b53b9..1e6974aa 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -5237,3 +5237,71 @@ paths:
games := findParsedEndpointByPath(t, parsed, "GET", "/games")
assert.Nil(t, games.Walker, "endpoint without x-pp-sync-walker must have nil Walker")
}
+
+// TestParseMarksFallbackBaseURLAsPlaceholder pins the contract used by the
+// generate command to refuse shipping specs that omit `servers:` entirely.
+// The parser falls back to a placeholder URL so in-memory test fixtures keep
+// parsing, but the returned spec must carry BaseURLIsPlaceholder=true so
+// downstream callers can detect-and-refuse instead of silently writing a
+// DNS-failing config.toml.
+func TestParseMarksFallbackBaseURLAsPlaceholder(t *testing.T) {
+ t.Parallel()
+
+ t.Run("no servers block sets the flag", func(t *testing.T) {
+ specYAML := `openapi: "3.0.3"
+info:
+ title: No Servers Test
+ version: "1.0"
+paths:
+ /thing:
+ get:
+ operationId: getThing
+ responses:
+ '200': {description: OK}
+`
+ parsed, err := Parse([]byte(specYAML))
+ require.NoError(t, err)
+ assert.True(t, parsed.BaseURLIsPlaceholder, "no-servers spec must mark BaseURL as placeholder")
+ assert.Equal(t, spec.PlaceholderBaseURL, parsed.BaseURL)
+ })
+
+ t.Run("explicit top-level servers leaves the flag false", func(t *testing.T) {
+ specYAML := `openapi: "3.0.3"
+info:
+ title: With Servers Test
+ version: "1.0"
+servers:
+ - url: https://api.real.com
+paths:
+ /thing:
+ get:
+ operationId: getThing
+ responses:
+ '200': {description: OK}
+`
+ parsed, err := Parse([]byte(specYAML))
+ require.NoError(t, err)
+ assert.False(t, parsed.BaseURLIsPlaceholder, "spec with real servers must not be marked placeholder")
+ assert.Equal(t, "https://api.real.com", parsed.BaseURL)
+ })
+
+ t.Run("per-operation servers leave the flag false", func(t *testing.T) {
+ specYAML := `openapi: "3.0.3"
+info:
+ title: Per-Op Only Test
+ version: "1.0"
+paths:
+ /thing:
+ get:
+ operationId: getThing
+ servers:
+ - url: https://api.real.com
+ responses:
+ '200': {description: OK}
+`
+ parsed, err := Parse([]byte(specYAML))
+ require.NoError(t, err)
+ assert.False(t, parsed.BaseURLIsPlaceholder, "spec with per-operation servers must not be marked placeholder")
+ assert.Equal(t, "https://api.real.com", parsed.BaseURL)
+ })
+}
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 468276cf..eca8ae37 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -61,6 +61,12 @@ const (
// - Astro: site-specific; declare per spec
const DefaultEmbeddedJSONScriptSelector = "script#__NEXT_DATA__"
+// PlaceholderBaseURL is the fake host parsers substitute when they cannot
+// resolve a real one. Shared across openapi/graphql/docspec so callers have
+// one canonical sentinel to compare against; the generate command refuses
+// to ship a CLI whose BaseURL is this value.
+const PlaceholderBaseURL = "https://api.example.com"
+
type APISpec struct {
Name string `yaml:"name" json:"name"`
// DisplayName is the human-readable brand name used in user-facing
@@ -76,6 +82,10 @@ type APISpec struct {
// info.title. Catalog enrichment may replace that fallback, but must not
// replace explicit display_name / x-display-name values.
DisplayNameDerivedFromTitle bool `yaml:"-" json:"-"`
+ // BaseURLIsPlaceholder is set by parsers that filled BaseURL with the
+ // PlaceholderBaseURL fallback because the source declared no real host.
+ // The generate command refuses to ship in that state — see internal/cli/root.go.
+ BaseURLIsPlaceholder bool `yaml:"-" json:"-"`
// Description describes the API itself ("REST API for ordering pizza").
// It flows into generated docs and SKILL.md but is intentionally NOT used
// as the printed CLI's --help text; that's CLIDescription's job.
← c5a5441d fix(cli): dedupe nested body-field flatten collisions with s
·
back to Cli Printing Press
·
docs(skills): document x-mcp as the OpenAPI form of the mcp: 9df8c5a2 →