[object Object]

← back to Cli Printing Press

feat(validate): quality gates + Clerk/Loops test specs + integration tests

4b510ed709ec93b893865250b1d98736e052422d · 2026-03-23 09:41:57 -0700 · Matt Van Horn

- validate.go: runs go mod tidy, go vet, go build, --help, version, doctor
  on generated projects. Enabled via --validate flag (default: true)
- clerk.yaml + loops.yaml: test spec fixtures for Clerk auth and Loops email APIs
- generator_test.go: integration tests generating + compiling all 3 specs
- All tests pass, all 3 generated CLIs compile and run

Co-Authored-By: OpenAI Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 4b510ed709ec93b893865250b1d98736e052422d
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date:   Mon Mar 23 09:41:57 2026 -0700

    feat(validate): quality gates + Clerk/Loops test specs + integration tests
    
    - validate.go: runs go mod tidy, go vet, go build, --help, version, doctor
      on generated projects. Enabled via --validate flag (default: true)
    - clerk.yaml + loops.yaml: test spec fixtures for Clerk auth and Loops email APIs
    - generator_test.go: integration tests generating + compiling all 3 specs
    - All tests pass, all 3 generated CLIs compile and run
    
    Co-Authored-By: OpenAI Codex <noreply@openai.com>
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 internal/cli/root.go                               |   7 +
 internal/generator/generator.go                    | 249 ++++++++++++++++++++-
 internal/generator/generator_test.go               |  74 ++++++
 internal/{ => generator}/templates/client.go.tmpl  |   0
 internal/{ => generator}/templates/command.go.tmpl |  19 +-
 internal/{ => generator}/templates/config.go.tmpl  |  14 +-
 internal/{ => generator}/templates/doctor.go.tmpl  |   1 -
 internal/{ => generator}/templates/go.mod.tmpl     |   0
 .../{ => generator}/templates/golangci.yml.tmpl    |   0
 .../{ => generator}/templates/goreleaser.yaml.tmpl |   0
 internal/{ => generator}/templates/helpers.go.tmpl |   9 +-
 internal/{ => generator}/templates/main.go.tmpl    |   0
 internal/{ => generator}/templates/makefile.tmpl   |   0
 internal/{ => generator}/templates/readme.md.tmpl  |   0
 internal/{ => generator}/templates/root.go.tmpl    |   1 -
 internal/{ => generator}/templates/types.go.tmpl   |   0
 internal/generator/validate.go                     | 123 ++++++++++
 testdata/clerk.yaml                                | 191 ++++++++++++++++
 testdata/loops.yaml                                | 144 ++++++++++++
 19 files changed, 804 insertions(+), 28 deletions(-)

