Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions pkg/lathe/catalog.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package lathe

import (
"encoding/json"
"fmt"
"strings"

"github.com/spf13/cobra"

"github.com/samzong/lathe/pkg/config"
"github.com/samzong/lathe/pkg/runtime"
)

func commandsCmd(m *config.Manifest) *cobra.Command {
var jsonOut bool
var includeHidden bool
cmd := &cobra.Command{
Use: "commands",
Short: "List generated commands",
RunE: func(cmd *cobra.Command, _ []string) error {
catalog := runtime.BuildCatalog(cmd.Root(), catalogOptions(m, includeHidden))
if jsonOut {
return writeJSON(cmd, catalog)
}
for i, entry := range catalog.Commands {
if i > 0 {
fmt.Fprintln(cmd.OutOrStdout())
}
writeCommandSummary(cmd, entry)
}
return nil
},
}
addJSONFlag(cmd, &jsonOut, "Emit catalog JSON")
cmd.Flags().BoolVar(&includeHidden, "include-hidden", false, "Include hidden commands")
Comment on lines +34 to +35
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid shadowing subcommand flags on commands

The parent commands command defines local --json/--include-hidden flags, and commands show defines flags with the same names again; when users place flags before the subcommand (for example, commands --json show ... or commands --include-hidden show ...), Cobra consumes the parent flag value but show reads its own separate variables, so the request is silently ignored. This makes valid-looking invocations return non-JSON output or fail to find hidden commands unexpectedly.

Useful? React with 👍 / 👎.

cmd.AddCommand(commandsShowCmd(m), commandsSchemaCmd())
return cmd
}

func commandsShowCmd(m *config.Manifest) *cobra.Command {
var jsonOut bool
var includeHidden bool
cmd := &cobra.Command{
Use: "show <path...>",
Short: "Show one generated command",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
entry, ok := runtime.FindCatalogCommand(cmd.Root(), args, catalogOptions(m, includeHidden))
if !ok {
return fmt.Errorf("generated command not found: %s", strings.Join(args, " "))
}
if jsonOut {
return writeJSON(cmd, entry)
}
writeCommandSummary(cmd, entry)
if len(entry.Flags) > 0 {
fmt.Fprintln(cmd.OutOrStdout())
fmt.Fprintln(cmd.OutOrStdout(), "Flags:")
for _, flag := range entry.Flags {
required := ""
if flag.Required {
required = " required"
}
fmt.Fprintf(cmd.OutOrStdout(), " --%s %s%s %s\n", flag.Flag, flag.Type, required, flag.Help)
}
}
return nil
},
}
addJSONFlag(cmd, &jsonOut, "Emit command JSON")
cmd.Flags().BoolVar(&includeHidden, "include-hidden", false, "Include hidden commands")
return cmd
}

func commandsSchemaCmd() *cobra.Command {
var jsonOut bool
cmd := &cobra.Command{
Use: "schema",
Short: "Print command catalog schema version",
RunE: func(cmd *cobra.Command, _ []string) error {
data := map[string]int{"catalog_schema_version": runtime.CatalogSchemaVersion}
if jsonOut {
return writeJSON(cmd, data)
}
fmt.Fprintf(cmd.OutOrStdout(), "catalog schema %d\n", runtime.CatalogSchemaVersion)
return nil
},
}
addJSONFlag(cmd, &jsonOut, "Emit schema JSON")
return cmd
}

func searchCmd(m *config.Manifest) *cobra.Command {
var jsonOut bool
var limit int
cmd := &cobra.Command{
Use: "search <query>",
Short: "Search generated commands",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
query := strings.Join(args, " ")
results := runtime.SearchCatalog(cmd.Root(), query, runtime.SearchOptions{
CatalogOptions: catalogOptions(m, false),
Limit: limit,
})
if jsonOut {
return writeJSON(cmd, results)
}
for i, result := range results {
if i > 0 {
fmt.Fprintln(cmd.OutOrStdout())
}
writeCommandSummary(cmd, result.Command)
}
return nil
},
}
addJSONFlag(cmd, &jsonOut, "Emit search results JSON")
cmd.Flags().IntVar(&limit, "limit", runtime.DefaultSearchLimit, "Maximum number of results")
return cmd
}

func catalogOptions(m *config.Manifest, includeHidden bool) runtime.CatalogOptions {
return runtime.CatalogOptions{
CLIName: m.CLI.Name,
CLIVersion: Version,
IncludeHidden: includeHidden,
}
}

func writeCommandSummary(cmd *cobra.Command, entry runtime.CatalogCommand) {
fmt.Fprintln(cmd.OutOrStdout(), strings.Join(entry.Path, " "))
if entry.Summary != "" {
fmt.Fprintf(cmd.OutOrStdout(), " %s\n", entry.Summary)
}
if entry.HTTP.Method != "" || entry.HTTP.PathTemplate != "" {
fmt.Fprintf(cmd.OutOrStdout(), " %s %s\n", entry.HTTP.Method, entry.HTTP.PathTemplate)
}
}

func writeJSON(cmd *cobra.Command, v any) error {
enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")
return enc.Encode(v)
}

