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
120 changes: 97 additions & 23 deletions cmd/sync/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"strings"

"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"

"github.com/launchdarkly/ldcli/cmd/cliflags"
Expand All @@ -20,11 +21,13 @@ import (

const (
archiveFlag = "archive"
configKeyFlag = "config-key"
conflictFlag = "conflict"
contentFlag = "content"
diffFlag = "diff"
dryRunFlag = "dry-run"
formatFlag = "format"
keyFlag = "key"
modelConfigKeyFlag = "model-config-key"
nameFlag = "name"
noInputFlag = "no-input"
Expand All @@ -44,25 +47,21 @@ func NewPromptCmd(client resources.Client) *cobra.Command {
ldcli sync prompts --dry-run

# Add the first variation without prompting
ldcli sync prompts add production/support/default --no-input
ldcli sync prompts add --project production --config-key support --key default --no-input

# Apply changes in an existing workspace without prompting
ldcli sync prompts --yes --no-input

# Prefer local changes when conflicts occur
ldcli sync prompts --yes --conflict=local`,
Args: func(cmd *cobra.Command, args []string) error {
if err := cobra.NoArgs(cmd, args); err != nil {
return err
}
return validators.Validate()(cmd, args)
},
# Sync one config, and prefer local changes when conflicts occur
ldcli sync prompts --project production --config-key support --yes --conflict=local`,
Args: validatedArgs(cobra.NoArgs),
RunE: runPrompt(client, func(cmd *cobra.Command, _ []string) (syncprompt.CommandAction, error) {
dryRun, _ := cmd.Flags().GetBool(dryRunFlag)
return syncprompt.SyncAction{DryRun: dryRun}, nil
return syncprompt.SyncAction{DryRun: dryRun, Filter: targetFilter(cmd)}, nil
}),
}

addTargetFlags(cmd, filterFlagHelp)
addApplyFlags(cmd, true)
cmd.AddCommand(
newWatchCmd(client),
Expand Down Expand Up @@ -129,10 +128,11 @@ func newWatchCmd(client resources.Client) *cobra.Command {
# Apply watched changes without prompting and report unresolved conflicts
ldcli sync prompts watch --yes --no-input`,
Args: validatedArgs(cobra.NoArgs),
RunE: runPrompt(client, func(*cobra.Command, []string) (syncprompt.CommandAction, error) {
return syncprompt.SyncAction{Watch: true}, nil
RunE: runPrompt(client, func(cmd *cobra.Command, _ []string) (syncprompt.CommandAction, error) {
return syncprompt.SyncAction{Watch: true, Filter: targetFilter(cmd)}, nil
}),
}
addTargetFlags(cmd, filterFlagHelp)
addApplyFlags(cmd, false)
return cmd
}
Expand All @@ -144,21 +144,22 @@ func newAddCmd(client resources.Client) *cobra.Command {
Example: ` # Choose variations interactively
ldcli sync prompts add

# Add one variation by its full selector
ldcli sync prompts add production/support/default
# Choose variations in one config
ldcli sync prompts add --project production --config-key support

# Add multiple variations without prompting
ldcli sync prompts add production/support/default production/chat/concise --no-input`,
# Add variations by key without prompting
ldcli sync prompts add --project production --config-key support --key default --key concise --no-input`,
Args: validatedArgs(cobra.MinimumNArgs(0)),
RunE: runPrompt(client, func(cmd *cobra.Command, args []string) (syncprompt.CommandAction, error) {
variations, err := parseVariationSelectors(args)
if err != nil {
return nil, err
}
dryRun, _ := cmd.Flags().GetBool(dryRunFlag)
return syncprompt.AddAction{Variations: variations, DryRun: dryRun}, nil
return syncprompt.AddAction{Variations: variations, Filter: targetFilter(cmd), DryRun: dryRun}, nil
}),
}
addTargetFlags(cmd, questionFlagHelp)
cmd.Flags().Bool(dryRunFlag, false, "Preview local files without creating them")
cmd.Flags().Bool(diffFlag, false, "Show full diffs and file content in the plan")
cmd.Flags().Bool(noInputFlag, false, "Fail instead of prompting for missing input")
Expand Down Expand Up @@ -203,9 +204,10 @@ func newAttachKindCmd(client resources.Client, kind syncdomain.AttachmentKind) *
if len(args) == 1 {
key = args[0]
}
return syncprompt.AttachAction{Kind: kind, Key: key, Target: target}, nil
return syncprompt.AttachAction{Kind: kind, Key: key, ProjectKey: targetFilter(cmd).Project, Target: target}, nil
}),
}
addTargetFlags(cmd, targetFlagHelp{project: questionFlagHelp.project})
cmd.Flags().String(toFlag, "", "Target variation (project-key/config-key/variation-key)")
addApplyFlags(cmd, false)
return cmd
Expand All @@ -219,20 +221,21 @@ func newDetachCmd(client resources.Client) *cobra.Command {
ldcli sync prompts detach

# Stop syncing one variation
ldcli sync prompts detach production/support/default
ldcli sync prompts detach --project production --config-key support --key default

# Stop syncing one variation and archive it in LaunchDarkly
ldcli sync prompts detach production/support/default --archive --yes --no-input`,
ldcli sync prompts detach --project production --config-key support --key default --archive --yes --no-input`,
Args: validatedArgs(cobra.MinimumNArgs(0)),
RunE: runPrompt(client, func(cmd *cobra.Command, args []string) (syncprompt.CommandAction, error) {
variations, err := parseVariationSelectors(args)
if err != nil {
return nil, err
}
archive, _ := cmd.Flags().GetBool(archiveFlag)
return syncprompt.DetachAction{Variations: variations, Archive: archive}, nil
return syncprompt.DetachAction{Variations: variations, Filter: targetFilter(cmd), Archive: archive}, nil
}),
}
addTargetFlags(cmd, questionFlagHelp)
cmd.Flags().Bool(archiveFlag, false, "Also archive the variations in LaunchDarkly")
cmd.Flags().Bool(yesFlag, false, "Archive without confirmation")
cmd.Flags().Bool(noInputFlag, false, "Fail instead of prompting for missing input")
Expand All @@ -256,9 +259,10 @@ func newLinkCmd(client resources.Client) *cobra.Command {
if err != nil {
return nil, err
}
return syncprompt.LinkAction{File: args[0], Format: format, Target: target}, nil
return syncprompt.LinkAction{File: args[0], Format: format, ProjectKey: targetFilter(cmd).Project, Target: target}, nil
}),
}
addTargetFlags(cmd, targetFlagHelp{project: questionFlagHelp.project})
cmd.Flags().String(formatFlag, "", "Format adapter (for example, plain-markdown)")
cmd.Flags().String(toFlag, "", "New variation (project-key/config-key/variation-key)")
cmd.Flags().String(modelConfigKeyFlag, "", "Model config key for the new variation")
Expand All @@ -280,13 +284,83 @@ func addApplyFlags(cmd *cobra.Command, dryRun bool) {
cmd.Flags().Bool(noInputFlag, false, "Fail instead of prompting for missing input")
}

