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
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* @wherobots/wbc-crew
40 changes: 38 additions & 2 deletions internal/commands/builder.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package commands

import (
"bufio"
"bytes"
"encoding/json"
"fmt"
Expand All @@ -23,6 +24,8 @@ var persistentFlagNames = map[string]struct{}{
"q": {},
"dry-run": {},
"tree": {},
"yes": {},
"y": {},
}

type parameterBinding struct {
Expand Down Expand Up @@ -75,6 +78,7 @@ func BuildRootCommand(cfg config.Config, runtimeSpec *spec.RuntimeSpec) *cobra.C
root.PersistentFlags().StringArrayVarP(&flags.Query, "query", "q", nil, "query pair (key=value), repeatable")
root.PersistentFlags().BoolVar(&flags.DryRun, "dry-run", false, "print curl equivalent without executing request")
root.PersistentFlags().BoolVar(&flags.Tree, "tree", false, "print available command tree")
root.PersistentFlags().BoolVarP(&flags.Yes, "yes", "y", false, "skip confirmation prompt (for CI/scripts)")

root.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error {
return hints.Wrap(findOperationContext(operationByCommand, cmd), err)
Expand All @@ -95,6 +99,9 @@ func BuildRootCommand(cfg config.Config, runtimeSpec *spec.RuntimeSpec) *cobra.C
root.AddCommand(apiCmd)

for _, op := range runtimeSpec.Operations {
if op.Excluded {
continue
}
parent := ensureResourceHierarchy(apiCmd, resourceCommands, PathToResourceSegments(op.Path))
verb := uniqueVerbName(parent, ChooseVerb(op))
op.Verb = verb
Expand Down Expand Up @@ -142,6 +149,12 @@ func buildOperationCommand(
return printTree(cmd)
}

if !flags.DryRun && isWriteMethod(op.Method) && !flags.Yes {
if err := confirmAction(cmd, op); err != nil {
return err
}
}

parsedQuery, err := ParseQueryPairs(flags.Query)
if err != nil {
return hints.Wrap(op, err)
Expand Down Expand Up @@ -514,10 +527,14 @@ func reserveFlagName(used map[string]struct{}, preferred, prefix string) string
}

func buildOperationHelp(op *spec.Operation, pathBindings, queryBindings []parameterBinding, bodyFlags bodyBinding) string {
lines := []string{
lines := []string{}
if desc := strings.TrimSpace(op.Description); desc != "" {
lines = append(lines, desc, "")
}
lines = append(lines,
fmt.Sprintf("Operation: %s %s", op.Method, op.Path),
"Use named flags for operation inputs. Object and array values must be JSON strings.",
}
)

if len(pathBindings) > 0 {
lines = append(lines, "Path flags:")
Expand Down Expand Up @@ -684,3 +701,22 @@ func buildOperationShort(op *spec.Operation) string {
}
return fmt.Sprintf("%s operation", strings.ToUpper(op.Method))
}

func isWriteMethod(method string) bool {
switch strings.ToUpper(method) {
case "POST", "PUT", "DELETE", "PATCH":
return true
}
return false
}

func confirmAction(cmd *cobra.Command, op *spec.Operation) error {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s %s\nProceed? [y/N]: ", strings.ToUpper(op.Method), op.Path)
scanner := bufio.NewScanner(cmd.InOrStdin())
scanner.Scan()
answer := strings.TrimSpace(scanner.Text())
if strings.ToLower(answer) != "y" {
return fmt.Errorf("aborted")
}
return nil
}
111 changes: 110 additions & 1 deletion internal/commands/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,120 @@ func TestRootTreeOutput(t *testing.T) {
}

got := out.String()
if !strings.Contains(got, "wherobots\n") || !strings.Contains(got, " api\n") || !strings.Contains(got, " users\n") || !strings.Contains(got, " get\n") || !strings.Contains(got, " list\n") {
if !strings.Contains(got, "wherobots\n") ||
!strings.Contains(got, " api\n") ||
!strings.Contains(got, " users\n") ||
!strings.Contains(got, " get ") ||
!strings.Contains(got, " list ") {
t.Fatalf("tree output missing expected nodes:\n%s", got)
}
}

func TestTreeShowsSummaryOnLeafNodes(t *testing.T) {
t.Parallel()

cfg := config.Config{AppName: "wherobots", HTTPTimeout: time.Second}
runtimeSpec := &spec.RuntimeSpec{
BaseURL: "https://api.example.com",
Operations: []*spec.Operation{
{Method: "GET", Path: "/catalogs", Summary: "List all catalogs"},
{Method: "POST", Path: "/catalogs", Summary: "Create a catalog"},
},
}

root := BuildRootCommand(cfg, runtimeSpec)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"--tree"})

if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}

tree := out.String()
if !strings.Contains(tree, "List all catalogs") {
t.Errorf("tree should show summary 'List all catalogs':\n%s", tree)
}
if !strings.Contains(tree, "Create a catalog") {
t.Errorf("tree should show summary 'Create a catalog':\n%s", tree)
}
// Group node (catalogs) should not have a trailing summary
if strings.Contains(tree, "catalogs ") {
t.Errorf("group node 'catalogs' should not have a summary:\n%s", tree)
}
}

func TestDescriptionAppearsInHelp(t *testing.T) {
t.Parallel()

cfg := config.Config{AppName: "wherobots", HTTPTimeout: time.Second}
runtimeSpec := &spec.RuntimeSpec{
BaseURL: "https://api.example.com",
Operations: []*spec.Operation{
{
Method: "GET",
Path: "/catalogs",
Summary: "List all catalogs",
Description: "Returns all catalogs accessible to the current user, including managed and foreign catalogs.",
},
},
}

root := BuildRootCommand(cfg, runtimeSpec)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"api", "catalogs", "list", "--help"})

if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}