diff --git a/internal/cli/root.go b/internal/cli/root.go
index 0b7c80e0..6d45eee1 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -31,6 +31,7 @@ func Execute() error {
 func newGenerateCmd() *cobra.Command {
 	var specFile string
 	var outputDir string
+	var validate bool
 
 	cmd := &cobra.Command{
 		Use:   "generate",
@@ -58,6 +59,11 @@ func newGenerateCmd() *cobra.Command {
 			if err := gen.Generate(); err != nil {
 				return fmt.Errorf("generating project: %w", err)
 			}
+			if validate {
+				if err := gen.Validate(); err != nil {
+					return fmt.Errorf("validating generated project: %w", err)
+				}
+			}
 
 			fmt.Fprintf(os.Stderr, "Generated %s-cli at %s\n", apiSpec.Name, absOut)
 			return nil
@@ -66,6 +72,7 @@ func newGenerateCmd() *cobra.Command {
 
 	cmd.Flags().StringVar(&specFile, "spec", "", "Path to API spec YAML file (required)")
 	cmd.Flags().StringVar(&outputDir, "output", "", "Output directory (default: <name>-cli)")
+	cmd.Flags().BoolVar(&validate, "validate", true, "Run quality gates on the generated project")
 
 	return cmd
 }
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 75aa5e7a..24a3b592 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -1,19 +1,264 @@
 package generator
 
 import (
+	"embed"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"text/template"
+	"unicode"
+
 	"github.com/mvanhorn/cli-printing-press/internal/spec"
 )
 
+//go:embed templates
+var templateFS embed.FS
+
 type Generator struct {
 	Spec      *spec.APISpec
 	OutputDir string
+	funcs     template.FuncMap
 }
 
 func New(s *spec.APISpec, outputDir string) *Generator {
-	return &Generator{Spec: s, OutputDir: outputDir}
+	g := &Generator{Spec: s, OutputDir: outputDir}
+	g.funcs = template.FuncMap{
+		"title":            strings.Title,
+		"lower":            strings.ToLower,
+		"upper":            strings.ToUpper,
+		"camel":            toCamel,
+		"snake":            toSnake,
+		"goType":           goType,
+		"cobraFlagFunc":    cobraFlagFunc,
+		"defaultVal":       defaultVal,
+		"zeroVal":          zeroVal,
+		"positionalArgs":   positionalArgs,
+		"configTag":        configTag,
+		"envVarField":      envVarField,
+		"envVarPlaceholder": envVarPlaceholder,
+		"add":              func(a, b int) int { return a + b },
+	}
+	return g
 }
 
 func (g *Generator) Generate() error {
-	// TODO: implement in Task 3+4
+	dirs := []string{
+		filepath.Join("cmd", g.Spec.Name+"-cli"),
+		filepath.Join("internal", "cli"),
+		filepath.Join("internal", "client"),
+		filepath.Join("internal", "config"),
+		filepath.Join("internal", "types"),
+	}
+
+	for _, d := range dirs {
+		if err := os.MkdirAll(filepath.Join(g.OutputDir, d), 0755); err != nil {
+			return fmt.Errorf("creating dir %s: %w", d, err)
+		}
+	}
+
+	// Generate single files
+	singleFiles := map[string]string{
+		"main.go.tmpl":           filepath.Join("cmd", g.Spec.Name+"-cli", "main.go"),
+		"root.go.tmpl":           filepath.Join("internal", "cli", "root.go"),
+		"helpers.go.tmpl":        filepath.Join("internal", "cli", "helpers.go"),
+		"doctor.go.tmpl":         filepath.Join("internal", "cli", "doctor.go"),
+		"config.go.tmpl":         filepath.Join("internal", "config", "config.go"),
+		"client.go.tmpl":         filepath.Join("internal", "client", "client.go"),
+		"types.go.tmpl":          filepath.Join("internal", "types", "types.go"),
+		"go.mod.tmpl":            "go.mod",
+		"goreleaser.yaml.tmpl":   ".goreleaser.yaml",
+		"golangci.yml.tmpl":      ".golangci.yml",
+		"makefile.tmpl":          "Makefile",
+		"readme.md.tmpl":         "README.md",
+	}
+
+	for tmplName, outPath := range singleFiles {
+		if err := g.renderTemplate(tmplName, outPath, g.Spec); err != nil {
+			return fmt.Errorf("rendering %s: %w", tmplName, err)
+		}
+	}
+
+	// Generate per-resource command files
+	for name, resource := range g.Spec.Resources {
+		data := struct {
+			ResourceName string
+			Resource     spec.Resource
+			*spec.APISpec
+		}{
+			ResourceName: name,
+			Resource:     resource,
+			APISpec:      g.Spec,
+		}
+		outPath := filepath.Join("internal", "cli", name+".go")
+		if err := g.renderTemplate("command.go.tmpl", outPath, data); err != nil {
+			return fmt.Errorf("rendering command %s: %w", name, err)
+		}
+	}
+
+	return nil
+}
+
+func (g *Generator) renderTemplate(tmplName, outPath string, data any) error {
+	content, err := templateFS.ReadFile(filepath.Join("templates", tmplName))
+	if err != nil {
+		return fmt.Errorf("reading template %s: %w", tmplName, err)
+	}
+
+	tmpl, err := template.New(tmplName).Funcs(g.funcs).Parse(string(content))
+	if err != nil {
+		return fmt.Errorf("parsing template %s: %w", tmplName, err)
+	}
+
+	fullPath := filepath.Join(g.OutputDir, outPath)
+	f, err := os.Create(fullPath)
+	if err != nil {
+		return fmt.Errorf("creating %s: %w", fullPath, err)
+	}
+	defer f.Close()
+
+	if err := tmpl.Execute(f, data); err != nil {
+		return fmt.Errorf("executing template %s: %w", tmplName, err)
+	}
+
 	return nil
 }
+
+// Template helper functions
+
+func toCamel(s string) string {
+	parts := strings.FieldsFunc(s, func(r rune) bool {
+		return r == '_' || r == '-' || r == ' '
+	})
+	for i, p := range parts {
+		if len(p) > 0 {
+			parts[i] = strings.ToUpper(p[:1]) + p[1:]
+		}
+	}
+	return strings.Join(parts, "")
+}
+
+func toSnake(s string) string {
+	var result strings.Builder
+	for i, r := range s {
+		if unicode.IsUpper(r) && i > 0 {
+			result.WriteRune('_')
+		}
+		result.WriteRune(unicode.ToLower(r))
+	}
+	return result.String()
+}
+
+func goType(t string) string {
+	switch t {
+	case "string":
+		return "string"
+	case "int":
+		return "int"
+	case "bool":
+		return "bool"
+	case "float":
+		return "float64"
+	default:
+		return "string"
+	}
+}
+
+func cobraFlagFunc(t string) string {
+	switch t {
+	case "string":
+		return "StringVar"
+	case "int":
+		return "IntVar"
+	case "bool":
+		return "BoolVar"
+	case "float":
+		return "Float64Var"
+	default:
+		return "StringVar"
+	}
+}
+
+func defaultVal(p spec.Param) string {
+	if p.Default != nil {
+		switch v := p.Default.(type) {
+		case string:
+			return fmt.Sprintf("%q", v)
+		case int:
+			return fmt.Sprintf("%d", v)
+		case float64:
+			if v == float64(int(v)) {
+				return fmt.Sprintf("%d", int(v))
+			}
+			return fmt.Sprintf("%f", v)
+		case bool:
+			return fmt.Sprintf("%t", v)
+		}
+	}
+	return zeroVal(p.Type)
+}
+
+func zeroVal(t string) string {
+	switch t {
+	case "string":
+		return `""`
+	case "int":
+		return "0"
+	case "bool":
+		return "false"
+	case "float":
+		return "0.0"
+	default:
+		return `""`
+	}
+}
+
+func positionalArgs(e spec.Endpoint) string {
+	var args []string
+	for _, p := range e.Params {
+		if p.Positional {
+			args = append(args, "<"+p.Name+">")
+		}
+	}
+	if len(args) > 0 {
+		return " " + strings.Join(args, " ")
+	}
+	return ""
+}
+
+func configTag(format string) string {
+	switch format {
+	case "toml":
+		return "toml"
+	case "yaml":
+		return "yaml"
+	default:
+		return "json"
+	}
+}
+
+func envVarField(envVar string) string {
+	// STYTCH_PROJECT_ID -> ProjectID
+	parts := strings.Split(strings.ToLower(envVar), "_")
+	var result string
+	for _, p := range parts {
+		if len(p) > 0 {
+			result += strings.ToUpper(p[:1]) + p[1:]
+		}
+	}
+	return result
+}
+
+func envVarPlaceholder(envVar string) string {
+	// STYTCH_PROJECT_ID -> project_id (the placeholder in the format string)
+	parts := strings.Split(envVar, "_")
+	if len(parts) <= 1 {
+		return strings.ToLower(envVar)
+	}
+	// Skip the first part (tool name prefix) and join the rest
+	var lower []string
+	for _, p := range parts[1:] {
+		lower = append(lower, strings.ToLower(p))
+	}
+	return strings.Join(lower, "_")
+}
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
new file mode 100644
index 00000000..d41c1380
--- /dev/null
+++ b/internal/generator/generator_test.go
@@ -0,0 +1,74 @@
+package generator
+
+import (
+	"os"
+	"os/exec"
+	"path/filepath"
+	"testing"
+
+	"github.com/mvanhorn/cli-printing-press/internal/spec"
+	"github.com/stretchr/testify/require"
+)
+
+func TestGenerateProjectsCompile(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name          string
+		specPath      string
+		expectedFiles int
+	}{
+		{name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 14},
+		{name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 15},
+		{name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 15},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			apiSpec, err := spec.Parse(tt.specPath)
+			require.NoError(t, err)
+
+			outputDir := filepath.Join(t.TempDir(), apiSpec.Name+"-cli")
+			gen := New(apiSpec, outputDir)
+			require.NoError(t, gen.Generate())
+
+			require.Equal(t, tt.expectedFiles, countFiles(t, outputDir))
+
+			runGoCommand(t, outputDir, "mod", "tidy")
+			runGoCommand(t, outputDir, "build", "./...")
+
+			binaryPath := filepath.Join(outputDir, apiSpec.Name+"-cli")
+			runGoCommand(t, outputDir, "build", "-o", binaryPath, "./cmd/"+apiSpec.Name+"-cli")
+
+			info, err := os.Stat(binaryPath)
+			require.NoError(t, err)
+			require.False(t, info.IsDir())
+			require.NotZero(t, info.Size())
+		})
+	}
+}
+
+func countFiles(t *testing.T, root string) int {
+	t.Helper()
+
+	total := 0
+	err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
+		require.NoError(t, err)
+		if !d.IsDir() {
+			total++
+		}
+		return nil
+	})
+	require.NoError(t, err)
+	return total
+}
+
+func runGoCommand(t *testing.T, dir string, args ...string) {
+	t.Helper()
+
+	cmd := exec.Command("go", args...)
+	cmd.Dir = dir
+	cmd.Env = append(os.Environ(), "GOCACHE="+filepath.Join(dir, ".cache", "go-build"))
+	output, err := cmd.CombinedOutput()
+	require.NoError(t, err, string(output))
+}
diff --git a/internal/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
similarity index 100%
rename from internal/templates/client.go.tmpl
rename to internal/generator/templates/client.go.tmpl
diff --git a/internal/templates/command.go.tmpl b/internal/generator/templates/command.go.tmpl
similarity index 85%
rename from internal/templates/command.go.tmpl
rename to internal/generator/templates/command.go.tmpl
index 892be853..6f33e99d 100644
--- a/internal/templates/command.go.tmpl
+++ b/internal/generator/templates/command.go.tmpl
@@ -3,10 +3,13 @@ package cli
 import (
 	"encoding/json"
 	"fmt"
+	"strings"
 
 	"github.com/spf13/cobra"
 )
 