// targetFlagHelp is the help text of the target flags of one command. An
// empty text leaves out its flag.
type targetFlagHelp struct {
project string
configKey string
key string
}

var (
// filterFlagHelp describes target flags that limit a sync.
filterFlagHelp = targetFlagHelp{
project: "Sync only this project",
configKey: "Sync only this config. Needs --project.",
key: "Sync only this variation. Repeat for more variations. Needs --config-key.",
}
// questionFlagHelp describes target flags that answer the questions of a command.
questionFlagHelp = targetFlagHelp{
project: "Project key. Skips the project question.",
configKey: "Config key. Skips the config question. Needs --project.",
key: "Variation key. Repeat for more variations. Needs --config-key.",
}
)

// addTargetFlags registers --project, --config-key, and --key.
func addTargetFlags(cmd *cobra.Command, help targetFlagHelp) {
cmd.Flags().String(cliflags.ProjectFlag, "", help.project)
if help.configKey != "" {
cmd.Flags().String(configKeyFlag, "", help.configKey)
}
if help.key != "" {
cmd.Flags().StringArray(keyFlag, nil, help.key)
}
}

// targetFilter reads the target flags of a command. A flag that the command
// does not have is empty.
func targetFilter(cmd *cobra.Command) syncdomain.Filter {
project, _ := cmd.Flags().GetString(cliflags.ProjectFlag)
configKey, _ := cmd.Flags().GetString(configKeyFlag)
keys, _ := cmd.Flags().GetStringArray(keyFlag)
return syncdomain.Filter{Project: project, ConfigKey: configKey, Keys: keys}
}

