← 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
M internal/cli/root.goM internal/generator/generator.goA internal/generator/generator_test.goR100 internal/templates/client.go.tmpl internal/generator/templates/client.go.tmplR085 internal/templates/command.go.tmpl internal/generator/templates/command.go.tmplR080 internal/templates/config.go.tmpl internal/generator/templates/config.go.tmplR098 internal/templates/doctor.go.tmpl internal/generator/templates/doctor.go.tmplR100 internal/templates/go.mod.tmpl internal/generator/templates/go.mod.tmplR100 internal/templates/golangci.yml.tmpl internal/generator/templates/golangci.yml.tmplR100 internal/templates/goreleaser.yaml.tmpl internal/generator/templates/goreleaser.yaml.tmplR083 internal/templates/helpers.go.tmpl internal/generator/templates/helpers.go.tmplR100 internal/templates/main.go.tmpl internal/generator/templates/main.go.tmplR100 internal/templates/makefile.tmpl internal/generator/templates/makefile.tmplR100 internal/templates/readme.md.tmpl internal/generator/templates/readme.md.tmplR099 internal/templates/root.go.tmpl internal/generator/templates/root.go.tmplR100 internal/templates/types.go.tmpl internal/generator/templates/types.go.tmplA internal/generator/validate.goA testdata/clerk.yamlA testdata/loops.yaml
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 →