help := out.String()
if !strings.Contains(help, "Returns all catalogs accessible to the current user") {
t.Errorf("help should contain the OpenAPI description:\n%s", help)
}
}

func TestExcludedOperationsAbsentFromTree(t *testing.T) {
t.Parallel()

cfg := config.Config{AppName: "wherobots", HTTPTimeout: time.Second}
runtimeSpec := &spec.RuntimeSpec{
BaseURL: "https://api.example.com",
Operations: []*spec.Operation{
{Method: "GET", Path: "/catalogs", Summary: "List catalogs"},
{Method: "POST", Path: "/management/org", Summary: "Superuser action", Excluded: true},
{Method: "POST", Path: "/files/upload-url", Summary: "Legacy upload", Excluded: true},
},
}

root := BuildRootCommand(cfg, runtimeSpec)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"--tree"})

if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}

tree := out.String()

// Visible endpoint should be present
if !strings.Contains(tree, "catalogs") {
t.Errorf("tree should contain 'catalogs':\n%s", tree)
}
// Excluded endpoints must not appear
if strings.Contains(tree, "management") {
t.Errorf("tree must not contain excluded 'management':\n%s", tree)
}
if strings.Contains(tree, "files") {
t.Errorf("tree must not contain excluded 'files':\n%s", tree)
}
}

func TestDryRunOutputsCurl(t *testing.T) {
t.Parallel()

Expand Down
1 change: 1 addition & 0 deletions internal/commands/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type GlobalFlags struct {
Query []string
DryRun bool
Tree bool
Yes bool
}

type QueryPair struct {
Expand Down
81 changes: 0 additions & 81 deletions internal/commands/jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,6 @@ func addJobsCustomCommands(root *cobra.Command, cfg config.Config, runtimeSpec *
jobsCmd.AddCommand(runner.newCreateCommand())
jobsCmd.AddCommand(runner.newLogsCommand())
jobsCmd.AddCommand(runner.newListCommand())
jobsCmd.AddCommand(runner.newRunningAliasCommand())
jobsCmd.AddCommand(runner.newFailedAliasCommand())
jobsCmd.AddCommand(runner.newCompletedAliasCommand())
if runner.getRunMetrics != nil {
jobsCmd.AddCommand(runner.newMetricsCommand())
}
Expand Down Expand Up @@ -730,84 +727,6 @@ func writeRunListTable(out io.Writer, body []byte) error {
return tw.Flush()
}

func (r *jobsRunner) newRunningAliasCommand() *cobra.Command {
var (
name string
after string
limit int
region string
output string
)

cmd := &cobra.Command{
Use: "running",
Short: "Alias for job-runs list --status RUNNING",
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, _ []string) error {
return r.executeList(cmd, []string{"RUNNING"}, name, after, limit, region, output)
},
}
cmd.Flags().StringVar(&name, "name", "", "filter by name pattern")
cmd.Flags().StringVar(&after, "after", "", "filter runs created after ISO timestamp")
cmd.Flags().IntVarP(&limit, "limit", "l", defaultListLimit, "max results")
cmd.Flags().StringVar(&region, "region", "", "filter by region")
cmd.Flags().StringVar(&output, "output", outputText, "output format: text|json")
return cmd
}

func (r *jobsRunner) newFailedAliasCommand() *cobra.Command {
var (
name string
after string
limit int
region string
output string
)

cmd := &cobra.Command{
Use: "failed",
Short: "Alias for job-runs list --status FAILED",
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, _ []string) error {
return r.executeList(cmd, []string{"FAILED"}, name, after, limit, region, output)
},
}
cmd.Flags().StringVar(&name, "name", "", "filter by name pattern")
cmd.Flags().StringVar(&after, "after", "", "filter runs created after ISO timestamp")
cmd.Flags().IntVarP(&limit, "limit", "l", defaultListLimit, "max results")
cmd.Flags().StringVar(&region, "region", "", "filter by region")
cmd.Flags().StringVar(&output, "output", outputText, "output format: text|json")
return cmd
}

func (r *jobsRunner) newCompletedAliasCommand() *cobra.Command {
var (
name string
after string
limit int
region string
output string
)

cmd := &cobra.Command{
Use: "completed",
Short: "Alias for job-runs list --status COMPLETED",
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, _ []string) error {
return r.executeList(cmd, []string{"COMPLETED"}, name, after, limit, region, output)
},
}
cmd.Flags().StringVar(&name, "name", "", "filter by name pattern")
cmd.Flags().StringVar(&after, "after", "", "filter runs created after ISO timestamp")
cmd.Flags().IntVarP(&limit, "limit", "l", defaultListLimit, "max results")
cmd.Flags().StringVar(&region, "region", "", "filter by region")
cmd.Flags().StringVar(&output, "output", outputText, "output format: text|json")
return cmd
}