// validatedArgs checks the positional arguments and runs the shared ldcli
// validation. That validation also copies each LD_* environment variable and
// ldcli configuration value into the flag with the same name. Sync flags come
// only from the command line, so validatedArgs then resets each sync flag that
// the command line did not set. Global flags, such as --access-token, keep
// their environment and configuration values.
func validatedArgs(validate cobra.PositionalArgs) cobra.PositionalArgs {
return func(cmd *cobra.Command, args []string) error {
if err := validate(cmd, args); err != nil {
return err
}
return validators.Validate()(cmd, args)
syncFlags := cmd.LocalNonPersistentFlags()
fromCommandLine := make(map[string]bool)
syncFlags.VisitAll(func(flag *pflag.Flag) { fromCommandLine[flag.Name] = flag.Changed })
if err := validators.Validate()(cmd, args); err != nil {
return err
}
syncFlags.VisitAll(func(flag *pflag.Flag) {
if flag.Changed && !fromCommandLine[flag.Name] {
resetFlag(flag)
}
})
return nil
}
}

// resetFlag gives a flag its default value and marks it as not set.
func resetFlag(flag *pflag.Flag) {
if values, ok := flag.Value.(pflag.SliceValue); ok {
_ = values.Replace(nil)
} else {
_ = flag.Value.Set(flag.DefValue)
}
flag.Changed = false
}

func parseVariationSelectors(values []string) ([]syncdomain.ResourceID, error) {
Expand Down
72 changes: 72 additions & 0 deletions cmd/sync/prompt_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package sync

import (
"strings"
"testing"

"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/launchdarkly/ldcli/cmd/cliflags"
syncdomain "github.com/launchdarkly/ldcli/internal/sync"
syncprompt "github.com/launchdarkly/ldcli/internal/sync/prompt"
)
Expand Down Expand Up @@ -91,6 +94,75 @@ func TestPromptCommandsRegisterDiffFlag(t *testing.T) {
}
}

func TestPromptCommandsRegisterTargetFlags(t *testing.T) {
command := NewPromptCmd(nil)
tests := map[string]struct {
path []string
help targetFlagHelp
}{
"prompts": {help: filterFlagHelp},
"watch": {path: []string{"watch"}, help: filterFlagHelp},
"add": {path: []string{"add"}, help: questionFlagHelp},
"detach": {path: []string{"detach"}, help: questionFlagHelp},
"attach tool": {path: []string{"attach", "tool"}, help: targetFlagHelp{project: questionFlagHelp.project}},
"link": {path: []string{"link"}, help: targetFlagHelp{project: questionFlagHelp.project}},
}

for name, test := range tests {
t.Run(name, func(t *testing.T) {
target := command
if len(test.path) != 0 {
var err error
target, _, err = command.Find(test.path)
require.NoError(t, err)
}

usage := func(name string) string {
if flag := target.Flags().Lookup(name); flag != nil {
return flag.Usage
}
return ""
}
assert.Equal(t, test.help, targetFlagHelp{
project: usage(cliflags.ProjectFlag), configKey: usage(configKeyFlag), key: usage(keyFlag),
})
assert.NotContains(t, target.Example, "production/support/default --", "examples use target flags")
})
}
}

