Skip to content
Open
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
38 changes: 38 additions & 0 deletions cmd/sync/all.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package sync

import (
"github.com/spf13/cobra"

resourcescmd "github.com/launchdarkly/ldcli/cmd/resources"
"github.com/launchdarkly/ldcli/internal/resources"
syncdomain "github.com/launchdarkly/ldcli/internal/sync"
)

// NewAllCmd creates the command that syncs prompt variations, tools, and
// skills in one plan.
func NewAllCmd(client resources.Client) *cobra.Command {
help := targetFlagHelp{project: filterFlagHelp.project}
cmd := &cobra.Command{
Use: "all",
Short: "Synchronize prompt variations, tools, and skills with LaunchDarkly",
Long: "Synchronize prompt variations, tools, and skills with LaunchDarkly in one plan. " +
"Sync applies tools and skills before the prompt variations that use them. " +
"One conflict choice covers every conflict. " +
"To choose for one resource, use sync prompts, sync tools, or sync skills with --key.",
Example: ` # Preview every change
ldcli sync all --dry-run

# Apply every change without prompting
ldcli sync all --yes --no-input

# Keep the local side of every conflict in one project
ldcli sync all --project production --conflict=local --yes`,
Args: validatedArgs(cobra.NoArgs),
RunE: runCommand(client, syncdomain.ScopeAll, buildSyncAction),
}
addTargetFlags(cmd, help)
addApplyFlags(cmd, syncdomain.ScopeAll, true)
cmd.AddCommand(newWatchCmd(client, syncdomain.ScopeAll, help))
cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate())
return cmd
}
114 changes: 114 additions & 0 deletions cmd/sync/attachments.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package sync

import (
"fmt"

"github.com/spf13/cobra"

resourcescmd "github.com/launchdarkly/ldcli/cmd/resources"
"github.com/launchdarkly/ldcli/internal/resources"
syncdomain "github.com/launchdarkly/ldcli/internal/sync"
syncprompt "github.com/launchdarkly/ldcli/internal/sync/prompt"
)

// attachmentExamples are the keys that the help of a tool or skill command
// uses in its examples.
var attachmentExamples = map[syncdomain.AttachmentKind][2]string{
syncdomain.AttachmentTool: {"search", "lookup-order"},
syncdomain.AttachmentSkill: {"summarize", "triage"},
}

// newAttachmentScopeCmd creates the command that syncs the tools or the
// skills of the repository, with its watch, add, and detach commands. They
// run the same sync as sync prompts, in the scope of kind.
func newAttachmentScopeCmd(client resources.Client, kind syncdomain.AttachmentKind) *cobra.Command {
scope := syncdomain.Scope(kind + "s")
key := attachmentExamples[kind][0]
cmd := &cobra.Command{
Use: string(scope),
Short: fmt.Sprintf("Synchronize local %s files with LaunchDarkly", kind),
Long: attachmentScopeLong(kind),
Example: fmt.Sprintf(` # Preview %[1]s changes
ldcli sync %[2]s --dry-run

# Apply %[1]s changes without prompting
ldcli sync %[2]s --yes --no-input

# Keep the local version of one %[1]s when it conflicts with LaunchDarkly
ldcli sync %[2]s --project production --key %[3]s --conflict=local --yes`, kind, scope, key),
Args: validatedArgs(cobra.NoArgs),
RunE: runCommand(client, scope, buildSyncAction),
}
help := attachmentFlagHelp(kind, true)
addTargetFlags(cmd, help)
addApplyFlags(cmd, scope, true)
cmd.AddCommand(
newWatchCmd(client, scope, help),
newAttachmentAddCmd(client, kind),
newAttachmentDetachCmd(client, kind),
)
cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate())
return cmd
}

// attachmentScopeLong is the description of sync tools or sync skills.
func attachmentScopeLong(kind syncdomain.AttachmentKind) string {
upsert := `set "upsert": true in its file`
name := ""
if kind == syncdomain.AttachmentSkill {
upsert = "set upsert: true in its front matter"
name = " A skill that sync creates from a file takes its name from name: in the file, or from its key."
}
return fmt.Sprintf("Synchronize local %[1]s files with LaunchDarkly. A %[1]s syncs on its own, also when no prompt variation uses it. "+
"To create a %[1]s, %[2]s.%[3]s Sync rechecks state before every write. If the state changed, run sync again.", kind, upsert, name)
}