func (r *jobsRunner) newMetricsCommand() *cobra.Command {
var output string

Expand Down
15 changes: 11 additions & 4 deletions internal/commands/tree.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package commands

import (
"fmt"
"slices"
"strings"

Expand All @@ -14,15 +15,21 @@ func RenderTree(root *cobra.Command) string {
}

func renderNode(builder *strings.Builder, cmd *cobra.Command, depth int) {
builder.WriteString(strings.Repeat(" ", depth))
builder.WriteString(cmd.Name())
builder.WriteString("\n")

indent := strings.Repeat(" ", depth)
children := visibleChildren(cmd)
slices.SortFunc(children, func(a, b *cobra.Command) int {
return strings.Compare(a.Name(), b.Name())
})

// For leaf nodes (verb commands) show the summary; for grouping nodes just the name.
short := strings.TrimSpace(cmd.Short)
if short != "" && len(children) == 0 {
// Align: pad name to a minimum width relative to siblings when possible.
fmt.Fprintf(builder, "%s%-20s %s\n", indent, cmd.Name(), short)
} else {
fmt.Fprintf(builder, "%s%s\n", indent, cmd.Name())
}

for _, child := range children {
renderNode(builder, child, depth+1)
}
Expand Down
15 changes: 13 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package config

import (
"fmt"
"hash/fnv"
"net/url"
"os"
"path/filepath"
Expand Down Expand Up @@ -51,6 +52,8 @@ func Load() (Config, error) {
}

cacheDir := filepath.Join(cacheRoot, appName)
cacheKey := urlCacheKey(openAPIURL)

ttl, err := parseTTL(os.Getenv(envOpenAPICacheTTL))
if err != nil {
return Config{}, err
Expand All @@ -67,14 +70,22 @@ func Load() (Config, error) {
AppName: appName,
OpenAPIURL: openAPIURL,
APIKey: apiKey,
CachePath: filepath.Join(cacheDir, "spec.json"),
CacheMeta: filepath.Join(cacheDir, "spec.meta.json"),
CachePath: filepath.Join(cacheDir, "spec-"+cacheKey+".json"),
CacheMeta: filepath.Join(cacheDir, "spec-"+cacheKey+".meta.json"),
CacheTTL: ttl,
HTTPTimeout: timeout,
UploadPath: uploadPath,
}, nil
}

// urlCacheKey returns a short hex string derived from the URL so that
// different API endpoints get separate cache files.
func urlCacheKey(rawURL string) string {
h := fnv.New32a()
_, _ = h.Write([]byte(rawURL))
return fmt.Sprintf("%08x", h.Sum32())
}

func resolveOpenAPISpecURL(baseURL string) (string, error) {
raw := strings.TrimSpace(baseURL)
if raw == "" {
Expand Down
Loading