+var _ = strings.ReplaceAll // ensure import
+
 func new{{title .ResourceName}}Cmd(flags *rootFlags) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "{{.ResourceName}}",
@@ -107,19 +110,3 @@ func new{{title $.ResourceName}}{{title $eName}}Cmd(flags *rootFlags) *cobra.Com
 	return cmd
 }
 {{end}}
-func replacePathParam(path, name, value string) string {
-	return fmt.Sprintf("%s", path[:0]) + // prevent unused import
-		fmt.Sprintf("%s", replaceAll(path, "{"+name+"}", value))
-}
-
-var replaceAll = func(s, old, new string) string {
-	import_strings := "strings"
-	_ = import_strings
-	result := s
-	for i := 0; i < len(result); i++ {
-		if i+len(old) <= len(result) && result[i:i+len(old)] == old {
-			result = result[:i] + new + result[i+len(old):]
-		}
-	}
-	return result
-}
diff --git a/internal/templates/config.go.tmpl b/internal/generator/templates/config.go.tmpl
similarity index 80%
rename from internal/templates/config.go.tmpl
rename to internal/generator/templates/config.go.tmpl
index e7166fb1..e6b54c0e 100644
--- a/internal/templates/config.go.tmpl
+++ b/internal/generator/templates/config.go.tmpl
@@ -12,12 +12,12 @@ import (
 )
 
 type Config struct {
-	BaseURL    string `{{configTag .Config.Format}} :"base_url"`
-	AuthHeader string `{{configTag .Config.Format}} :"auth_header"`
-	AuthSource string `{{configTag .Config.Format}} :"-"`
-	Path       string `{{configTag .Config.Format}} :"-"`
+	BaseURL        string `{{configTag .Config.Format}}:"base_url"`
+	AuthHeaderVal  string `{{configTag .Config.Format}}:"auth_header"`
+	AuthSource     string `{{configTag .Config.Format}}:"-"`
+	Path           string `{{configTag .Config.Format}}:"-"`
 {{- range .Auth.EnvVars}}
-	{{envVarField .}} string `{{configTag $.Config.Format}} :"{{snake .}}"`
+	{{envVarField .}} string `{{configTag $.Config.Format}}:"{{envVarPlaceholder .}}"`
 {{- end}}
 }
 