func newAttachmentAddCmd(client resources.Client, kind syncdomain.AttachmentKind) *cobra.Command {
scope := syncdomain.Scope(kind + "s")
keys := attachmentExamples[kind]
cmd := &cobra.Command{
Use: "add",
Short: fmt.Sprintf("Add %ss from LaunchDarkly", kind),
Long: fmt.Sprintf("Add %[1]ss from LaunchDarkly. Add writes the file of each %[1]s and records it in sync.lock and "+
"in the LaunchDarkly manifest. Each %[1]s must exist in LaunchDarkly.", kind),
Example: fmt.Sprintf(` # Choose a project and a %[1]s interactively
ldcli sync %[2]s add

# Add %[1]ss by key without prompting
ldcli sync %[2]s add --project production --key %[3]s --key %[4]s --no-input`, kind, scope, keys[0], keys[1]),
Args: validatedArgs(cobra.NoArgs),
RunE: runCommand(client, scope, func(cmd *cobra.Command, _ []string) (syncprompt.CommandAction, error) {
dryRun, _ := cmd.Flags().GetBool(dryRunFlag)
return syncprompt.AddAction{Filter: targetFilter(cmd), DryRun: dryRun}, nil
}),
}
addTargetFlags(cmd, attachmentFlagHelp(kind, false))
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")
return cmd
}