// Sync flags come only from the command line. The shared ldcli validation
// copies environment variables and configuration values into every flag, so
// validatedArgs must undo that for the sync flags.
func TestValidatedArgsKeepsSyncFlagsFromTheCommandLineOnly(t *testing.T) {
t.Cleanup(viper.Reset)
viper.Reset()
viper.SetEnvPrefix("LD")
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
viper.AutomaticEnv()
viper.SetConfigType("yaml")
require.NoError(t, viper.ReadConfig(strings.NewReader("project: from-config\nyes: true\noutput: json\n")))
t.Setenv("LD_BASE_URI", "https://example.test")
t.Setenv("LD_CONFLICT", "bogus")
t.Setenv("LD_KEY", "from-environment")
t.Setenv("LD_NO_INPUT", "true")
t.Setenv("LD_RESOLVE", "demo/support/default=local")

command := NewPromptCmd(nil)
require.NoError(t, command.ParseFlags([]string{"--dry-run", "--config-key", "support"}))
require.NoError(t, command.Args(command, nil))

for name, want := range map[string]string{
cliflags.ProjectFlag: "", yesFlag: "false", conflictFlag: "", keyFlag: "[]", noInputFlag: "false",
resolveFlag: "[]", dryRunFlag: "true", configKeyFlag: "support",
} {
flag := command.Flags().Lookup(name)
assert.Equal(t, want, flag.Value.String(), name)
assert.Equal(t, want != flag.DefValue, flag.Changed, name)
}
assert.Equal(t, "https://example.test", viper.GetString(cliflags.BaseURIFlag), "global flags keep environment values")
}

func TestAttachSubcommandsAcceptOptionalKeyAndTarget(t *testing.T) {
command := NewPromptCmd(nil)
tool, _, err := command.Find([]string{"attach", "tool"})
Expand Down
38 changes: 32 additions & 6 deletions internal/sync/bootstrap/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ type Options struct {
DryRun bool
// Diff shows the content of each file that a dry run lists.
Diff bool
// ProjectKey and ConfigKey skip the project and the config questions.
ProjectKey string
ConfigKey string
// Selections are the variations to add. If it is empty, Run asks the user.
Selections []syncdomain.ResourceID
NoInput bool
Expand All @@ -58,7 +61,7 @@ func Run(options Options) error {
files, err = selectedVariations(options)
} else {
if err := syncinteractive.RequireTerminal(
options.Input, options.Output, options.NoInput, "variation selectors", "prompt selection",
options.Input, options.Output, options.NoInput, missingTargetFlags(options), "prompt selection",
); err != nil {
return err
}
Expand Down Expand Up @@ -124,14 +127,37 @@ func selectedVariations(options Options) ([]synclocal.VariationFile, error) {
return files, nil
}

// missingTargetFlags names the flags that answer the questions that Run still
// has to ask.
func missingTargetFlags(options Options) string {
switch {
case options.ProjectKey == "":
return "--project, --config-key, and --key"
case options.ConfigKey == "":
return "--config-key and --key"
default:
return "--key values"
}
}

// promptForVariations asks the user for a project, a config, and one or more
// variations. The bool result is true when the user cancels.
// variations. ProjectKey and ConfigKey skip their questions. The bool result
// is true when the user cancels.
func promptForVariations(options Options) ([]synclocal.VariationFile, bool, error) {
project, canceled, err := syncinteractive.SelectProject(options.Input, options.Output, options.Catalog)
if err != nil || canceled {
return nil, canceled, err
project := syncapi.Project{Key: options.ProjectKey}
var canceled bool
var err error
if project.Key == "" {
if project, canceled, err = syncinteractive.SelectProject(options.Input, options.Output, options.Catalog); err != nil || canceled {
return nil, canceled, err
}
}
config := syncapi.Config{Key: options.ConfigKey}
if config.Key == "" {
config, canceled, err = syncinteractive.SelectConfig(options.Input, options.Output, options.Catalog, project.Key, nil)
} else {
config, err = options.Catalog.Config(project.Key, config.Key)
}
config, canceled, err := syncinteractive.SelectConfig(options.Input, options.Output, options.Catalog, project.Key, nil)
if err != nil || canceled {
return nil, canceled, err
}
Expand Down
Loading
Loading