Skip to content

Commit e2a4b76

Browse files
authored
Merge pull request #13 from wherobots/feat/cli-confirmation-exclusions
feat: CLI exclusions, write confirmations, and job alias removal
2 parents 697f8ec + b92c6f8 commit e2a4b76

11 files changed

Lines changed: 277 additions & 92 deletions

File tree

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* @wherobots/wbc-crew

internal/commands/builder.go

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package commands
22

33
import (
4+
"bufio"
45
"bytes"
56
"encoding/json"
67
"fmt"
@@ -23,6 +24,8 @@ var persistentFlagNames = map[string]struct{}{
2324
"q": {},
2425
"dry-run": {},
2526
"tree": {},
27+
"yes": {},
28+
"y": {},
2629
}
2730

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

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

97101
for _, op := range runtimeSpec.Operations {
102+
if op.Excluded {
103+
continue
104+
}
98105
parent := ensureResourceHierarchy(apiCmd, resourceCommands, PathToResourceSegments(op.Path))
99106
verb := uniqueVerbName(parent, ChooseVerb(op))
100107
op.Verb = verb
@@ -142,6 +149,12 @@ func buildOperationCommand(
142149
return printTree(cmd)
143150
}
144151

152+
if !flags.DryRun && isWriteMethod(op.Method) && !flags.Yes {
153+
if err := confirmAction(cmd, op); err != nil {
154+
return err
155+
}
156+
}
157+
145158
parsedQuery, err := ParseQueryPairs(flags.Query)
146159
if err != nil {
147160
return hints.Wrap(op, err)
@@ -514,10 +527,14 @@ func reserveFlagName(used map[string]struct{}, preferred, prefix string) string
514527
}
515528

516529
func buildOperationHelp(op *spec.Operation, pathBindings, queryBindings []parameterBinding, bodyFlags bodyBinding) string {
517-
lines := []string{
530+
lines := []string{}
531+
if desc := strings.TrimSpace(op.Description); desc != "" {
532+
lines = append(lines, desc, "")
533+
}
534+
lines = append(lines,
518535
fmt.Sprintf("Operation: %s %s", op.Method, op.Path),
519536
"Use named flags for operation inputs. Object and array values must be JSON strings.",
520-
}
537+
)
521538

522539
if len(pathBindings) > 0 {
523540
lines = append(lines, "Path flags:")
@@ -684,3 +701,22 @@ func buildOperationShort(op *spec.Operation) string {
684701
}
685702
return fmt.Sprintf("%s operation", strings.ToUpper(op.Method))
686703
}
704+
705+
func isWriteMethod(method string) bool {
706+
switch strings.ToUpper(method) {
707+
case "POST", "PUT", "DELETE", "PATCH":
708+
return true
709+
}
710+
return false
711+
}
712+
713+
func confirmAction(cmd *cobra.Command, op *spec.Operation) error {
714+
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s %s\nProceed? [y/N]: ", strings.ToUpper(op.Method), op.Path)
715+
scanner := bufio.NewScanner(cmd.InOrStdin())
716+
scanner.Scan()
717+
answer := strings.TrimSpace(scanner.Text())
718+
if strings.ToLower(answer) != "y" {
719+
return fmt.Errorf("aborted")
720+
}
721+
return nil
722+
}

internal/commands/builder_test.go

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,120 @@ func TestRootTreeOutput(t *testing.T) {
3333
}
3434

3535
got := out.String()
36-
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") {
36+
if !strings.Contains(got, "wherobots\n") ||
37+
!strings.Contains(got, " api\n") ||
38+
!strings.Contains(got, " users\n") ||
39+
!strings.Contains(got, " get ") ||
40+
!strings.Contains(got, " list ") {
3741
t.Fatalf("tree output missing expected nodes:\n%s", got)
3842
}
3943
}
4044

45+
func TestTreeShowsSummaryOnLeafNodes(t *testing.T) {
46+
t.Parallel()
47+
48+
cfg := config.Config{AppName: "wherobots", HTTPTimeout: time.Second}
49+
runtimeSpec := &spec.RuntimeSpec{
50+
BaseURL: "https://api.example.com",
51+
Operations: []*spec.Operation{
52+
{Method: "GET", Path: "/catalogs", Summary: "List all catalogs"},
53+
{Method: "POST", Path: "/catalogs", Summary: "Create a catalog"},
54+
},
55+
}
56+
57+
root := BuildRootCommand(cfg, runtimeSpec)
58+
var out bytes.Buffer
59+
root.SetOut(&out)
60+
root.SetErr(&out)
61+
root.SetArgs([]string{"--tree"})
62+
63+
if err := root.Execute(); err != nil {
64+
t.Fatalf("Execute() error = %v", err)
65+
}
66+
67+
tree := out.String()
68+
if !strings.Contains(tree, "List all catalogs") {
69+
t.Errorf("tree should show summary 'List all catalogs':\n%s", tree)
70+
}
71+
if !strings.Contains(tree, "Create a catalog") {
72+
t.Errorf("tree should show summary 'Create a catalog':\n%s", tree)
73+
}
74+
// Group node (catalogs) should not have a trailing summary
75+
if strings.Contains(tree, "catalogs ") {
76+
t.Errorf("group node 'catalogs' should not have a summary:\n%s", tree)
77+
}
78+
}
79+
80+
func TestDescriptionAppearsInHelp(t *testing.T) {
81+
t.Parallel()
82+
83+
cfg := config.Config{AppName: "wherobots", HTTPTimeout: time.Second}
84+
runtimeSpec := &spec.RuntimeSpec{
85+
BaseURL: "https://api.example.com",
86+
Operations: []*spec.Operation{
87+
{
88+
Method: "GET",
89+
Path: "/catalogs",
90+
Summary: "List all catalogs",
91+
Description: "Returns all catalogs accessible to the current user, including managed and foreign catalogs.",
92+
},
93+
},
94+
}
95+
96+
root := BuildRootCommand(cfg, runtimeSpec)
97+
var out bytes.Buffer
98+
root.SetOut(&out)
99+
root.SetErr(&out)
100+
root.SetArgs([]string{"api", "catalogs", "list", "--help"})
101+
102+
if err := root.Execute(); err != nil {
103+
t.Fatalf("Execute() error = %v", err)
104+
}
105+
106+
help := out.String()
107+
if !strings.Contains(help, "Returns all catalogs accessible to the current user") {
108+
t.Errorf("help should contain the OpenAPI description:\n%s", help)
109+
}
110+
}
111+
112+
func TestExcludedOperationsAbsentFromTree(t *testing.T) {
113+
t.Parallel()
114+
115+
cfg := config.Config{AppName: "wherobots", HTTPTimeout: time.Second}
116+
runtimeSpec := &spec.RuntimeSpec{
117+
BaseURL: "https://api.example.com",
118+
Operations: []*spec.Operation{
119+
{Method: "GET", Path: "/catalogs", Summary: "List catalogs"},
120+
{Method: "POST", Path: "/management/org", Summary: "Superuser action", Excluded: true},
121+
{Method: "POST", Path: "/files/upload-url", Summary: "Legacy upload", Excluded: true},
122+
},
123+
}
124+
125+
root := BuildRootCommand(cfg, runtimeSpec)
126+
var out bytes.Buffer
127+
root.SetOut(&out)
128+
root.SetErr(&out)
129+
root.SetArgs([]string{"--tree"})
130+
131+
if err := root.Execute(); err != nil {
132+
t.Fatalf("Execute() error = %v", err)
133+
}
134+
135+
tree := out.String()
136+
137+
// Visible endpoint should be present
138+
if !strings.Contains(tree, "catalogs") {
139+
t.Errorf("tree should contain 'catalogs':\n%s", tree)
140+
}
141+
// Excluded endpoints must not appear
142+
if strings.Contains(tree, "management") {
143+
t.Errorf("tree must not contain excluded 'management':\n%s", tree)
144+
}
145+
if strings.Contains(tree, "files") {
146+
t.Errorf("tree must not contain excluded 'files':\n%s", tree)
147+
}
148+
}
149+
41150
func TestDryRunOutputsCurl(t *testing.T) {
42151
t.Parallel()
43152

internal/commands/flags.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ type GlobalFlags struct {
1010
Query []string
1111
DryRun bool
1212
Tree bool
13+
Yes bool
1314
}
1415

1516
type QueryPair struct {

internal/commands/jobs.go

Lines changed: 0 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,6 @@ func addJobsCustomCommands(root *cobra.Command, cfg config.Config, runtimeSpec *
7474
jobsCmd.AddCommand(runner.newCreateCommand())
7575
jobsCmd.AddCommand(runner.newLogsCommand())
7676
jobsCmd.AddCommand(runner.newListCommand())
77-
jobsCmd.AddCommand(runner.newRunningAliasCommand())
78-
jobsCmd.AddCommand(runner.newFailedAliasCommand())
79-
jobsCmd.AddCommand(runner.newCompletedAliasCommand())
8077
if runner.getRunMetrics != nil {
8178
jobsCmd.AddCommand(runner.newMetricsCommand())
8279
}
@@ -730,84 +727,6 @@ func writeRunListTable(out io.Writer, body []byte) error {
730727
return tw.Flush()
731728
}
732729

733-
func (r *jobsRunner) newRunningAliasCommand() *cobra.Command {
734-
var (
735-
name string
736-
after string
737-
limit int
738-
region string
739-
output string
740-
)
741-
742-
cmd := &cobra.Command{
743-
Use: "running",
744-
Short: "Alias for job-runs list --status RUNNING",
745-
SilenceUsage: true,
746-
SilenceErrors: true,
747-
RunE: func(cmd *cobra.Command, _ []string) error {
748-
return r.executeList(cmd, []string{"RUNNING"}, name, after, limit, region, output)
749-
},
750-
}
751-
cmd.Flags().StringVar(&name, "name", "", "filter by name pattern")
752-
cmd.Flags().StringVar(&after, "after", "", "filter runs created after ISO timestamp")
753-
cmd.Flags().IntVarP(&limit, "limit", "l", defaultListLimit, "max results")
754-
cmd.Flags().StringVar(&region, "region", "", "filter by region")
755-
cmd.Flags().StringVar(&output, "output", outputText, "output format: text|json")
756-
return cmd
757-
}
758-
759-
func (r *jobsRunner) newFailedAliasCommand() *cobra.Command {
760-
var (
761-
name string
762-
after string
763-
limit int
764-
region string
765-
output string
766-
)
767-
768-
cmd := &cobra.Command{
769-
Use: "failed",
770-
Short: "Alias for job-runs list --status FAILED",
771-
SilenceUsage: true,
772-
SilenceErrors: true,
773-
RunE: func(cmd *cobra.Command, _ []string) error {
774-
return r.executeList(cmd, []string{"FAILED"}, name, after, limit, region, output)
775-
},
776-
}
777-
cmd.Flags().StringVar(&name, "name", "", "filter by name pattern")
778-
cmd.Flags().StringVar(&after, "after", "", "filter runs created after ISO timestamp")
779-
cmd.Flags().IntVarP(&limit, "limit", "l", defaultListLimit, "max results")
780-
cmd.Flags().StringVar(&region, "region", "", "filter by region")
781-
cmd.Flags().StringVar(&output, "output", outputText, "output format: text|json")
782-
return cmd
783-
}
784-
785-
func (r *jobsRunner) newCompletedAliasCommand() *cobra.Command {
786-
var (
787-
name string
788-
after string
789-
limit int
790-
region string
791-
output string
792-
)
793-
794-
cmd := &cobra.Command{
795-
Use: "completed",
796-
Short: "Alias for job-runs list --status COMPLETED",
797-
SilenceUsage: true,
798-
SilenceErrors: true,
799-
RunE: func(cmd *cobra.Command, _ []string) error {
800-
return r.executeList(cmd, []string{"COMPLETED"}, name, after, limit, region, output)
801-
},
802-
}
803-
cmd.Flags().StringVar(&name, "name", "", "filter by name pattern")
804-
cmd.Flags().StringVar(&after, "after", "", "filter runs created after ISO timestamp")
805-
cmd.Flags().IntVarP(&limit, "limit", "l", defaultListLimit, "max results")
806-
cmd.Flags().StringVar(&region, "region", "", "filter by region")
807-
cmd.Flags().StringVar(&output, "output", outputText, "output format: text|json")
808-
return cmd
809-
}
810-
811730
func (r *jobsRunner) newMetricsCommand() *cobra.Command {
812731
var output string
813732

internal/commands/tree.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package commands
22

33
import (
4+
"fmt"
45
"slices"
56
"strings"
67

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

1617
func renderNode(builder *strings.Builder, cmd *cobra.Command, depth int) {
17-
builder.WriteString(strings.Repeat(" ", depth))
18-
builder.WriteString(cmd.Name())
19-
builder.WriteString("\n")
20-
18+
indent := strings.Repeat(" ", depth)
2119
children := visibleChildren(cmd)
2220
slices.SortFunc(children, func(a, b *cobra.Command) int {
2321
return strings.Compare(a.Name(), b.Name())
2422
})
2523

24+
// For leaf nodes (verb commands) show the summary; for grouping nodes just the name.
25+
short := strings.TrimSpace(cmd.Short)
26+
if short != "" && len(children) == 0 {
27+
// Align: pad name to a minimum width relative to siblings when possible.
28+
fmt.Fprintf(builder, "%s%-20s %s\n", indent, cmd.Name(), short)
29+
} else {
30+
fmt.Fprintf(builder, "%s%s\n", indent, cmd.Name())
31+
}
32+
2633
for _, child := range children {
2734
renderNode(builder, child, depth+1)
2835
}

internal/config/config.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package config
22

33
import (
44
"fmt"
5+
"hash/fnv"
56
"net/url"
67
"os"
78
"path/filepath"
@@ -51,6 +52,8 @@ func Load() (Config, error) {
5152
}
5253

5354
cacheDir := filepath.Join(cacheRoot, appName)
55+
cacheKey := urlCacheKey(openAPIURL)
56+
5457
ttl, err := parseTTL(os.Getenv(envOpenAPICacheTTL))
5558
if err != nil {
5659
return Config{}, err
@@ -67,14 +70,22 @@ func Load() (Config, error) {
6770
AppName: appName,
6871
OpenAPIURL: openAPIURL,
6972
APIKey: apiKey,
70-
CachePath: filepath.Join(cacheDir, "spec.json"),
71-
CacheMeta: filepath.Join(cacheDir, "spec.meta.json"),
73+
CachePath: filepath.Join(cacheDir, "spec-"+cacheKey+".json"),
74+
CacheMeta: filepath.Join(cacheDir, "spec-"+cacheKey+".meta.json"),
7275
CacheTTL: ttl,
7376
HTTPTimeout: timeout,
7477
UploadPath: uploadPath,
7578
}, nil
7679
}
7780

81+
// urlCacheKey returns a short hex string derived from the URL so that
82+
// different API endpoints get separate cache files.
83+
func urlCacheKey(rawURL string) string {
84+
h := fnv.New32a()
85+
_, _ = h.Write([]byte(rawURL))
86+
return fmt.Sprintf("%08x", h.Sum32())
87+
}
88+
7889
func resolveOpenAPISpecURL(baseURL string) (string, error) {
7990
raw := strings.TrimSpace(baseURL)
8091
if raw == "" {

0 commit comments

Comments
 (0)