From a81e447ed28084608aa845ad52bff9cae71ced0e Mon Sep 17 00:00:00 2001 From: Ben Kalmus Date: Wed, 17 Jun 2026 15:38:09 +0100 Subject: [PATCH 1/4] feat(compositions): add --interactive mode to write and search commands Inject a Prompter through cmdutil.Factory (defaulted to a survey-backed prompter in factory.New) and add an --interactive/-i flag to the compositions commands that build a request body: - compositions upsert -> Composition - compositions rules upsert -> CompositionRule - compositions search -> search RequestBody (query optional under -i) Each builds the body with the interactive engine instead of requiring JSON; --file and --interactive are mutually exclusive and interactive requires a TTY. Adds a group-level integration test plus per-command interactive tests. --- pkg/cmd/compositions/compositions_test.go | 85 +++++++++++++++++++ pkg/cmd/compositions/rules/upsert/upsert.go | 49 +++++++++-- .../compositions/rules/upsert/upsert_test.go | 45 ++++++++++ pkg/cmd/compositions/search/search.go | 70 +++++++++++---- pkg/cmd/compositions/search/search_test.go | 33 ++++++- pkg/cmd/compositions/upsert/upsert.go | 50 +++++++++-- pkg/cmd/compositions/upsert/upsert_test.go | 20 ++++- pkg/cmd/factory/default.go | 2 + pkg/cmdutil/factory.go | 5 ++ 9 files changed, 329 insertions(+), 30 deletions(-) create mode 100644 pkg/cmd/compositions/compositions_test.go diff --git a/pkg/cmd/compositions/compositions_test.go b/pkg/cmd/compositions/compositions_test.go new file mode 100644 index 00000000..3e9a112a --- /dev/null +++ b/pkg/cmd/compositions/compositions_test.go @@ -0,0 +1,85 @@ +package compositions_test + +import ( + "io" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/algolia/cli/pkg/cmd/compositions" + compinternal "github.com/algolia/cli/pkg/cmd/compositions/internal" + "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/pkg/interactive" + "github.com/algolia/cli/test" +) + +// wantBody is the exact composition the interactive build produces from the +// scripted answers below. It is a valid composition: objectID is pre-populated +// from the positional arg, name comes from the keyed Input, and behavior selects +// the injection variant whose main source is a search source pointing at a real +// index. description, sortingStrategy, and all the optional query parameters are +// unanswered and therefore omitted. +const wantBody = `{ + "objectID": "my-comp", + "name": "My Composition", + "behavior": { + "injection": { + "main": { + "source": { + "search": {"index": "my-index"} + } + } + } + } +}` + +// Drives `compositions upsert --interactive` through the real command tree with +// a label-keyed ScriptedPrompter on the Factory. Answers are keyed by a unique +// substring of the prompt label, so adding or reordering SDK fields does not +// break the INPUT side: unmatched prompts fall back to safe defaults (skip). +func TestCompositions_UpsertInteractive(t *testing.T) { + r := &httpmock.Registry{} + var captured []byte + r.Register(httpmock.REST("PUT", "1/compositions/my-comp"), func(req *http.Request) (*http.Response, error) { + captured, _ = io.ReadAll(req.Body) + return httpmock.StringResponse(`{"taskID":42}`)(req) + }) + r.Register(httpmock.REST("GET", "1/compositions/my-comp/task/42"), httpmock.StringResponse(`{"status":"published"}`)) + + compinternal.PollInterval = 1 * time.Millisecond + compinternal.Timeout = 50 * time.Millisecond + t.Cleanup(func() { + compinternal.PollInterval = compinternal.DefaultPollInterval + compinternal.Timeout = compinternal.DefaultTimeout + }) + + f, out := test.NewFactory(true, r, nil, "") + f.Prompter = &interactive.ScriptedPrompter{ + Inputs: map[string]string{ + "name": "My Composition", + "index": "my-index", // behavior.injection.main.source.search.index (required) + }, + Confirms: map[string]bool{ + // Trailing "?" pins this to the source pointer confirm + // ("...main.source?") so it does not also match the deeper + // "...search.params?" confirm, whose path contains ".main.source.". + "main.source?": true, + }, + // Both unions are keyed by their leaf "(variant)" label so the deep + // source select does not collide with the top-level behavior select. + Selects: map[string]string{ + "behavior (variant)": "CompositionInjectionBehavior", + "source (variant)": "InjectionMainSearchSource", + }, + } + + cmd := compositions.NewCompositionsCmd(f) + _, err := test.Execute(cmd, "upsert my-comp --interactive", out) + require.NoError(t, err) + + assert.JSONEq(t, wantBody, string(captured)) + r.Verify(t) +} diff --git a/pkg/cmd/compositions/rules/upsert/upsert.go b/pkg/cmd/compositions/rules/upsert/upsert.go index 52ee289e..17aa7775 100644 --- a/pkg/cmd/compositions/rules/upsert/upsert.go +++ b/pkg/cmd/compositions/rules/upsert/upsert.go @@ -11,6 +11,7 @@ import ( compinternal "github.com/algolia/cli/pkg/cmd/compositions/internal" "github.com/algolia/cli/pkg/cmdutil" "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/interactive" "github.com/algolia/cli/pkg/iostreams" "github.com/algolia/cli/pkg/validators" ) @@ -20,9 +21,11 @@ type UpsertOptions struct { Config config.IConfig IO *iostreams.IOStreams CompositionClient func() (*algoliaComposition.APIClient, error) + Prompter interactive.Prompter CompositionID string ObjectID string File string + Interactive bool PrintFlags *cmdutil.PrintFlags } @@ -32,6 +35,7 @@ func NewUpsertCmd(f *cmdutil.Factory) *cobra.Command { IO: f.IOStreams, Config: f.Config, CompositionClient: f.CompositionClient, + Prompter: f.Prompter, PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), } @@ -48,30 +52,63 @@ func NewUpsertCmd(f *cmdutil.Factory) *cobra.Command { # Upsert from stdin $ cat rule.json | algolia compositions rules upsert my-comp rule-1 --file - + + # Build a rule interactively + $ algolia compositions rules upsert my-comp rule-1 --interactive `), RunE: func(cmd *cobra.Command, args []string) error { opts.CompositionID = args[0] opts.ObjectID = args[1] + + if opts.Interactive == (opts.File != "") { + return cmdutil.FlagErrorf("exactly one of `--file` or `--interactive` is required") + } + if opts.Interactive && !opts.IO.CanPrompt() { + return cmdutil.FlagErrorf("`--interactive` requires a terminal; use `--file` instead") + } + return runUpsertCmd(opts) }, } cmd.Flags().StringVarP(&opts.File, "file", "f", "", "JSON file path (use - for stdin)") - _ = cmd.MarkFlagRequired("file") + cmd.Flags().BoolVarP(&opts.Interactive, "interactive", "i", false, "Build the rule interactively") opts.PrintFlags.AddFlags(cmd) return cmd } -func runUpsertCmd(opts *UpsertOptions) error { +// buildRule produces the rule body either interactively or by reading and +// parsing the JSON file. +func buildRule(opts *UpsertOptions) (algoliaComposition.CompositionRule, error) { + var rule algoliaComposition.CompositionRule + + if opts.Interactive { + rule.ObjectID = opts.ObjectID + prompter := opts.Prompter + if prompter == nil { + prompter = interactive.NewSurveyPrompter(opts.IO) + } + if err := (&interactive.Builder{Prompter: prompter}).Build(&rule); err != nil { + return rule, fmt.Errorf("building rule: %w", err) + } + return rule, nil + } + raw, err := cmdutil.ReadFile(opts.File, opts.IO.In) if err != nil { - return fmt.Errorf("reading file: %w", err) + return rule, fmt.Errorf("reading file: %w", err) } - - var rule algoliaComposition.CompositionRule if err := json.Unmarshal(raw, &rule); err != nil { - return fmt.Errorf("parsing rule JSON: %w", err) + return rule, fmt.Errorf("parsing rule JSON: %w", err) + } + return rule, nil +} + +func runUpsertCmd(opts *UpsertOptions) error { + rule, err := buildRule(opts) + if err != nil { + return err } client, err := opts.CompositionClient() diff --git a/pkg/cmd/compositions/rules/upsert/upsert_test.go b/pkg/cmd/compositions/rules/upsert/upsert_test.go index 44748a81..c6d75656 100644 --- a/pkg/cmd/compositions/rules/upsert/upsert_test.go +++ b/pkg/cmd/compositions/rules/upsert/upsert_test.go @@ -11,6 +11,7 @@ import ( compinternal "github.com/algolia/cli/pkg/cmd/compositions/internal" "github.com/algolia/cli/pkg/cmd/compositions/rules/upsert" "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/pkg/interactive" "github.com/algolia/cli/test" ) @@ -100,3 +101,47 @@ func TestUpsertRule_MissingArgs(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "requires a and a argument") } + +func TestUpsertRule_Interactive(t *testing.T) { + r := &httpmock.Registry{} + r.Register(httpmock.REST("PUT", "1/compositions/my-comp/rules/rule-1"), httpmock.StringResponse(`{"taskID":77}`)) + r.Register(httpmock.REST("GET", "1/compositions/my-comp/task/77"), httpmock.StringResponse(`{"status":"published"}`)) + + compinternal.PollInterval = 1 * time.Millisecond + compinternal.Timeout = 50 * time.Millisecond + t.Cleanup(func() { + compinternal.PollInterval = compinternal.DefaultPollInterval + compinternal.Timeout = compinternal.DefaultTimeout + }) + + f, out := test.NewFactory(true, r, nil, "") + // Empty script: pre-populated objectID, first behavior variant, optionals skipped. + f.Prompter = &interactive.ScriptedPrompter{} + + cmd := upsert.NewUpsertCmd(f) + _, err := test.Execute(cmd, "my-comp rule-1 --interactive", out) + require.NoError(t, err) + + // TTY factory prints a success line before the JSON, so assert on substrings. + assert.Contains(t, out.String(), `{"taskID":77}`) + assert.Contains(t, out.String(), "Upserted rule rule-1") + r.Verify(t) +} + +func TestUpsertRule_InteractiveAndFileConflict(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(true, r, nil, "") + cmd := upsert.NewUpsertCmd(f) + _, err := test.Execute(cmd, "my-comp rule-1 --interactive --file rule.json", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "exactly one of `--file` or `--interactive`") +} + +func TestUpsertRule_InteractiveNoTTY(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := upsert.NewUpsertCmd(f) + _, err := test.Execute(cmd, "my-comp rule-1 --interactive", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires a terminal") +} diff --git a/pkg/cmd/compositions/search/search.go b/pkg/cmd/compositions/search/search.go index d98ff4cb..52ba28a3 100644 --- a/pkg/cmd/compositions/search/search.go +++ b/pkg/cmd/compositions/search/search.go @@ -1,14 +1,16 @@ package search import ( + "fmt" + "github.com/MakeNowJust/heredoc" algoliaComposition "github.com/algolia/algoliasearch-client-go/v4/algolia/composition" "github.com/spf13/cobra" "github.com/algolia/cli/pkg/cmdutil" "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/interactive" "github.com/algolia/cli/pkg/iostreams" - "github.com/algolia/cli/pkg/validators" ) // SearchOptions holds the dependencies and flags for the search command. @@ -16,11 +18,13 @@ type SearchOptions struct { Config config.IConfig IO *iostreams.IOStreams CompositionClient func() (*algoliaComposition.APIClient, error) + Prompter interactive.Prompter CompositionID string Query string HitsPerPage *int32 Page *int32 Filters string + Interactive bool PrintFlags *cmdutil.PrintFlags } @@ -30,6 +34,7 @@ func NewSearchCmd(f *cmdutil.Factory) *cobra.Command { IO: f.IOStreams, Config: f.Config, CompositionClient: f.CompositionClient, + Prompter: f.Prompter, PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), } @@ -37,9 +42,9 @@ func NewSearchCmd(f *cmdutil.Factory) *cobra.Command { var page int32 cmd := &cobra.Command{ - Use: "search ", + Use: "search [query]", Short: "Search a composition", - Args: validators.ExactArgsWithMsg(2, "compositions search requires a and a argument."), + Args: cobra.RangeArgs(1, 2), Annotations: map[string]string{ "acls": "search", }, @@ -52,9 +57,23 @@ func NewSearchCmd(f *cmdutil.Factory) *cobra.Command { # Search with pagination $ algolia compositions search my-comp "shirt" --hits-per-page 20 --page 2 + + # Build the search request interactively + $ algolia compositions search my-comp --interactive `), RunE: func(cmd *cobra.Command, args []string) error { opts.CompositionID = args[0] + + if opts.Interactive { + if !opts.IO.CanPrompt() { + return cmdutil.FlagErrorf("`--interactive` requires a terminal") + } + return runSearchCmd(opts) + } + + if len(args) < 2 { + return cmdutil.FlagErrorf("a argument is required (or use `--interactive`)") + } opts.Query = args[1] if cmd.Flags().Changed("hits-per-page") { opts.HitsPerPage = &hitsPerPage @@ -69,20 +88,25 @@ func NewSearchCmd(f *cmdutil.Factory) *cobra.Command { cmd.Flags().Int32Var(&hitsPerPage, "hits-per-page", 20, "Number of hits per page") cmd.Flags().Int32Var(&page, "page", 0, "Page number") cmd.Flags().StringVar(&opts.Filters, "filters", "", "Filter expression") + cmd.Flags().BoolVarP(&opts.Interactive, "interactive", "i", false, "Build the search request interactively") opts.PrintFlags.AddFlags(cmd) return cmd } -func runSearchCmd(opts *SearchOptions) error { - client, err := opts.CompositionClient() - if err != nil { - return err - } - - p, err := opts.PrintFlags.ToPrinter() - if err != nil { - return err +// buildRequestBody assembles the search request body, interactively when +// requested or from the query argument and flags otherwise. +func buildRequestBody(opts *SearchOptions) (*algoliaComposition.RequestBody, error) { + if opts.Interactive { + var body algoliaComposition.RequestBody + prompter := opts.Prompter + if prompter == nil { + prompter = interactive.NewSurveyPrompter(opts.IO) + } + if err := (&interactive.Builder{Prompter: prompter}).Build(&body); err != nil { + return nil, fmt.Errorf("building search request: %w", err) + } + return &body, nil } params := algoliaComposition.NewParams( @@ -97,10 +121,26 @@ func runSearchCmd(opts *SearchOptions) error { if opts.Filters != "" { params.Filters = &opts.Filters } - - reqBody := algoliaComposition.NewRequestBody( + return algoliaComposition.NewRequestBody( algoliaComposition.WithRequestBodyParams(*params), - ) + ), nil +} + +func runSearchCmd(opts *SearchOptions) error { + client, err := opts.CompositionClient() + if err != nil { + return err + } + + p, err := opts.PrintFlags.ToPrinter() + if err != nil { + return err + } + + reqBody, err := buildRequestBody(opts) + if err != nil { + return err + } opts.IO.StartProgressIndicatorWithLabel("Searching") diff --git a/pkg/cmd/compositions/search/search_test.go b/pkg/cmd/compositions/search/search_test.go index 3e982e9c..06cc2ae4 100644 --- a/pkg/cmd/compositions/search/search_test.go +++ b/pkg/cmd/compositions/search/search_test.go @@ -9,6 +9,7 @@ import ( compsearch "github.com/algolia/cli/pkg/cmd/compositions/search" "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/pkg/interactive" "github.com/algolia/cli/test" ) @@ -52,11 +53,39 @@ func TestSearchComposition(t *testing.T) { } } -func TestSearchComposition_MissingArgs(t *testing.T) { +func TestSearchComposition_MissingQuery(t *testing.T) { r := &httpmock.Registry{} f, out := test.NewFactory(false, r, nil, "") cmd := compsearch.NewSearchCmd(f) _, err := test.Execute(cmd, "my-comp", out) require.Error(t, err) - assert.Contains(t, err.Error(), "requires a and a argument") + assert.Contains(t, err.Error(), "a argument is required") +} + +func TestSearchComposition_Interactive(t *testing.T) { + r := &httpmock.Registry{} + r.Register( + httpmock.REST("POST", "1/compositions/my-comp/run"), + httpmock.StringResponse(`{"hits":[],"nbHits":0}`), + ) + + f, out := test.NewFactory(true, r, nil, "") + // Empty script: decline the optional params, producing an empty request body. + f.Prompter = &interactive.ScriptedPrompter{} + + cmd := compsearch.NewSearchCmd(f) + _, err := test.Execute(cmd, "my-comp --interactive", out) + require.NoError(t, err) + + assert.JSONEq(t, `{"hits":[],"nbHits":0,"results":null}`, strings.TrimSpace(out.String())) + r.Verify(t) +} + +func TestSearchComposition_InteractiveNoTTY(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := compsearch.NewSearchCmd(f) + _, err := test.Execute(cmd, "my-comp --interactive", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires a terminal") } diff --git a/pkg/cmd/compositions/upsert/upsert.go b/pkg/cmd/compositions/upsert/upsert.go index f531ebcb..ea39e3cb 100644 --- a/pkg/cmd/compositions/upsert/upsert.go +++ b/pkg/cmd/compositions/upsert/upsert.go @@ -11,6 +11,7 @@ import ( compinternal "github.com/algolia/cli/pkg/cmd/compositions/internal" "github.com/algolia/cli/pkg/cmdutil" "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/interactive" "github.com/algolia/cli/pkg/iostreams" "github.com/algolia/cli/pkg/validators" ) @@ -20,8 +21,10 @@ type UpsertOptions struct { Config config.IConfig IO *iostreams.IOStreams CompositionClient func() (*algoliaComposition.APIClient, error) + Prompter interactive.Prompter CompositionID string File string + Interactive bool PrintFlags *cmdutil.PrintFlags } @@ -31,6 +34,7 @@ func NewUpsertCmd(f *cmdutil.Factory) *cobra.Command { IO: f.IOStreams, Config: f.Config, CompositionClient: f.CompositionClient, + Prompter: f.Prompter, PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), } @@ -47,29 +51,63 @@ func NewUpsertCmd(f *cmdutil.Factory) *cobra.Command { # Upsert from stdin $ cat body.json | algolia compositions upsert my-comp --file - + + # Build a composition interactively + $ algolia compositions upsert my-comp --interactive `), RunE: func(cmd *cobra.Command, args []string) error { opts.CompositionID = args[0] + + if opts.Interactive == (opts.File != "") { + return cmdutil.FlagErrorf("exactly one of `--file` or `--interactive` is required") + } + if opts.Interactive && !opts.IO.CanPrompt() { + return cmdutil.FlagErrorf("`--interactive` requires a terminal; use `--file` instead") + } + return runUpsertCmd(opts) }, } cmd.Flags().StringVarP(&opts.File, "file", "f", "", "JSON file path (use - for stdin)") - _ = cmd.MarkFlagRequired("file") + cmd.Flags().BoolVarP(&opts.Interactive, "interactive", "i", false, "Build the composition interactively") opts.PrintFlags.AddFlags(cmd) return cmd } -func runUpsertCmd(opts *UpsertOptions) error { +// buildComposition produces the composition body either interactively or by +// reading and parsing the JSON file. +func buildComposition(opts *UpsertOptions) (algoliaComposition.Composition, error) { + var comp algoliaComposition.Composition + + if opts.Interactive { + comp.ObjectID = opts.CompositionID + prompter := opts.Prompter + if prompter == nil { + prompter = interactive.NewSurveyPrompter(opts.IO) + } + builder := &interactive.Builder{Prompter: prompter} + if err := builder.Build(&comp); err != nil { + return comp, fmt.Errorf("building composition: %w", err) + } + return comp, nil + } + raw, err := cmdutil.ReadFile(opts.File, opts.IO.In) if err != nil { - return fmt.Errorf("reading file: %w", err) + return comp, fmt.Errorf("reading file: %w", err) } - - var comp algoliaComposition.Composition if err := json.Unmarshal(raw, &comp); err != nil { - return fmt.Errorf("parsing composition JSON: %w", err) + return comp, fmt.Errorf("parsing composition JSON: %w", err) + } + return comp, nil +} + +func runUpsertCmd(opts *UpsertOptions) error { + comp, err := buildComposition(opts) + if err != nil { + return err } client, err := opts.CompositionClient() diff --git a/pkg/cmd/compositions/upsert/upsert_test.go b/pkg/cmd/compositions/upsert/upsert_test.go index 33efa63d..d3a299ee 100644 --- a/pkg/cmd/compositions/upsert/upsert_test.go +++ b/pkg/cmd/compositions/upsert/upsert_test.go @@ -78,7 +78,7 @@ func TestUpsertComposition_MissingFile(t *testing.T) { cmd := upsert.NewUpsertCmd(f) _, err := test.Execute(cmd, "my-comp", out) require.Error(t, err) - assert.Contains(t, err.Error(), "file") + assert.Contains(t, err.Error(), "exactly one of `--file` or `--interactive`") } func TestUpsertComposition_InvalidJSON(t *testing.T) { @@ -99,3 +99,21 @@ func TestUpsertComposition_MissingArg(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "requires a argument") } + +func TestUpsertComposition_InteractiveAndFileConflict(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(true, r, nil, "") + cmd := upsert.NewUpsertCmd(f) + _, err := test.Execute(cmd, "my-comp --interactive --file body.json", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "exactly one of `--file` or `--interactive`") +} + +func TestUpsertComposition_InteractiveNoTTY(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") // not a TTY + cmd := upsert.NewUpsertCmd(f) + _, err := test.Execute(cmd, "my-comp --interactive", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires a terminal") +} diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index 9959c214..6e27cf3e 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -15,6 +15,7 @@ import ( "github.com/algolia/cli/pkg/auth" "github.com/algolia/cli/pkg/cmdutil" "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/interactive" "github.com/algolia/cli/pkg/iostreams" ) @@ -24,6 +25,7 @@ func New(appVersion string, cfg config.IConfig) *cmdutil.Factory { ExecutableName: "gh", } f.IOStreams = ioStreams(f) + f.Prompter = interactive.NewSurveyPrompter(f.IOStreams) f.SearchClient = searchClient(f, appVersion) f.CrawlerClient = crawlerClient(f) f.CompositionClient = compositionClient(f, appVersion) diff --git a/pkg/cmdutil/factory.go b/pkg/cmdutil/factory.go index a31ce15e..3d135893 100644 --- a/pkg/cmdutil/factory.go +++ b/pkg/cmdutil/factory.go @@ -10,6 +10,7 @@ import ( "github.com/algolia/cli/api/crawler" "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/interactive" "github.com/algolia/cli/pkg/iostreams" ) @@ -20,6 +21,10 @@ type Factory struct { CrawlerClient func() (*crawler.Client, error) CompositionClient func() (*composition.APIClient, error) + // Prompter is the interactive input source used by commands that support + // an --interactive mode. Defaulted to a real SurveyPrompter in factory.New; + // tests set it to an interactive.ScriptedPrompter. + Prompter interactive.Prompter ExecutableName string } From 020ce0a0a717d76bdc103e1ccc41ca088cf7dfbd Mon Sep 17 00:00:00 2001 From: Ben Kalmus Date: Mon, 6 Jul 2026 12:25:32 +0100 Subject: [PATCH 2/4] feat(compositions): add the sorting strategy command include interactive mode --- pkg/cmd/compositions/compositions.go | 2 + .../sortingstrategy/sortingstrategy.go | 152 ++++++++++++++++++ .../sortingstrategy/sortingstrategy_test.go | 100 ++++++++++++ 3 files changed, 254 insertions(+) create mode 100644 pkg/cmd/compositions/sortingstrategy/sortingstrategy.go create mode 100644 pkg/cmd/compositions/sortingstrategy/sortingstrategy_test.go diff --git a/pkg/cmd/compositions/compositions.go b/pkg/cmd/compositions/compositions.go index 0f2d13a2..8fe87e1a 100644 --- a/pkg/cmd/compositions/compositions.go +++ b/pkg/cmd/compositions/compositions.go @@ -8,6 +8,7 @@ import ( "github.com/algolia/cli/pkg/cmd/compositions/list" "github.com/algolia/cli/pkg/cmd/compositions/rules" compsearch "github.com/algolia/cli/pkg/cmd/compositions/search" + "github.com/algolia/cli/pkg/cmd/compositions/sortingstrategy" "github.com/algolia/cli/pkg/cmd/compositions/upsert" "github.com/algolia/cli/pkg/cmdutil" ) @@ -23,6 +24,7 @@ func NewCompositionsCmd(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(list.NewListCmd(f)) cmd.AddCommand(get.NewGetCmd(f)) cmd.AddCommand(upsert.NewUpsertCmd(f)) + cmd.AddCommand(sortingstrategy.NewSortingStrategyCmd(f)) cmd.AddCommand(delete.NewDeleteCmd(f)) cmd.AddCommand(compsearch.NewSearchCmd(f)) cmd.AddCommand(rules.NewRulesCmd(f)) diff --git a/pkg/cmd/compositions/sortingstrategy/sortingstrategy.go b/pkg/cmd/compositions/sortingstrategy/sortingstrategy.go new file mode 100644 index 00000000..28224044 --- /dev/null +++ b/pkg/cmd/compositions/sortingstrategy/sortingstrategy.go @@ -0,0 +1,152 @@ +package sortingstrategy + +import ( + "encoding/json" + "fmt" + + "github.com/MakeNowJust/heredoc" + algoliaComposition "github.com/algolia/algoliasearch-client-go/v4/algolia/composition" + "github.com/spf13/cobra" + + compinternal "github.com/algolia/cli/pkg/cmd/compositions/internal" + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/interactive" + "github.com/algolia/cli/pkg/iostreams" + "github.com/algolia/cli/pkg/validators" +) + +// Options holds dependencies and flags for the sorting-strategy command. +type Options struct { + Config config.IConfig + IO *iostreams.IOStreams + CompositionClient func() (*algoliaComposition.APIClient, error) + Prompter interactive.Prompter + CompositionID string + File string + Interactive bool + PrintFlags *cmdutil.PrintFlags +} + +// NewSortingStrategyCmd returns the `compositions sorting-strategy` command. +func NewSortingStrategyCmd(f *cmdutil.Factory) *cobra.Command { + opts := &Options{ + IO: f.IOStreams, + Config: f.Config, + CompositionClient: f.CompositionClient, + Prompter: f.Prompter, + PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"), + } + + cmd := &cobra.Command{ + Use: "sorting-strategy ", + Short: "Set the sorting strategy of a composition", + Long: heredoc.Doc(` + Replace a composition's sorting strategy: a mapping of sort labels to the + indices (or replicas) that implement them. These labels are what the + ` + "`sortBy`" + ` field on composition rules and search params selects at runtime. + `), + Args: validators.ExactArgsWithMsg(1, "compositions sorting-strategy requires a argument."), + Annotations: map[string]string{ + "acls": "editSettings", + }, + Example: heredoc.Doc(` + # Set the sorting strategy from a JSON file + $ algolia compositions sorting-strategy my-comp --file strategy.json + + # Set it from stdin + $ echo '{"Price (asc)":"products_price_asc"}' | algolia compositions sorting-strategy my-comp --file - + + # Build the mapping interactively + $ algolia compositions sorting-strategy my-comp --interactive + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.CompositionID = args[0] + + if opts.Interactive == (opts.File != "") { + return cmdutil.FlagErrorf("exactly one of `--file` or `--interactive` is required") + } + if opts.Interactive && !opts.IO.CanPrompt() { + return cmdutil.FlagErrorf("`--interactive` requires a terminal; use `--file` instead") + } + + return runCmd(opts) + }, + } + + cmd.Flags().StringVarP(&opts.File, "file", "f", "", "JSON file path (use - for stdin)") + cmd.Flags().BoolVarP(&opts.Interactive, "interactive", "i", false, "Build the sorting strategy interactively") + + opts.PrintFlags.AddFlags(cmd) + return cmd +} + +// buildStrategy produces the label->index map either interactively or by reading +// and parsing the JSON file. +func buildStrategy(opts *Options) (map[string]string, error) { + if opts.Interactive { + // The body is a bare map; the interactive Builder needs a struct, so wrap + // it and reuse the engine's map handling (count, then key/value pairs). + var doc struct { + SortingStrategy map[string]string `json:"sortingStrategy"` + } + prompter := opts.Prompter + if prompter == nil { + prompter = interactive.NewSurveyPrompter(opts.IO) + } + if err := (&interactive.Builder{Prompter: prompter}).Build(&doc); err != nil { + return nil, fmt.Errorf("building sorting strategy: %w", err) + } + return doc.SortingStrategy, nil + } + + raw, err := cmdutil.ReadFile(opts.File, opts.IO.In) + if err != nil { + return nil, fmt.Errorf("reading file: %w", err) + } + var strategy map[string]string + if err := json.Unmarshal(raw, &strategy); err != nil { + return nil, fmt.Errorf("parsing sorting strategy JSON: %w", err) + } + return strategy, nil +} + +func runCmd(opts *Options) error { + strategy, err := buildStrategy(opts) + if err != nil { + return err + } + + client, err := opts.CompositionClient() + if err != nil { + return err + } + + p, err := opts.PrintFlags.ToPrinter() + if err != nil { + return err + } + + opts.IO.StartProgressIndicatorWithLabel("Updating sorting strategy") + + res, err := client.UpdateSortingStrategyComposition( + client.NewApiUpdateSortingStrategyCompositionRequest(opts.CompositionID, strategy), + ) + if err != nil { + opts.IO.StopProgressIndicator() + return err + } + + opts.IO.StopProgressIndicator() + + if err := compinternal.WaitForTask(opts.IO, client, opts.CompositionID, res.TaskID, compinternal.PollInterval, compinternal.Timeout); err != nil { + return err + } + + if opts.IO.IsStdoutTTY() { + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Out, "%s Updated sorting strategy for composition %s\n", cs.SuccessIcon(), opts.CompositionID) + } + + return p.Print(opts.IO, res) +} diff --git a/pkg/cmd/compositions/sortingstrategy/sortingstrategy_test.go b/pkg/cmd/compositions/sortingstrategy/sortingstrategy_test.go new file mode 100644 index 00000000..1f1ca261 --- /dev/null +++ b/pkg/cmd/compositions/sortingstrategy/sortingstrategy_test.go @@ -0,0 +1,100 @@ +package sortingstrategy_test + +import ( + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + compinternal "github.com/algolia/cli/pkg/cmd/compositions/internal" + "github.com/algolia/cli/pkg/cmd/compositions/sortingstrategy" + "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/pkg/interactive" + "github.com/algolia/cli/test" +) + +func TestSortingStrategy_File(t *testing.T) { + r := &httpmock.Registry{} + r.Register(httpmock.REST("POST", "1/compositions/my-comp/sortingStrategy"), httpmock.StringResponse(`{"taskID":7}`)) + r.Register(httpmock.REST("GET", "1/compositions/my-comp/task/7"), httpmock.StringResponse(`{"status":"published"}`)) + + compinternal.PollInterval = 1 * time.Millisecond + compinternal.Timeout = 50 * time.Millisecond + t.Cleanup(func() { + compinternal.PollInterval = compinternal.DefaultPollInterval + compinternal.Timeout = compinternal.DefaultTimeout + }) + + f, out := test.NewFactory(false, r, nil, "") + cmd := sortingstrategy.NewSortingStrategyCmd(f) + out.InBuf.WriteString(`{"Price (asc)":"products_price_asc"}`) + _, err := test.Execute(cmd, "my-comp --file -", out) + require.NoError(t, err) + + assert.JSONEq(t, `{"taskID":7}`, strings.TrimSpace(out.String())) + r.Verify(t) +} + +func TestSortingStrategy_Interactive(t *testing.T) { + r := &httpmock.Registry{} + var captured []byte + r.Register(httpmock.REST("POST", "1/compositions/my-comp/sortingStrategy"), func(req *http.Request) (*http.Response, error) { + captured, _ = io.ReadAll(req.Body) + return httpmock.StringResponse(`{"taskID":8}`)(req) + }) + r.Register(httpmock.REST("GET", "1/compositions/my-comp/task/8"), httpmock.StringResponse(`{"status":"published"}`)) + + compinternal.PollInterval = 1 * time.Millisecond + compinternal.Timeout = 50 * time.Millisecond + t.Cleanup(func() { + compinternal.PollInterval = compinternal.DefaultPollInterval + compinternal.Timeout = compinternal.DefaultTimeout + }) + + f, out := test.NewFactory(true, r, nil, "") + // One entry: label "Price (asc)" -> index "products_price_asc". Keys match + // the engine's map prompt labels (count/key/value), as in TestBuild_StringMap. + f.Prompter = &interactive.ScriptedPrompter{Inputs: map[string]string{ + "entries": "1", + "key[0]": "Price (asc)", + `["Price (asc)"]`: "products_price_asc", + }} + + cmd := sortingstrategy.NewSortingStrategyCmd(f) + _, err := test.Execute(cmd, "my-comp --interactive", out) + require.NoError(t, err) + + assert.JSONEq(t, `{"Price (asc)":"products_price_asc"}`, string(captured)) + r.Verify(t) +} + +func TestSortingStrategy_InteractiveAndFileConflict(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(true, r, nil, "") + cmd := sortingstrategy.NewSortingStrategyCmd(f) + _, err := test.Execute(cmd, "my-comp --file strategy.json --interactive", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "exactly one of `--file` or `--interactive`") +} + +func TestSortingStrategy_InteractiveNoTTY(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := sortingstrategy.NewSortingStrategyCmd(f) + _, err := test.Execute(cmd, "my-comp --interactive", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires a terminal") +} + +func TestSortingStrategy_MissingArg(t *testing.T) { + r := &httpmock.Registry{} + f, out := test.NewFactory(false, r, nil, "") + cmd := sortingstrategy.NewSortingStrategyCmd(f) + _, err := test.Execute(cmd, "--file -", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires a argument") +} From e0a4db39a854c46fc64915341003dbcc20cb917a Mon Sep 17 00:00:00 2001 From: Ben Kalmus Date: Tue, 7 Jul 2026 16:09:01 +0100 Subject: [PATCH 3/4] address review feedback on compositions interactive - search: reject a positional query or --hits-per-page/--page/--filters when --interactive is set, instead of silently ignoring them - drop the redundant nil-Prompter fallback in upsert, rules upsert, search, and sorting-strategy (factory.New always sets it; Builder errors on nil anyway) --- pkg/cmd/compositions/rules/upsert/upsert.go | 6 +----- pkg/cmd/compositions/search/search.go | 9 ++++----- pkg/cmd/compositions/search/search_test.go | 13 +++++++++++++ .../compositions/sortingstrategy/sortingstrategy.go | 6 +----- pkg/cmd/compositions/upsert/upsert.go | 6 +----- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/pkg/cmd/compositions/rules/upsert/upsert.go b/pkg/cmd/compositions/rules/upsert/upsert.go index 17aa7775..3275fac5 100644 --- a/pkg/cmd/compositions/rules/upsert/upsert.go +++ b/pkg/cmd/compositions/rules/upsert/upsert.go @@ -85,11 +85,7 @@ func buildRule(opts *UpsertOptions) (algoliaComposition.CompositionRule, error) if opts.Interactive { rule.ObjectID = opts.ObjectID - prompter := opts.Prompter - if prompter == nil { - prompter = interactive.NewSurveyPrompter(opts.IO) - } - if err := (&interactive.Builder{Prompter: prompter}).Build(&rule); err != nil { + if err := (&interactive.Builder{Prompter: opts.Prompter}).Build(&rule); err != nil { return rule, fmt.Errorf("building rule: %w", err) } return rule, nil diff --git a/pkg/cmd/compositions/search/search.go b/pkg/cmd/compositions/search/search.go index 52ba28a3..84522339 100644 --- a/pkg/cmd/compositions/search/search.go +++ b/pkg/cmd/compositions/search/search.go @@ -68,6 +68,9 @@ func NewSearchCmd(f *cmdutil.Factory) *cobra.Command { if !opts.IO.CanPrompt() { return cmdutil.FlagErrorf("`--interactive` requires a terminal") } + if len(args) > 1 || cmd.Flags().Changed("hits-per-page") || cmd.Flags().Changed("page") || cmd.Flags().Changed("filters") { + return cmdutil.FlagErrorf("`--interactive` builds the whole request; omit the query argument and the --hits-per-page/--page/--filters flags") + } return runSearchCmd(opts) } @@ -99,11 +102,7 @@ func NewSearchCmd(f *cmdutil.Factory) *cobra.Command { func buildRequestBody(opts *SearchOptions) (*algoliaComposition.RequestBody, error) { if opts.Interactive { var body algoliaComposition.RequestBody - prompter := opts.Prompter - if prompter == nil { - prompter = interactive.NewSurveyPrompter(opts.IO) - } - if err := (&interactive.Builder{Prompter: prompter}).Build(&body); err != nil { + if err := (&interactive.Builder{Prompter: opts.Prompter}).Build(&body); err != nil { return nil, fmt.Errorf("building search request: %w", err) } return &body, nil diff --git a/pkg/cmd/compositions/search/search_test.go b/pkg/cmd/compositions/search/search_test.go index 06cc2ae4..4c4b59d7 100644 --- a/pkg/cmd/compositions/search/search_test.go +++ b/pkg/cmd/compositions/search/search_test.go @@ -89,3 +89,16 @@ func TestSearchComposition_InteractiveNoTTY(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "requires a terminal") } + +func TestSearchComposition_InteractiveRejectsQueryAndFlags(t *testing.T) { + // --interactive builds the whole request; a positional query or the flags + // would be silently ignored, so they are rejected. + for _, cli := range []string{`my-comp "shirt" --interactive`, "my-comp --interactive --hits-per-page 5"} { + r := &httpmock.Registry{} + f, out := test.NewFactory(true, r, nil, "") + cmd := compsearch.NewSearchCmd(f) + _, err := test.Execute(cmd, cli, out) + require.Error(t, err) + assert.Contains(t, err.Error(), "builds the whole request") + } +} diff --git a/pkg/cmd/compositions/sortingstrategy/sortingstrategy.go b/pkg/cmd/compositions/sortingstrategy/sortingstrategy.go index 28224044..291a2392 100644 --- a/pkg/cmd/compositions/sortingstrategy/sortingstrategy.go +++ b/pkg/cmd/compositions/sortingstrategy/sortingstrategy.go @@ -90,11 +90,7 @@ func buildStrategy(opts *Options) (map[string]string, error) { var doc struct { SortingStrategy map[string]string `json:"sortingStrategy"` } - prompter := opts.Prompter - if prompter == nil { - prompter = interactive.NewSurveyPrompter(opts.IO) - } - if err := (&interactive.Builder{Prompter: prompter}).Build(&doc); err != nil { + if err := (&interactive.Builder{Prompter: opts.Prompter}).Build(&doc); err != nil { return nil, fmt.Errorf("building sorting strategy: %w", err) } return doc.SortingStrategy, nil diff --git a/pkg/cmd/compositions/upsert/upsert.go b/pkg/cmd/compositions/upsert/upsert.go index ea39e3cb..ed60c401 100644 --- a/pkg/cmd/compositions/upsert/upsert.go +++ b/pkg/cmd/compositions/upsert/upsert.go @@ -83,11 +83,7 @@ func buildComposition(opts *UpsertOptions) (algoliaComposition.Composition, erro if opts.Interactive { comp.ObjectID = opts.CompositionID - prompter := opts.Prompter - if prompter == nil { - prompter = interactive.NewSurveyPrompter(opts.IO) - } - builder := &interactive.Builder{Prompter: prompter} + builder := &interactive.Builder{Prompter: opts.Prompter} if err := builder.Build(&comp); err != nil { return comp, fmt.Errorf("building composition: %w", err) } From 2597d89dd46a42b38a6738a641bf2bf9ad50fbd0 Mon Sep 17 00:00:00 2001 From: Ben Kalmus Date: Wed, 8 Jul 2026 12:39:25 +0100 Subject: [PATCH 4/4] retry ci