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
45 changes: 26 additions & 19 deletions cmd/sync/prompt.go
Comment thread
tonytrinh3 marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
)

const (
archiveFlag = "archive"
conflictFlag = "conflict"
contentFlag = "content"
dryRunFlag = "dry-run"
Expand All @@ -34,21 +35,21 @@ const (
// NewPromptCmd creates the prompt synchronization command.
func NewPromptCmd(client resources.Client) *cobra.Command {
cmd := &cobra.Command{
Use: "prompt",
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.",
Example: ` # Preview synchronization changes
ldcli sync prompt --dry-run
ldcli sync prompts --dry-run

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

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

# Prefer local changes when conflicts occur
ldcli sync prompt --yes --conflict=local`,
ldcli sync prompts --yes --conflict=local`,
Args: func(cmd *cobra.Command, args []string) error {
if err := cobra.NoArgs(cmd, args); err != nil {
return err
Expand Down Expand Up @@ -120,10 +121,10 @@ func newWatchCmd(client resources.Client) *cobra.Command {
Use: "watch",
Short: "Synchronize when managed files change",
Example: ` # Watch files and confirm each set of changes
ldcli sync prompt watch
ldcli sync prompts watch

# Apply watched changes without prompting and report unresolved conflicts
ldcli sync prompt watch --yes --no-input`,
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
Expand All @@ -138,13 +139,13 @@ func newAddCmd(client resources.Client) *cobra.Command {
Use: "add [project-key/config-key/variation-key...]",
Short: "Add prompt variations from LaunchDarkly",
Example: ` # Choose variations interactively
ldcli sync prompt add
ldcli sync prompts add

# Add one variation by its full selector
ldcli sync prompt add production/support/default
ldcli sync prompts add production/support/default

# Add multiple variations without prompting
ldcli sync prompt add production/support/default production/chat/concise --no-input`,
ldcli sync prompts add production/support/default production/chat/concise --no-input`,
Args: validatedArgs(cobra.MinimumNArgs(0)),
RunE: runPrompt(client, func(cmd *cobra.Command, args []string) (syncprompt.CommandAction, error) {
variations, err := parseVariationSelectors(args)
Expand All @@ -165,10 +166,10 @@ func newAttachCmd(client resources.Client) *cobra.Command {
Use: "attach",
Short: "Attach a tool or skill to a synced variation",
Example: ` # Attach a tool
ldcli sync prompt attach tool search --to production/support/default --yes
ldcli sync prompts attach tool search --to production/support/default --yes

# Attach a skill
ldcli sync prompt attach skill summarize --to production/support/default --yes`,
ldcli sync prompts attach skill summarize --to production/support/default --yes`,
}
cmd.AddCommand(
newAttachKindCmd(client, syncdomain.AttachmentTool),
Expand All @@ -182,10 +183,10 @@ func newAttachKindCmd(client resources.Client, kind syncdomain.AttachmentKind) *
Use: string(kind) + " [key]",
Short: "Attach a " + string(kind) + " to a synced variation",
Example: fmt.Sprintf(` # Choose a %s and synced variation interactively
ldcli sync prompt attach %s
ldcli sync prompts attach %s

# Attach a %s by key to a synced variation
ldcli sync prompt attach %s example-key \
ldcli sync prompts attach %s example-key \
--to production/support/default \
--yes --no-input`, kind, kind, kind, kind),
Args: validatedArgs(cobra.MaximumNArgs(1)),
Expand All @@ -211,19 +212,25 @@ func newDetachCmd(client resources.Client) *cobra.Command {
Use: "detach [project-key/config-key/variation-key...]",
Short: "Stop syncing local prompt variations",
Example: ` # Choose variations interactively
ldcli sync prompt detach
ldcli sync prompts detach

# Stop syncing one variation
ldcli sync prompt detach production/support/default`,
ldcli sync prompts detach production/support/default

# Stop syncing one variation and archive it in LaunchDarkly
ldcli sync prompts detach production/support/default --archive --yes --no-input`,
Args: validatedArgs(cobra.MinimumNArgs(0)),
RunE: runPrompt(client, func(_ *cobra.Command, args []string) (syncprompt.CommandAction, error) {
RunE: runPrompt(client, func(cmd *cobra.Command, args []string) (syncprompt.CommandAction, error) {
variations, err := parseVariationSelectors(args)
if err != nil {
return nil, err
}
return syncprompt.DetachAction{Variations: variations}, nil
archive, _ := cmd.Flags().GetBool(archiveFlag)
return syncprompt.DetachAction{Variations: variations, Archive: archive}, nil
}),
}
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")
return cmd
}
Expand All @@ -233,7 +240,7 @@ func newLinkCmd(client resources.Client) *cobra.Command {
Use: "link <file>",
Short: "Link an external prompt file",
Example: ` # Link a Markdown file as a new variation
ldcli sync prompt link prompts/support.md \
ldcli sync prompts link prompts/support.md \
--format plain-markdown \
--to production/support/default \
--model-config-key claude \
Expand Down
8 changes: 4 additions & 4 deletions cmd/sync/prompt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
func TestPromptCommandUsesActionSubcommands(t *testing.T) {
command := NewPromptCmd(nil)

assert.Equal(t, "prompt", command.Use)
assert.Equal(t, "prompts", command.Use)
for _, name := range []string{"watch", "add", "attach", "detach", "link"} {
child, _, err := command.Find([]string{name})
require.NoError(t, err)
Expand All @@ -34,13 +34,13 @@ func TestPromptCommandUsesActionSubcommands(t *testing.T) {
func TestSyncCommandIncludesExamples(t *testing.T) {
command := NewSyncCmd(nil, nil)

assert.Contains(t, command.Example, "ldcli sync prompt")
assert.Contains(t, command.Example, "ldcli sync prompts")
}

func TestPromptCommandHelpIncludesExamples(t *testing.T) {
command := NewPromptCmd(nil)
tests := map[string][]string{
"prompt": {},
"prompts": {},
"watch": {"watch"},
"add": {"add"},
"attach": {"attach"},
Expand All @@ -59,7 +59,7 @@ func TestPromptCommandHelpIncludesExamples(t *testing.T) {
require.NoError(t, err)
}

assert.Contains(t, target.Example, "ldcli sync prompt")
assert.Contains(t, target.Example, "ldcli sync prompts")
})
}
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/sync/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ func NewSyncCmd(client resources.Client, analyticsTrackerFn analytics.TrackerFn)
Use: "sync",
Short: "Synchronize local resources with LaunchDarkly",
Example: ` # Synchronize prompt variations
ldcli sync prompt
ldcli sync prompts

# Preview prompt synchronization changes
ldcli sync prompt --dry-run`,
ldcli sync prompts --dry-run`,
Args: cobra.MinimumNArgs(1),
PersistentPreRun: func(cmd *cobra.Command, _ []string) {
tracker := analyticsTrackerFn(
Expand Down
107 changes: 102 additions & 5 deletions internal/sync/detach/detach.go
Comment thread
ctawiah marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,20 +1,31 @@
// Package detach stops syncing selected variations. It removes their local
// files and their baseline. LaunchDarkly keeps the variations.
// files and their baseline. LaunchDarkly keeps the variations, unless the
// user asks to archive them.
package detach

import (
"context"
"errors"
"fmt"
"io"
"slices"

syncdomain "github.com/launchdarkly/ldcli/internal/sync"
syncapi "github.com/launchdarkly/ldcli/internal/sync/api"
syncconsole "github.com/launchdarkly/ldcli/internal/sync/console"
syncinteractive "github.com/launchdarkly/ldcli/internal/sync/interactive"
synclocal "github.com/launchdarkly/ldcli/internal/sync/local"
syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest"
)

const archiveQuestion = "\nArchive these variations in LaunchDarkly? [y/N] "

// Archiver reads and archives a variation in LaunchDarkly.
type Archiver interface {
ReadVariation(projectKey, configKey, variationKey string) (syncapi.VariationState, error)
ArchiveVariation(projectKey, configKey, variationKey string) error
}

// Options are the dependencies and the input of one detach.
type Options struct {
RepositoryRoot string
Expand All @@ -27,6 +38,24 @@ type Options struct {
// Selections are the variations to detach. If it is empty, Run asks the user.
Selections []syncdomain.ResourceID
NoInput bool
// Archive also archives each selected variation in LaunchDarkly. Without
// Yes, the user must confirm the archive.
Archive bool
Archiver Archiver
Yes bool
// Context stops the archive confirmation and the archive when it ends.
Context context.Context

// isTerminal reports whether the streams are a terminal. Tests replace it.
isTerminal func(io.Reader, io.Writer) bool
}

// context returns the context of the command, or a context that never ends.
func (options Options) context() context.Context {
if options.Context == nil {
return context.Background()
}
return options.Context
}

// Run detaches the selected variations.
Expand Down Expand Up @@ -60,11 +89,26 @@ func Run(options Options) error {
return err
}
}
if options.Archive {
confirmed, err := confirmArchive(options, selected)
if err != nil || !confirmed {
return err
}
// Archive first. If a later step fails, the same command can run again,
// because it skips a variation that is already archived.
if err := archiveVariations(options.context(), options.Archiver, selected); err != nil {
return err
}
}
if err := detachResources(options, baseline, selected); err != nil {
return err
}

_ = console.Line("Detached resources:")
if options.Archive {
_ = console.Line("Detached and archived resources:")
} else {
_ = console.Line("Detached resources:")
}
for _, resource := range selected {
_ = console.Printf("- %s %s\n", resource.Kind, resource)
}
Expand All @@ -85,9 +129,62 @@ func promptForResources(options Options, synced []syncdomain.ResourceID) ([]sync
Title: resource.String(), Description: string(resource.Kind), Value: resource,
})
}
return syncinteractive.MultiSelect(
options.Input, options.Output, "Select resources to detach", "Detached resources remain in LaunchDarkly.", choices,
)
description := "Detached resources remain in LaunchDarkly."
if options.Archive {
description = "Detached resources are also archived in LaunchDarkly."
}
return syncinteractive.MultiSelect(options.Input, options.Output, "Select resources to detach", description, choices)
}

// confirmArchive asks the user to agree to the archive, unless Yes is set.
func confirmArchive(options Options, selected []syncdomain.ResourceID) (bool, error) {
if options.Yes {
return true, nil
}
console := syncconsole.New(options.Output)
_ = console.Line("Variations to archive in LaunchDarkly:")
for _, resource := range selected {
_ = console.Printf("- %s\n", resource)
}
isTerminal := options.isTerminal
if isTerminal == nil {
isTerminal = syncinteractive.StreamsAreTerminal
}
interactive := !options.NoInput && isTerminal(options.Input, options.Output)
confirmed, err := syncinteractive.Confirm(options.context(), options.Input, options.Output, interactive, archiveQuestion)
if err == nil && !confirmed {
_ = console.Line("Detach canceled.")
}
return confirmed, err
}

// archiveVariations archives each variation that LaunchDarkly still has. It
// reads each variation first, because the API rejects the archive of an
// archived variation. A variation that is already archived or gone does not
// fail, so the same command can run again after a later step fails. It stops
// before the next archive when ctx ends.
func archiveVariations(ctx context.Context, archiver Archiver, selected []syncdomain.ResourceID) error {
for _, resource := range selected {
if err := ctx.Err(); err != nil {
return err
}
configKey, variationKey, err := resource.VariationKeys()
if err != nil {
return err
}
// ReadVariation treats an archived variation as absent.
state, err := archiver.ReadVariation(resource.ProjectKey, configKey, variationKey)
switch {
case syncapi.IsNotFound(err) || err == nil && !state.Exists:
continue
case err != nil:
return fmt.Errorf("read variation %s: %w", resource, err)
}
if err := archiver.ArchiveVariation(resource.ProjectKey, configKey, variationKey); err != nil && !syncapi.IsNotFound(err) {
return fmt.Errorf("archive variation %s: %w", resource, err)
}
}
return nil
}

func validateSelections(synced, selected []syncdomain.ResourceID) error {
Expand Down
Loading
Loading