func newAttachmentDetachCmd(client resources.Client, kind syncdomain.AttachmentKind) *cobra.Command {
scope := syncdomain.Scope(kind + "s")
cmd := &cobra.Command{
Use: "detach",
Short: fmt.Sprintf("Stop syncing local %ss", kind),
Long: fmt.Sprintf("Stop syncing local %[1]ss. Detach deletes the local %[1]s file and its sync.lock entry. "+
"LaunchDarkly keeps the %[1]s. Detach cannot remove a %[1]s that a synced prompt variation uses. "+
"Remove it from the variation first. Detach asks before it deletes files. Without a terminal, pass --yes.", kind),
Example: fmt.Sprintf(` # Choose %[1]ss interactively
ldcli sync %[2]s detach

# Stop syncing one %[1]s without prompting
ldcli sync %[2]s detach --project production --key %[3]s --yes`, kind, scope, attachmentExamples[kind][0]),
Args: validatedArgs(cobra.NoArgs),
RunE: runCommand(client, scope, func(cmd *cobra.Command, _ []string) (syncprompt.CommandAction, error) {
return syncprompt.DetachAction{Filter: targetFilter(cmd)}, nil
}),
}
addTargetFlags(cmd, attachmentFlagHelp(kind, false))
cmd.Flags().Bool(yesFlag, false, "Detach without confirmation")
cmd.Flags().Bool(noInputFlag, false, "Fail instead of prompting for missing input")
return cmd
}
76 changes: 53 additions & 23 deletions cmd/sync/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ func NewPromptCmd(client resources.Client) *cobra.Command {
Use: "prompts",
Short: "Synchronize local prompt variations with LaunchDarkly",
Long: "Bootstrap local prompt variations from LaunchDarkly, add more variations, or synchronize changes using the LaunchDarkly manifest. " +
"Sync rechecks state before every write, rerun sync after a change.",
"Sync rechecks state before every write, rerun sync after a change. " +
"Tool and skill files that no prompt variation uses are not in this plan. Use ldcli sync all, ldcli sync tools, or ldcli sync skills to sync them.",
Example: ` # Preview synchronization changes
ldcli sync prompts --dry-run

Expand All @@ -55,16 +56,13 @@ func NewPromptCmd(client resources.Client) *cobra.Command {
# 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, Filter: targetFilter(cmd)}, nil
}),
RunE: runCommand(client, syncdomain.ScopePrompts, buildSyncAction),
}

addTargetFlags(cmd, filterFlagHelp)
addApplyFlags(cmd, true)
addApplyFlags(cmd, syncdomain.ScopePrompts, true)
cmd.AddCommand(
newWatchCmd(client),
newWatchCmd(client, syncdomain.ScopePrompts, filterFlagHelp),
newAddCmd(client),
newAttachCmd(client),
newDetachCmd(client),
Expand All @@ -77,7 +75,15 @@ func NewPromptCmd(client resources.Client) *cobra.Command {

type actionBuilder func(*cobra.Command, []string) (syncprompt.CommandAction, error)

func runPrompt(client resources.Client, buildAction actionBuilder) func(*cobra.Command, []string) error {
// buildSyncAction reads the flags of a root sync command.
func buildSyncAction(cmd *cobra.Command, _ []string) (syncprompt.CommandAction, error) {
dryRun, _ := cmd.Flags().GetBool(dryRunFlag)
return syncprompt.SyncAction{DryRun: dryRun, Filter: targetFilter(cmd)}, nil
}

// runCommand returns the RunE function of a sync command. It runs the action
// that buildAction reads from the flags, in the scope of the command.
func runCommand(client resources.Client, scope syncdomain.Scope, buildAction actionBuilder) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
workingDirectory, err := os.Getwd()
if err != nil {
Expand All @@ -101,6 +107,7 @@ func runPrompt(client resources.Client, buildAction actionBuilder) func(*cobra.C
AccessToken: viper.GetString(cliflags.AccessTokenFlag),
BaseURI: viper.GetString(cliflags.BaseURIFlag),
OutputKind: outputKind,
Scope: scope,
Action: action,
ConflictPolicy: policy,
Yes: yes,
Expand All @@ -118,22 +125,26 @@ func runPrompt(client resources.Client, buildAction actionBuilder) func(*cobra.C
}
}

func newWatchCmd(client resources.Client) *cobra.Command {
// newWatchCmd creates the watch command of a scope. help is the help text of
// the target flags of the root sync of the scope.
func newWatchCmd(client resources.Client, scope syncdomain.Scope, help targetFlagHelp) *cobra.Command {
cmd := &cobra.Command{
Use: "watch",
Short: "Synchronize when managed files change",
Example: ` # Watch files and confirm each set of changes
ldcli sync prompts watch
Long: "Synchronize each time a managed file changes. " +
"Watch creates a resource from a file with upsert when the file is saved.",
Example: fmt.Sprintf(` # Watch files and confirm each set of changes
ldcli sync %s watch

# Apply watched changes without prompting and report unresolved conflicts
ldcli sync prompts watch --yes --no-input`,
ldcli sync %s watch --yes --no-input`, scope, scope),
Args: validatedArgs(cobra.NoArgs),
RunE: runPrompt(client, func(cmd *cobra.Command, _ []string) (syncprompt.CommandAction, error) {
RunE: runCommand(client, scope, func(cmd *cobra.Command, _ []string) (syncprompt.CommandAction, error) {
return syncprompt.SyncAction{Watch: true, Filter: targetFilter(cmd)}, nil
}),
}
addTargetFlags(cmd, filterFlagHelp)
addApplyFlags(cmd, false)
addTargetFlags(cmd, help)
addApplyFlags(cmd, scope, false)
return cmd
}

Expand All @@ -150,7 +161,7 @@ func newAddCmd(client resources.Client) *cobra.Command {
# 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) {
RunE: runCommand(client, syncdomain.ScopePrompts, func(cmd *cobra.Command, args []string) (syncprompt.CommandAction, error) {
variations, err := parseVariationSelectors(args)
if err != nil {
return nil, err
Expand Down Expand Up @@ -195,7 +206,7 @@ func newAttachKindCmd(client resources.Client, kind syncdomain.AttachmentKind) *
--to production/support/default \
--yes --no-input`, kind, kind, kind, kind),
Args: validatedArgs(cobra.MaximumNArgs(1)),
RunE: runPrompt(client, func(cmd *cobra.Command, args []string) (syncprompt.CommandAction, error) {
RunE: runCommand(client, syncdomain.ScopePrompts, func(cmd *cobra.Command, args []string) (syncprompt.CommandAction, error) {
target, err := optionalVariationFlag(cmd, toFlag)
if err != nil {
return nil, err
Expand All @@ -209,7 +220,7 @@ func newAttachKindCmd(client resources.Client, kind syncdomain.AttachmentKind) *
}
addTargetFlags(cmd, targetFlagHelp{project: questionFlagHelp.project})
cmd.Flags().String(toFlag, "", "Target variation (project-key/config-key/variation-key)")
addApplyFlags(cmd, false)
addApplyFlags(cmd, syncdomain.ScopePrompts, false)
return cmd
}

Expand All @@ -229,7 +240,7 @@ func newDetachCmd(client resources.Client) *cobra.Command {
# Stop syncing one variation and archive it in LaunchDarkly
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) {
RunE: runCommand(client, syncdomain.ScopePrompts, func(cmd *cobra.Command, args []string) (syncprompt.CommandAction, error) {
variations, err := parseVariationSelectors(args)
if err != nil {
return nil, err
Expand All @@ -256,7 +267,7 @@ func newLinkCmd(client resources.Client) *cobra.Command {
--model-config-key claude \
--yes --no-input`,
Args: validatedArgs(cobra.ExactArgs(1)),
RunE: runPrompt(client, func(cmd *cobra.Command, args []string) (syncprompt.CommandAction, error) {
RunE: runCommand(client, syncdomain.ScopePrompts, func(cmd *cobra.Command, args []string) (syncprompt.CommandAction, error) {
format, _ := cmd.Flags().GetString(formatFlag)
target, err := linkTarget(cmd)
if err != nil {
Expand All @@ -272,17 +283,21 @@ func newLinkCmd(client resources.Client) *cobra.Command {
cmd.Flags().String(nameFlag, "", "Variation name when absent from the linked file")
cmd.Flags().String(contentFlag, "", "Prompt content when absent from the linked file")
_ = cmd.MarkFlagRequired(formatFlag)
addApplyFlags(cmd, false)
addApplyFlags(cmd, syncdomain.ScopePrompts, false)
return cmd
}

func addApplyFlags(cmd *cobra.Command, dryRun bool) {
// addApplyFlags registers the flags of a command that applies a plan. Only
// the prompts scope has --resolve, which names variations.
func addApplyFlags(cmd *cobra.Command, scope syncdomain.Scope, dryRun bool) {
if dryRun {
cmd.Flags().Bool(dryRunFlag, false, "Preview synchronization changes without applying them")
}
cmd.Flags().Bool(diffFlag, false, "Show full diffs in the plan")
cmd.Flags().String(conflictFlag, "", "Default conflict resolution: launchdarkly, local, or abort")
cmd.Flags().StringArray(resolveFlag, nil, "Resolve one variation conflict (project-key/config-key/variation-key=choice)")
if scope == syncdomain.ScopePrompts {
cmd.Flags().StringArray(resolveFlag, nil, "Resolve one variation conflict (project-key/config-key/variation-key=choice)")
}
cmd.Flags().Bool(yesFlag, false, "Apply changes without confirmation")
cmd.Flags().Bool(noInputFlag, false, "Fail instead of prompting for missing input")
}
Expand Down Expand Up @@ -310,6 +325,21 @@ var (
}
)

// attachmentFlagHelp describes the target flags of a tool or skill command.
// With filter, the flags limit a sync. Otherwise they answer questions.
func attachmentFlagHelp(kind syncdomain.AttachmentKind, filter bool) targetFlagHelp {
if filter {
return targetFlagHelp{
project: filterFlagHelp.project,
key: fmt.Sprintf("Sync only this %s. Repeat for more %ss. Needs --project.", kind, kind),
}
}
return targetFlagHelp{
project: questionFlagHelp.project,
key: fmt.Sprintf("%s key. Repeat for more %ss. Needs --project.", strings.ToUpper(string(kind[:1]))+string(kind[1:]), kind),
}
}

// addTargetFlags registers --project, --config-key, and --key.
func addTargetFlags(cmd *cobra.Command, help targetFlagHelp) {
cmd.Flags().String(cliflags.ProjectFlag, "", help.project)
Expand Down
9 changes: 8 additions & 1 deletion cmd/sync/prompt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,14 @@ func TestPromptCommandUsesActionSubcommands(t *testing.T) {
func TestSyncCommandIncludesExamples(t *testing.T) {
command := NewSyncCmd(nil, nil)

assert.Contains(t, command.Example, "ldcli sync prompts")
assert.Contains(t, command.Example, "ldcli sync all\n")
assert.Contains(t, command.Example, "ldcli sync all --dry-run")
assert.Contains(t, command.Example, "ldcli sync tools")
for _, name := range []string{"all", "prompts", "tools", "skills"} {
child, _, err := command.Find([]string{name})
require.NoError(t, err)
assert.Equal(t, name, child.Name())
}
}

func TestPromptCommandHelpIncludesExamples(t *testing.T) {
Expand Down
Loading
Loading