func addJSONFlag(cmd *cobra.Command, target *bool, usage string) {
cmd.Flags().BoolVar(target, "json", false, usage)
}
130 changes: 130 additions & 0 deletions pkg/lathe/catalog_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package lathe

import (
"bytes"
"encoding/json"
"strings"
"testing"

"github.com/spf13/cobra"

"github.com/samzong/lathe/pkg/config"
"github.com/samzong/lathe/pkg/runtime"
)

func TestCommandsJSON_EmptyCatalog(t *testing.T) {
root := NewApp(testManifest())
out, err := execute(root, "commands", "--json")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, `"commands": []`) {
t.Fatalf("output missing empty commands array:\n%s", out)
}
var catalog runtime.Catalog
if err := json.Unmarshal([]byte(out), &catalog); err != nil {
t.Fatal(err)
}
if catalog.Commands == nil || len(catalog.Commands) != 0 {
t.Fatalf("commands = %#v", catalog.Commands)
}
}

func TestCommandsShowAndSearchJSON(t *testing.T) {
root := NewApp(testManifest())
runtime.Build(root, "demo", []runtime.CommandSpec{{
Group: "Users",
Use: "get-user",
Short: "Get a user",
OperationID: "getUser",
Method: "GET",
PathTpl: "/users/{id}",
Params: []runtime.ParamSpec{
{Name: "id", Flag: "id", In: runtime.InPath, GoType: "string", Required: true, Help: "User id"},
},
}})

out, err := execute(root, "commands", "show", "demo", "users", "get-user", "--json")
if err != nil {
t.Fatal(err)
}
var entry runtime.CatalogCommand
if err := json.Unmarshal([]byte(out), &entry); err != nil {
t.Fatal(err)
}
if strings.Join(entry.Path, " ") != "demo users get-user" || entry.Group != "Users" {
t.Fatalf("entry = %+v", entry)
}

out, err = execute(root, "search", "getUser", "--json")
if err != nil {
t.Fatal(err)
}
var results []runtime.SearchResult
if err := json.Unmarshal([]byte(out), &results); err != nil {
t.Fatal(err)
}
if len(results) != 1 || results[0].Command.Use != "get-user" {
t.Fatalf("results = %+v", results)
}
}

func TestCommandsShow_NotFound(t *testing.T) {
root := NewApp(testManifest())
_, err := execute(root, "commands", "show", "demo", "users", "missing")
if err == nil {
t.Fatal("expected error")
}
}

func TestCommandsSchemaJSON(t *testing.T) {
root := NewApp(testManifest())
out, err := execute(root, "commands", "schema", "--json")
if err != nil {
t.Fatal(err)
}
var data map[string]int
if err := json.Unmarshal([]byte(out), &data); err != nil {
t.Fatal(err)
}
if data["catalog_schema_version"] != runtime.CatalogSchemaVersion {
t.Fatalf("schema = %d", data["catalog_schema_version"])
}
}

func TestSearchExcludesHiddenCommands(t *testing.T) {
root := NewApp(testManifest())
runtime.Build(root, "demo", []runtime.CommandSpec{{
Group: "Users",
Use: "delete-user",
Short: "Delete a user",
Method: "DELETE",
PathTpl: "/users/{id}",
Hidden: true,
}})

out, err := execute(root, "search", "delete", "--json")
if err != nil {
t.Fatal(err)
}
var results []runtime.SearchResult
if err := json.Unmarshal([]byte(out), &results); err != nil {
t.Fatal(err)
}
if len(results) != 0 {
t.Fatalf("results = %+v", results)
}
}

func testManifest() *config.Manifest {
return &config.Manifest{CLI: config.CLIInfo{Name: "myctl", Short: "test cli", HostEnv: "MYCTL_HOST"}}
}

func execute(root *cobra.Command, args ...string) (string, error) {
var buf bytes.Buffer
root.SetOut(&buf)
root.SetErr(&buf)
root.SetArgs(args)
err := root.Execute()
return buf.String(), err
}
2 changes: 2 additions & 0 deletions pkg/lathe/lathe.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ func NewApp(m *config.Manifest) *cobra.Command {
authCmd := auth.NewCommand(m)
authCmd.GroupID = authGroupID
cmd.AddCommand(authCmd)
cmd.AddCommand(commandsCmd(m))
cmd.AddCommand(searchCmd(m))
Comment on lines +41 to +42
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Guard top-level command names from module collisions

Adding fixed root commands commands and search without any collision check can break downstream CLIs that already have modules with those names: generated modules are mounted onto root after NewApp, so a module named search/commands will conflict at the same command path and become ambiguous or unreachable. This is a regression for previously valid module names and should be prevented (for example by reserving/validating names at generation time or namespacing these introspection commands).

Useful? React with 👍 / 👎.

cmd.AddCommand(versionCmd())
return cmd
}
1 change: 1 addition & 0 deletions pkg/runtime/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func Build(root *cobra.Command, service string, specs []CommandSpec) {
svc.AddCommand(g)
}
c := buildCmd(s)
AttachCatalogCommand(c, service, s)
g.AddCommand(c)
}
root.AddCommand(svc)
Expand Down
Loading
Loading