@@ -59,8 +59,8 @@ func Load(configPath string) (*Config, error) {
 }
 
 func (c *Config) AuthHeader() string {
-	if c.AuthHeader != "" {
-		return c.AuthHeader
+	if c.AuthHeaderVal != "" {
+		return c.AuthHeaderVal
 	}
 {{- if eq .Auth.Type "api_key"}}
 	format := "{{.Auth.Format}}"
diff --git a/internal/templates/doctor.go.tmpl b/internal/generator/templates/doctor.go.tmpl
similarity index 98%
rename from internal/templates/doctor.go.tmpl
rename to internal/generator/templates/doctor.go.tmpl
index b52b5853..8c0e124c 100644
--- a/internal/templates/doctor.go.tmpl
+++ b/internal/generator/templates/doctor.go.tmpl
@@ -1,7 +1,6 @@
 package cli
 
 import (
-	"encoding/json"
 	"fmt"
 	"net/http"
 	"time"
diff --git a/internal/templates/go.mod.tmpl b/internal/generator/templates/go.mod.tmpl
similarity index 100%
rename from internal/templates/go.mod.tmpl
rename to internal/generator/templates/go.mod.tmpl
diff --git a/internal/templates/golangci.yml.tmpl b/internal/generator/templates/golangci.yml.tmpl
similarity index 100%
rename from internal/templates/golangci.yml.tmpl
rename to internal/generator/templates/golangci.yml.tmpl
diff --git a/internal/templates/goreleaser.yaml.tmpl b/internal/generator/templates/goreleaser.yaml.tmpl
similarity index 100%
rename from internal/templates/goreleaser.yaml.tmpl
rename to internal/generator/templates/goreleaser.yaml.tmpl
diff --git a/internal/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
similarity index 83%
rename from internal/templates/helpers.go.tmpl
rename to internal/generator/templates/helpers.go.tmpl
index 3c0ed8b6..3fac6d69 100644
--- a/internal/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -1,6 +1,9 @@
 package cli
 
-import "errors"
+import (
+	"errors"
+	"strings"
+)
 
 var As = errors.As
 
@@ -35,3 +38,7 @@ func firstNonEmpty(items ...string) string {
 	}
 	return ""
 }
+
+func replacePathParam(path, name, value string) string {
+	return strings.ReplaceAll(path, "{"+name+"}", value)
+}
diff --git a/internal/templates/main.go.tmpl b/internal/generator/templates/main.go.tmpl
similarity index 100%
rename from internal/templates/main.go.tmpl
rename to internal/generator/templates/main.go.tmpl
diff --git a/internal/templates/makefile.tmpl b/internal/generator/templates/makefile.tmpl
similarity index 100%
rename from internal/templates/makefile.tmpl
rename to internal/generator/templates/makefile.tmpl
diff --git a/internal/templates/readme.md.tmpl b/internal/generator/templates/readme.md.tmpl
similarity index 100%
rename from internal/templates/readme.md.tmpl
rename to internal/generator/templates/readme.md.tmpl
diff --git a/internal/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
similarity index 99%
rename from internal/templates/root.go.tmpl
rename to internal/generator/templates/root.go.tmpl
index 83f41562..20086dea 100644
--- a/internal/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -3,7 +3,6 @@ package cli
 import (
 	"encoding/json"
 	"fmt"
-	"os"
 	"text/tabwriter"
 	"time"
 
diff --git a/internal/templates/types.go.tmpl b/internal/generator/templates/types.go.tmpl
similarity index 100%
rename from internal/templates/types.go.tmpl
rename to internal/generator/templates/types.go.tmpl
diff --git a/internal/generator/validate.go b/internal/generator/validate.go
new file mode 100644
index 00000000..2080e0d0
--- /dev/null
+++ b/internal/generator/validate.go
@@ -0,0 +1,123 @@
+package generator
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"time"
+)
+
+type validationGate struct {
+	name string
+	run  func() error
+}
+
+func (g *Generator) Validate() error {
+	binPath := filepath.Join(g.OutputDir, g.Spec.Name+"-cli-validation")
+
+	gates := []validationGate{
+		{
+			name: "go mod tidy",
+			run: func() error {
+				_, err := runCommand(g.OutputDir, 2*time.Minute, "go", "mod", "tidy")
+				return err
+			},
+		},
+		{
+			name: "go vet ./...",
+			run: func() error {
+				_, err := runCommand(g.OutputDir, 2*time.Minute, "go", "vet", "./...")
+				return err
+			},
+		},
+		{
+			name: "go build ./...",
+			run: func() error {
+				_, err := runCommand(g.OutputDir, 2*time.Minute, "go", "build", "./...")
+				return err
+			},
+		},
+		{
+			name: "build runnable binary",
+			run: func() error {
+				_, err := runCommand(g.OutputDir, 2*time.Minute, "go", "build", "-o", binPath, "./cmd/"+g.Spec.Name+"-cli")
+				return err
+			},
+		},
+		{
+			name: g.Spec.Name + "-cli --help",
+			run: func() error {
+				return validateCommandOutput(g.OutputDir, 15*time.Second, binPath, "--help")
+			},
+		},
+		{
+			name: g.Spec.Name + "-cli version",
+			run: func() error {
+				return validateCommandOutput(g.OutputDir, 15*time.Second, binPath, "version")
+			},
+		},
+		{
+			name: g.Spec.Name + "-cli doctor",
+			run: func() error {
+				return validateCommandOutput(g.OutputDir, 15*time.Second, binPath, "doctor")
+			},
+		},
+	}
+
+	for _, gate := range gates {
+		if err := gate.run(); err != nil {
+			fmt.Fprintf(os.Stderr, "FAIL %s\n", gate.name)
+			return fmt.Errorf("gate %q failed: %w", gate.name, err)
+		}
+		fmt.Fprintf(os.Stderr, "PASS %s\n", gate.name)
+	}
+
+	return nil
+}
+
+func validateCommandOutput(dir string, timeout time.Duration, name string, args ...string) error {
+	output, err := runCommand(dir, timeout, name, args...)
+	if err != nil {
+		return err
+	}
+	if strings.TrimSpace(output) == "" {
+		return fmt.Errorf("%s produced no output", strings.Join(append([]string{name}, args...), " "))
+	}
+	return nil
+}
+
+func runCommand(dir string, timeout time.Duration, name string, args ...string) (string, error) {
+	ctx := context.Background()
+	if timeout > 0 {
+		var cancel context.CancelFunc
+		ctx, cancel = context.WithTimeout(ctx, timeout)
+		defer cancel()
+	}
+
+	cmd := exec.CommandContext(ctx, name, args...)
+	cmd.Dir = dir
+	cmd.Env = append(os.Environ(), "GOCACHE="+filepath.Join(dir, ".cache", "go-build"))
+
+	var stdout bytes.Buffer
+	var stderr bytes.Buffer
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
+
+	err := cmd.Run()
+	output := strings.TrimSpace(strings.Join([]string{stdout.String(), stderr.String()}, "\n"))
+	if err != nil {
+		if ctx.Err() == context.DeadlineExceeded {
+			err = fmt.Errorf("timed out after %s", timeout)
+		}
+		if output == "" {
+			return "", err
+		}
+		return output, fmt.Errorf("%w\n%s", err, output)
+	}
+
+	return output, nil
+}
diff --git a/testdata/clerk.yaml b/testdata/clerk.yaml
new file mode 100644
index 00000000..0f390ef1
--- /dev/null
+++ b/testdata/clerk.yaml
@@ -0,0 +1,191 @@
+name: clerk
+description: "Clerk authentication API CLI"
+version: "0.1.0"
+base_url: "https://api.clerk.com/v1"
+
+auth:
+  type: bearer_token
+  header: "Authorization"
+  env_vars:
+    - CLERK_SECRET_KEY
+
+config:
+  format: toml
+  path: "~/.config/clerk-cli/config.toml"
+
+resources:
+  users:
+    description: "Manage Clerk users"
+    endpoints:
+      list:
+        method: GET
+        path: "/users"
+        description: "List users"
+        params:
+          - name: limit
+            type: int
+            description: "Max users to return"
+          - name: offset
+            type: int
+            description: "Pagination offset"
+          - name: order_by
+            type: string
+            description: "Sort field"
+        response:
+          type: array
+          item: User
+
+      get:
+        method: GET
+        path: "/users/{user_id}"
+        description: "Get a user"
+        params:
+          - name: user_id
+            type: string
+            required: true
+            positional: true
+            description: "User ID"
+        response:
+          type: object
+          item: User
+
+      create:
+        method: POST
+        path: "/users"
+        description: "Create a user"
+        body:
+          - name: email_address
+            type: string
+            required: true
+            description: "Primary email address"
+          - name: first_name
+            type: string
+            description: "First name"
+          - name: last_name
+            type: string
+            description: "Last name"
+        response:
+          type: object
+          item: User
+
+      delete:
+        method: DELETE
+        path: "/users/{user_id}"
+        description: "Delete a user"
+        params:
+          - name: user_id
+            type: string
+            required: true
+            positional: true
+            description: "User ID"
+
+  organizations:
+    description: "Manage Clerk organizations"
+    endpoints:
+      list:
+        method: GET
+        path: "/organizations"
+        description: "List organizations"
+        params:
+          - name: limit
+            type: int
+            description: "Max organizations to return"
+        response:
+          type: array
+          item: Organization
+
+      get:
+        method: GET
+        path: "/organizations/{organization_id}"
+        description: "Get an organization"
+        params:
+          - name: organization_id
+            type: string
+            required: true
+            positional: true
+            description: "Organization ID"
+        response:
+          type: object
+          item: Organization
+
+      create:
+        method: POST
+        path: "/organizations"
+        description: "Create an organization"
+        body:
+          - name: name
+            type: string
+            required: true
+            description: "Organization name"
+          - name: slug
+            type: string
+            required: true
+            description: "Organization slug"
+        response:
+          type: object
+          item: Organization
+
+  sessions:
+    description: "Manage Clerk sessions"
+    endpoints:
+      list:
+        method: GET
+        path: "/sessions"
+        description: "List sessions"
+        params:
+          - name: user_id
+            type: string
+            description: "User ID"
+        response:
+          type: array
+          item: Session
+
+      revoke:
+        method: POST
+        path: "/sessions/{session_id}/revoke"
+        description: "Revoke a session"
+        params:
+          - name: session_id
+            type: string
+            required: true
+            positional: true
+            description: "Session ID"
+        response:
+          type: object
+          item: Session
+
+types:
+  User:
+    fields:
+      - name: id
+        type: string
+      - name: email_address
+        type: string
+      - name: first_name
+        type: string
+      - name: last_name
+        type: string
+      - name: created_at
+        type: string
+
+  Organization:
+    fields:
+      - name: id
+        type: string
+      - name: name
+        type: string
+      - name: slug
+        type: string
+      - name: created_at
+        type: string
+
+  Session:
+    fields:
+      - name: id
+        type: string
+      - name: user_id
+        type: string
+      - name: status
+        type: string
+      - name: expire_at
+        type: string
diff --git a/testdata/loops.yaml b/testdata/loops.yaml
new file mode 100644
index 00000000..13f5555e
--- /dev/null
+++ b/testdata/loops.yaml
@@ -0,0 +1,144 @@
+name: loops
+description: "Loops email API CLI"
+version: "0.1.0"
+base_url: "https://app.loops.so/api/v1"
+
+auth:
+  type: bearer_token
+  header: "Authorization"
+  env_vars:
+    - LOOPS_API_KEY
+
+config:
+  format: toml
+  path: "~/.config/loops-cli/config.toml"
+
+resources:
+  contacts:
+    description: "Manage Loops contacts"
+    endpoints:
+      list:
+        method: GET
+        path: "/contacts"
+        description: "List contacts"
+        params:
+          - name: limit
+            type: int
+            description: "Max contacts to return"
+        response:
+          type: array
+          item: Contact
+
+      get:
+        method: GET
+        path: "/contacts/{contact_id}"
+        description: "Get a contact"
+        params:
+          - name: contact_id
+            type: string
+            required: true
+            positional: true
+            description: "Contact ID"
+        response:
+          type: object
+          item: Contact
+
+      create:
+        method: POST
+        path: "/contacts"
+        description: "Create a contact"
+        body:
+          - name: email
+            type: string
+            required: true
+            description: "Email address"
+          - name: first_name
+            type: string
+            description: "First name"
+          - name: last_name
+            type: string
+            description: "Last name"
+        response:
+          type: object
+          item: Contact
+
+      delete:
+        method: DELETE
+        path: "/contacts/{contact_id}"
+        description: "Delete a contact"
+        params:
+          - name: contact_id
+            type: string
+            required: true
+            positional: true
+            description: "Contact ID"
+
+  events:
+    description: "Send Loops events"
+    endpoints:
+      send:
+        method: POST
+        path: "/events"
+        description: "Send an event"
+        body:
+          - name: email
+            type: string
+            required: true
+            description: "Recipient email"
+          - name: event_name
+            type: string
+            required: true
+            description: "Event name"
+          - name: event_properties
+            type: string
+            description: "Serialized event properties"
+        response:
+          type: object
+          item: Event
+
+  emails:
+    description: "Send Loops transactional emails"
+    endpoints:
+      send_transactional:
+        method: POST
+        path: "/transactional"
+        description: "Send a transactional email"
+        body:
+          - name: email
+            type: string
+            required: true
+            description: "Recipient email"
+          - name: transactional_id
+            type: string
+            required: true
+            description: "Transactional template ID"
+          - name: data_variables
+            type: string
+            description: "Serialized template data"
+        response:
+          type: object
+          item: TransactionalEmail
+
+types:
+  Contact:
+    fields:
+      - name: id
+        type: string
+      - name: email
+        type: string
+      - name: first_name
+        type: string
+      - name: last_name
+        type: string
+      - name: source
+        type: string
+
+  Event:
+    fields:
+      - name: success
+        type: bool
+
+  TransactionalEmail:
+    fields:
+      - name: success
+        type: bool

← a78f0979 feat(templates): Go templates for all generated CLI files  ·  back to Cli Printing Press  ·  docs: add plan files to repo 87526922 →